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:
| HTTP | error_code | Meaning |
|---|
| 401 | unauthorized | Authorization header missing |
| 401 | invalid_api_key | API key invalid, revoked, or inactive — or the merchant account is deactivated |
| 401 | UNAUTHORIZED | Merchant identity could not be resolved for the request |
| 401 | MERCHANT_NOT_FOUND | Merchant 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
| HTTP | error_code | Meaning |
|---|
| 403 | TEST_MODE_UNAVAILABLE | sk_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 |
| 403 | RISK_CHECK_FAILED | Blocked by your merchant risk policy |
| 403 | TRANSACTION_BLOCKED | Blocked by fraud/AML screening (reason not disclosed) |
Request and validation
| HTTP | error_code | Meaning |
|---|
| 400 | VALIDATION_ERROR | Invalid 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 |
| 400 | IDEMPOTENCY_KEY_REQUIRED | POST /api/v1/payments called without the Idempotency-Key header |
| 400 | INVALID_ID | Path {id} is not a valid UUID |
| 400 | INVALID_REQUEST | Request rejected — missing or malformed fields; check your integration |
Payments
| HTTP | error_code | Meaning |
|---|
| 402 | PAYMENT_DECLINED | Payment declined immediately — customer not charged. details has transaction_id, status: "failed", reference; the failed payment stays queryable by ID |
| 402 | INSUFFICIENT_FUNDS | Customer’s wallet has insufficient funds |
| 409 | DUPLICATE_REFERENCE | A live (pending/processing/completed) payment with this reference exists. Reuse allowed after failed/cancelled/expired |
| 409 | CONFLICT | Resource state conflict (e.g. idempotency key collision outside the replay path) |
| 422 | INVALID_PAYMENT_DETAILS | Customer payment details were rejected |
| 422 | TRANSACTION_LIMIT_EXCEEDED | Amount exceeds a per-transaction limit |
| 400 | PAYMENT_FAILED | Payment 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
| HTTP | error_code | Meaning |
|---|
| 402 | INSUFFICIENT_BALANCE | Your available balance is too low for this payout — check balance first |
| 400 | PAYOUT_FAILED | Payout creation failed for an unclassified reason |
Account
| HTTP | error_code | Meaning |
|---|
| 400 | UPDATE_FAILED | Profile update (PUT /api/v1/merchants/me) was rejected |
Not found, rate limits, server, upstream
| HTTP | error_code | Meaning |
|---|
| 404 | RESOURCE_NOT_FOUND | Resource not found (or belongs to another merchant) |
| 429 | RATE_LIMIT_EXCEEDED | Over 60 requests/minute per API key — check X-RateLimit-Reset, then retry |
| 500 | INTERNAL_ERROR | Unexpected server-side failure |
| 500 | FEE_CALCULATION_ERROR | Transaction fee could not be computed |
| 502 | PROVIDER_UNAVAILABLE | Temporary upstream outage/timeout — retry with backoff, reusing the same Idempotency-Key |
| 502 | PROVIDER_ERROR | Unclassified 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