Skip to Content

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 on message instead — code won’t disambiguate between, say, invalid_input and payment_method_not_supported (both 400).
  • message — the lower-snake-case sentinel name, derived from the internal errors.go constant (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

HTTPTypical code valuesWhat it means
400INVALID_INPUT, MISSING_REQUIRED, INVALID_FORMAT, INVALID_LENGTH, INVALID_VALUE, INVALID_USER_STATUS, INVALID_USER_ROLE, PAYMENT_METHOD_NOT_SUPPORTED, AMOUNT_BELOW_MINIMUMBad request — look at details
401INVALID_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.
402INSUFFICIENT_CREDITMerchant prepaid balance ran out — top up before retrying
403FORBIDDEN, IP_BLOCKEDKey is valid but lacks the scope/IP allowance for this call
404NOT_FOUND, RECORD_NOT_FOUND, USER_NOT_FOUND, SESSION_NOT_FOUNDResource doesn’t exist (or doesn’t exist for this merchant)
409ALREADY_EXISTS, USER_ALREADY_EXISTS, SESSION_ALREADY_EXISTSIdempotency replay with a different body, or state machine refused the transition
429TOO_MANY_REQUESTS, TOO_MANY_ATTEMPTSRate limit hit; back off and retry
500INTERNAL_SERVER_ERROR, DATABASE_CONNECTION_ERROR, DATABASE_QUERY_ERROR, DATABASE_TRANSACTION_ERROR, REDIS_CONNECTION_ERROR, REDIS_OPERATION_ERROR, EXTERNAL_SERVICE_ERROROur 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.)
503SERVICE_UNAVAILABLEA 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 rejected
  • INVALID_TOKEN — JWT/session token unparseable or tampered
  • TOKEN_EXPIRED — JWT past exp
  • INVALID_OTP — OTP doesn’t match
  • OTP_EXPIRED — OTP issued > tolerance window ago
  • SESSION_EXPIRED — dashboard session aged out
  • INVALID_SIGNATURE — HMAC signature mismatch on B2B / webhook calls

Authorization (403)

  • FORBIDDEN — authenticated but role/scope/merchant boundary blocks the action
  • IP_BLOCKED — IP is on the abuse list

Not found (404)

  • NOT_FOUND — generic
  • RECORD_NOT_FOUND — row missing for the given ID
  • USER_NOT_FOUND — user lookup failed
  • SESSION_NOT_FOUND — dashboard session id unrecognised

Conflict (409)

  • ALREADY_EXISTS — generic
  • USER_ALREADY_EXISTS — signup hit a unique-constraint
  • SESSION_ALREADY_EXISTS — duplicate session insert

Validation (400)

  • INVALID_INPUT — generic; check details
  • MISSING_REQUIRED — a required field was absent
  • INVALID_FORMAT — value didn’t match the expected format (e.g. UUID, URL, email)
  • INVALID_LENGTH — value too short or too long
  • INVALID_VALUE — value out of allowed enum/range
  • INVALID_USER_STATUS — user is in a state that disallows the action
  • INVALID_USER_ROLE — role lacks permission for the action
  • PAYMENT_METHOD_NOT_SUPPORTED — provider/asset combo isn’t enabled for the merchant
  • AMOUNT_BELOW_MINIMUM — order amount below the per-network or env-level floor. The response’s details.floor_usd carries the configured floor (USD) so you can surface it directly; the message text 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-limit
  • TOO_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 DB
  • DATABASE_QUERY_ERROR — query plan failed at runtime
  • DATABASE_TRANSACTION_ERROR — commit/rollback failed
  • REDIS_CONNECTION_ERROR — couldn’t reach Redis
  • REDIS_OPERATION_ERROR — Redis command failed
  • EXTERNAL_SERVICE_ERROR — generic third-party failure (provider not bucketed below)
  • TWILIO_SERVICE_ERROR — Twilio SMS / Verify call failed
  • SENDGRID_SERVICE_ERROR — SendGrid mail send failed
  • INTERNAL_SERVER_ERROR — unexpected fall-through; capture the response Date header + 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 \n separators, or signed a parsed-then-re-stringified body that differs from what you sent)

See Authentication for the exact signing algorithm.

Rate limits (429)

SurfaceLimit
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.