Skip to main content
Every error response uses the standard envelope. error_code is stable and machine-readable — branch on it, never on message:
{
  "status": "error",
  "code": 409,
  "error_code": "DUPLICATE_REFERENCE",
  "message": "A payment with this reference already exists for this merchant...",
  "details": {
    "existing_transaction_id": "f5d238bd-f8ab-4379-9832-0f1ce6d65cbe",
    "existing_status": "pending",
    "reference": "ORDER_123"
  }
}
details is optional. For request-body validation failures it is an array of field-level errors ([{field, message}], e.g. {"field": "customer.email", "message": "must be a valid email"}); for payment outcomes it carries context such as transaction_id or existing_transaction_id. Other errors omit it.

Authentication

Authentication failures are rejected before your request reaches an endpoint:
HTTPerror_codeMeaning
401unauthorizedAuthorization header missing
401invalid_api_keyAPI key invalid, revoked, or inactive — or the merchant account is deactivated
401UNAUTHORIZEDMerchant identity could not be resolved for the request
401MERCHANT_NOT_FOUNDMerchant account for this key not found
The two lowercase codes come from the authentication layer and omit details. Treat any 401 as “stop and fix credentials” — do not retry.

Access control

HTTPerror_codeMeaning
403TEST_MODE_UNAVAILABLEsk_test_* key on a money-moving endpoint while test mode is not enabled on the platform. Test keys never move real money; payouts are always blocked for test keys
403RISK_CHECK_FAILEDBlocked by your merchant risk policy
403TRANSACTION_BLOCKEDBlocked by fraud/AML screening (reason not disclosed)

Request and validation

HTTPerror_codeMeaning
400VALIDATION_ERRORInvalid body or params — bad phone, unsupported currency, invalid network, Idempotency-Key over 255 chars. For body-binding failures details lists {field, message}; other validation errors carry the reason in message only
400IDEMPOTENCY_KEY_REQUIREDPOST /api/v1/payments called without the Idempotency-Key header
400INVALID_IDPath {id} is not a valid UUID
400INVALID_REQUESTRequest rejected — missing or malformed fields; check your integration

Payments

HTTPerror_codeMeaning
402PAYMENT_DECLINEDPayment declined immediately — customer not charged. details has transaction_id, status: "failed", reference; the failed payment stays queryable by ID
402INSUFFICIENT_FUNDSCustomer’s wallet has insufficient funds
409DUPLICATE_REFERENCEA live (pending/processing/completed) payment with this reference exists. Reuse allowed after failed/cancelled/expired
409CONFLICTResource state conflict (e.g. idempotency key collision outside the replay path)
422INVALID_PAYMENT_DETAILSCustomer payment details were rejected
422TRANSACTION_LIMIT_EXCEEDEDAmount exceeds a per-transaction limit
400PAYMENT_FAILEDPayment creation failed for an unclassified reason — including an amount below the 500 TZS minimum (TZS mobile) and missing card-payment fields (redirect_url, cancel_url, billing address)

Payouts

HTTPerror_codeMeaning
402INSUFFICIENT_BALANCEYour available balance is too low for this payout — check balance first
400PAYOUT_FAILEDPayout creation failed for an unclassified reason

Account

HTTPerror_codeMeaning
400UPDATE_FAILEDProfile update (PUT /api/v1/merchants/me) was rejected

Not found, rate limits, server, upstream

HTTPerror_codeMeaning
404RESOURCE_NOT_FOUNDResource not found (or belongs to another merchant)
429RATE_LIMIT_EXCEEDEDOver 60 requests/minute per API key — check X-RateLimit-Reset, then retry
500INTERNAL_ERRORUnexpected server-side failure
500FEE_CALCULATION_ERRORTransaction fee could not be computed
502PROVIDER_UNAVAILABLETemporary upstream outage/timeout — retry with backoff, reusing the same Idempotency-Key
502PROVIDER_ERRORUnclassified upstream error — not automatically retryable. Also returned when a status refresh (POST .../{id}/refresh) fails upstream; the refresh itself is safe to retry

Handling exceptions

Branch on error_code, fall back on the HTTP class:
const res = await fetch("https://meet.briq.tz/api/v1/payments", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  },
  body: JSON.stringify(paymentRequest),
});
const body = await res.json();

if (body.status === "error") {
  switch (body.error_code) {
    case "PAYMENT_DECLINED":
    case "INSUFFICIENT_FUNDS":
      // Terminal for this attempt — ask the customer to try another method.
      // body.details.transaction_id stays queryable for reconciliation.
      break;
    case "DUPLICATE_REFERENCE":
      // Already have a live payment for this order — fetch it instead.
      // GET /api/v1/payments/{body.details.existing_transaction_id}
      break;
    case "PROVIDER_UNAVAILABLE":
    case "RATE_LIMIT_EXCEEDED":
      // Transient — retry with backoff, SAME Idempotency-Key.
      break;
    case "VALIDATION_ERROR":
      // body.details = [{field, message}] — surface to your form/logs.
      break;
    default:
      if (res.status === 401 || res.status === 403) {
        // Credentials or permissions — alert, don't retry.
      }
  }
}

Retry rules

  • Retry with the same Idempotency-Key: timeouts, 429, 502 PROVIDER_UNAVAILABLE, 5xx. A retry that finds the original payment returns 200 with it — never a duplicate charge.
  • Fix the request first: 400, 401, 403, 404, 422.
  • Don’t retry as-is: 402 PAYMENT_DECLINED / INSUFFICIENT_FUNDS (ask the customer to use another method), 402 INSUFFICIENT_BALANCE (top up before retrying the payout), 409 DUPLICATE_REFERENCE (use a new reference, or check details.existing_transaction_id).
Create mobile payment · Create card payment · Create dynamic QR · Payouts · Idempotency