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, the sentinel name in lower snake case (e.g.INVALID_INPUTbecomes"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.
Error bodies don’t include a trace ID or timestamp. To report a problem, send support the response Date header, the X-RateLimit-* headers, your merchant ID, the endpoint, and the approximate time of the request.
Authentication failures use a different shape. The envelope above
is what the API returns for most errors. Requests rejected before they
are processed (a missing or invalid X-Signature, an 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 is
no numeric code and no details. Branch on the
HTTP status first, then read message. Example (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, PAYMENT_METHOD_NOT_SUPPORTED, AMOUNT_BELOW_MINIMUM | Bad request — look at details |
| 401 | INVALID_CREDENTIALS, INVALID_TOKEN, TOKEN_EXPIRED, INVALID_SIGNATURE (B2B); SESSION_EXPIRED (dashboard only) | Auth failed — bad key, expired timestamp, wrong signature. Dashboard sign-in codes aren’t relevant to B2B integrations. |
| 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 | Resource doesn’t exist (or doesn’t exist for this merchant) |
| 409 | 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, EXTERNAL_SERVICE_ERROR | Our fault; safe to retry with backoff. |
| 503 | SERVICE_UNAVAILABLE | A downstream dependency is down. Retry with backoff |
Full code reference
The code values you may see:
Authentication (401)
INVALID_CREDENTIALS— API key combination rejectedINVALID_TOKEN— token unparseable or tamperedTOKEN_EXPIRED— token expiredSESSION_EXPIRED— dashboard session expiredINVALID_SIGNATURE— HMAC signature mismatch on B2B / webhook calls
Authorization (403)
FORBIDDEN— authenticated, but you aren’t allowed to perform this actionIP_BLOCKED— requests from this IP address are blocked
Not found (404)
NOT_FOUND— genericRECORD_NOT_FOUND— row missing for the given ID
Conflict (409)
ALREADY_EXISTS— generic
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/rangePAYMENT_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 the native token to pay the network fee (gas) for the transfer.
Payment / billing (402)
INSUFFICIENT_CREDIT— merchant prepaid balance can’t cover the network fee (gas) and platform fee. Top up via the dashboard, then retry
Rate limiting (429)
TOO_MANY_REQUESTS— IP rate limit exceededTOO_MANY_ATTEMPTS— repeated failed attempts on the same resource (e.g. OTP) tripped a throttle
Server errors (500)
EXTERNAL_SERVICE_ERROR— a third-party provider failedINTERNAL_SERVER_ERROR— unexpected error; contact support with the responseDateheader andX-RateLimit-*headers
Availability (503)
SERVICE_UNAVAILABLE— the service is temporarily unavailable; retry with backoff
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 has three common causes:
- 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 |
|---|---|
All API routes (incl. /b2b/v1/*) | Per IP address — 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 included on
every rate-limited response, not just successful ones. 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 may change. If you reach them with legitimate traffic (for example, reconciling a large historical range), contact support.
Insufficient credit (402)
INSUFFICIENT_CREDIT (HTTP 402 Payment Required) means your
prepaid balance can’t cover the network fee (gas) and platform fee for the
operation you tried to perform, typically settling a crypto payment
or an on-chain action whose network fee is sponsored. Top up from the merchant
dashboard (Billing → Add credit), then retry the operation.
Operations already in progress continue automatically once the balance
is topped up.
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 find the request.
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 retried with exponential backoff at 0s, 1min, 5min, 15min, 1h, 6h (six attempts total, the 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 marked “Failed” in the dashboard, and you can replay it manually once your server is healthy.