Errors
Every 4xx/5xx response carries the same JSON envelope:
{
"code": 400,
"message": "invalid_input",
"details": [
{ "field": "items[0].unit_price", "message": "must be a positive decimal string" }
]
}code— the numeric HTTP status (400,401,404, …). Useful for generic HTTP-layer handling, but for branching logic switch onmessageinstead —codewon’t disambiguate between, say,invalid_inputandpayment_method_not_supported(both 400).message— the lower-snake-case sentinel name, derived from the internalerrors.goconstant (e.g.INVALID_INPUT → "invalid_input"). Stable across releases — switch on this.details— populated on validation errors. Array of{ field, message }objects so the client can pin errors to inputs. Omitted otherwise.
We don’t currently return trace_id or timestamp in the body. Earlier drafts of this page promised both — that was aspirational. If you need to correlate a server log to a request, capture the response Date header and the gateway-side rate-limit headers (X-RateLimit-*) and quote those in a support ticket.
Gateway-edge rejections use a different shape. The envelope above
is what the backend services emit. Requests rejected at the gateway
before reaching a service — a missing/invalid X-Signature,
unknown X-Client-ID, or a stale X-Timestamp on a /b2b/v1/*
call — come back as { "error": "...", "message": "..." }, where
error is a coarse slug (unauthorized / bad_request /
service_unavailable) and message carries the specifics. There’s
no numeric code and no details. So a verifier should branch on
HTTP status first, then read message, and only treat
code/details as present once the request made it past the
gateway. Example gateway body (401):
{ "error": "unauthorized", "message": "invalid signature" }HTTP status → typical codes
| HTTP | Typical code values | What it means |
|---|---|---|
| 400 | INVALID_INPUT, MISSING_REQUIRED, INVALID_FORMAT, INVALID_LENGTH, INVALID_VALUE, INVALID_USER_STATUS, INVALID_USER_ROLE, PAYMENT_METHOD_NOT_SUPPORTED, AMOUNT_BELOW_MINIMUM | Bad request — look at details |
| 401 | INVALID_CREDENTIALS, INVALID_TOKEN, TOKEN_EXPIRED, INVALID_SIGNATURE (B2B); INVALID_OTP, OTP_EXPIRED, SESSION_EXPIRED (dashboard / JWT only) | Auth failed — bad key, expired timestamp, wrong signature. The OTP/SESSION_EXPIRED codes only surface on dashboard-JWT routes (/payment/v1/*); pure B2B integrations won’t see them. |
| 402 | INSUFFICIENT_CREDIT | Merchant prepaid balance ran out — top up before retrying |
| 403 | FORBIDDEN, IP_BLOCKED | Key is valid but lacks the scope/IP allowance for this call |
| 404 | NOT_FOUND, RECORD_NOT_FOUND, USER_NOT_FOUND, SESSION_NOT_FOUND | Resource doesn’t exist (or doesn’t exist for this merchant) |
| 409 | ALREADY_EXISTS, USER_ALREADY_EXISTS, SESSION_ALREADY_EXISTS | Idempotency replay with a different body, or state machine refused the transition |
| 429 | TOO_MANY_REQUESTS, TOO_MANY_ATTEMPTS | Rate limit hit; back off and retry |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_CONNECTION_ERROR, DATABASE_QUERY_ERROR, DATABASE_TRANSACTION_ERROR, REDIS_CONNECTION_ERROR, REDIS_OPERATION_ERROR, EXTERNAL_SERVICE_ERROR | Our fault; safe to retry with backoff. (Provider-specific codes like TWILIO_SERVICE_ERROR / SENDGRID_SERVICE_ERROR exist internally but only surface on dashboard-side notification flows, not B2B endpoints.) |
| 503 | SERVICE_UNAVAILABLE | A downstream dependency is down. Retry with backoff |
Full code reference
The full set of code values you may see (matches payment-service/pkg/errors/errors.go):
Auth & sessions (401)
INVALID_CREDENTIALS— username/password or API key combo rejectedINVALID_TOKEN— JWT/session token unparseable or tamperedTOKEN_EXPIRED— JWT pastexpINVALID_OTP— OTP doesn’t matchOTP_EXPIRED— OTP issued > tolerance window agoSESSION_EXPIRED— dashboard session aged outINVALID_SIGNATURE— HMAC signature mismatch on B2B / webhook calls
Authorization (403)
FORBIDDEN— authenticated but role/scope/merchant boundary blocks the actionIP_BLOCKED— IP is on the abuse list
Not found (404)
NOT_FOUND— genericRECORD_NOT_FOUND— row missing for the given IDUSER_NOT_FOUND— user lookup failedSESSION_NOT_FOUND— dashboard session id unrecognised
Conflict (409)
ALREADY_EXISTS— genericUSER_ALREADY_EXISTS— signup hit a unique-constraintSESSION_ALREADY_EXISTS— duplicate session insert
Validation (400)
INVALID_INPUT— generic; checkdetailsMISSING_REQUIRED— a required field was absentINVALID_FORMAT— value didn’t match the expected format (e.g. UUID, URL, email)INVALID_LENGTH— value too short or too longINVALID_VALUE— value out of allowed enum/rangeINVALID_USER_STATUS— user is in a state that disallows the actionINVALID_USER_ROLE— role lacks permission for the actionPAYMENT_METHOD_NOT_SUPPORTED— provider/asset combo isn’t enabled for the merchantAMOUNT_BELOW_MINIMUM— order amount below the per-network or env-level floor. The response’sdetails.floor_usdcarries the configured floor (USD) so you can surface it directly; themessagetext states it too.INSUFFICIENT_BALANCE— the buyer’s wallet doesn’t hold enough of the pay asset to cover the transfer.INSUFFICIENT_GAS— the buyer’s wallet lacks native gas to broadcast the transfer.
Payment / billing (402)
INSUFFICIENT_CREDIT— merchant prepaid balance can’t cover the gas / platform fee. Top up via the dashboard, then retry
Rate limiting (429)
TOO_MANY_REQUESTS— gateway IP rate-limitTOO_MANY_ATTEMPTS— repeated failed attempts on the same resource (e.g. OTP) tripped a throttle
Storage / infrastructure (500)
DATABASE_CONNECTION_ERROR— couldn’t reach the DBDATABASE_QUERY_ERROR— query plan failed at runtimeDATABASE_TRANSACTION_ERROR— commit/rollback failedREDIS_CONNECTION_ERROR— couldn’t reach RedisREDIS_OPERATION_ERROR— Redis command failedEXTERNAL_SERVICE_ERROR— generic third-party failure (provider not bucketed below)TWILIO_SERVICE_ERROR— Twilio SMS / Verify call failedSENDGRID_SERVICE_ERROR— SendGrid mail send failedINTERNAL_SERVER_ERROR— unexpected fall-through; capture the responseDateheader +X-RateLimit-*and reach out
Availability (503)
SERVICE_UNAVAILABLE— a critical downstream is reporting unhealthy; backoff + retry
Validation errors (400)
When the issue is a malformed request body, details is an array so
you can map errors back to fields:
{
"code": 400,
"message": "invalid_input",
"details": [
{ "field": "items[0].unit_price", "message": "must be a positive decimal string" },
{ "field": "success_url", "message": "must be a valid https URL" }
]
}Auth errors (401)
INVALID_SIGNATURE covers three distinct failure modes — the only way
to tell them apart is to bisect:
- Wrong secret key (re-check env var)
- Timestamp drift > 5 min (sync NTP)
- Wrong canonical string (most often: forgot
\nseparators, or signed a parsed-then-re-stringified body that differs from what you sent)
See Authentication for the exact signing algorithm.
Rate limits (429)
| Surface | Limit |
|---|---|
Every gateway route (incl. /b2b/v1/*) | Per IP at the gateway — 500 req/min, shared bucket. Not per-merchant. |
Public checkout (/checkout/*) | Stricter sub-bucket — 20 req/min per IP |
X-RateLimit-Limit and X-RateLimit-Remaining are emitted on
every gateway-rate-limited response (not just successes). Treat
them as the live budget for your IP.
Rate-limited responses include a Retry-After header (seconds until
the window resets) — honor it. As a fallback, back off with jitter —
1s base + exponential to 30s.
Rate limits are subject to change. If you’re hitting them legitimately (e.g., reconciling a large historical range), reach out — bulk endpoints are on the roadmap.
Insufficient credit (402)
INSUFFICIENT_CREDIT (HTTP 402 Payment Required) means the merchant’s
prepaid balance can’t cover the next gas-and-platform fee for the
operation you tried to perform — typically settling a crypto payment
or executing a gas-sponsored on-chain action. Top up from the merchant
dashboard (Billing → Add credit), then retry the operation;
in-flight work waits and picks up automatically once the balance
clears.
Server errors (5xx)
A 500 means we couldn’t process the request. Retry with backoff —
your idempotency_key ensures you won’t double-charge if the original
request did partially succeed.
If retries don’t recover within a minute, surface a generic “payment
temporarily unavailable” to the buyer and reach out to support with
the failing endpoint, your merchant ID, the response Date header and
X-RateLimit-* values, and the approximate request time — that’s
enough for us to pivot to the relevant request in our logs.
Webhook delivery errors
Webhook deliveries are a separate failure channel — they don’t surface as API errors because your server isn’t the one calling. When a delivery returns a non-2xx (or times out), it’s queued for exponential-backoff retry at 0s, 1min, 5min, 15min, 1h, 6h (six attempts total — same schedule as Webhooks → Overview). The full per-delivery history shows up under Developers → Webhooks → [endpoint] → Delivery log in the dashboard. After the sixth attempt the delivery is dead-lettered — the dashboard surfaces a “Failed” badge you can manually replay once your server is healthy.