Skip to content

API Gateway

Conventions & errors

amount means different units in each direction — consistently:

DirectionUnitExample
Requestshuman decimals"10.50"
Responsestoken base units"10500000" (USDC has 6 decimals)
Filters and analyticstoken base unitsmin_amount=1000000

Amounts are always strings. Every payment response carries the token’s decimals, so you can render a base-unit amount without a lookup. Two exceptions: a refund’s phase-1 amount is already in base units, and wallet balances return both scales side by side.

POST /payments and every /{operation}/prepare accept an optional Idempotency-Key header. The contract is stricter than usual — a reused key is rejected, not silently replayed with different terms:

  • Same key, same terms → the original result, with 200 instead of 201. Amounts compare by value, so "1.0" and "1.00" match.
  • Same key, different terms → 422 idempotency_key_reused. A response for terms you did not just send is how a client charges the wrong amount.
  • Key already used by another account → 409.

Use one key per logical order (or per capture attempt), not per process.

Resource ids are UUIDs. On every /payments/{id}/… route, {id} also accepts the payment’s rail0_id — the contract’s bytes32 paymentId — so you can go from an on-chain event to the gateway record directly.

List endpoints share three conventions:

  • Filter with the query parameters each endpoint documents — anything else is ignored or rejected. Multi-value filters take a comma-separated list: status=authorized,expired.
  • Sort with sort=, comma-separated, - for descending: sort=-created_at,status. An unknown field is 422 invalid_sort, naming the allowed set. The default is -created_at.
  • Paginate with page (1-based) and per_page (default 25, max 100).

The body stays a plain array; paging metadata is in the response headers:

HeaderMeaning
x-total-countitems across all pages
x-total-pagespages at this per_page (0 for an empty collection)
x-page, x-per-pagethe page you got
linkRFC 8288 first / prev / next / last links, relative, carrying your filters

GET /payments can also page by cursor, at a constant cost per page however deep you go: pass after= (empty for the first page), then the x-next-cursor header of each response as the next after. The link header’s next carries it too; the last page has neither. This mode walks the default -created_at order and returns no x-total-count. A page or any other sort next to after, or a cursor the endpoint did not issue, is 422 invalid_cursor.

GET /payments?after=&per_page=100
GET /payments?after=<x-next-cursor>&per_page=100

Two tiers: unauthenticated routes (sign-in, nonces, sign-up and claim, the public catalog, payment methods, health) are limited per client IP, 100 requests a minute by default, and an IPv6 client counts by its /64. Authenticated calls are limited per session, 300 a minute by default. POST /signup without a token has its own, much tighter budget.

Every response advertises the budget, so you can slow down before you hit the wall:

HeaderMeaning
RateLimit-Limitrequests allowed in the window
RateLimit-Remainingrequests still available
RateLimit-Resetseconds until the counter resets
RateLimit-Policy<limit>;w=<window>;name="<bucket>": which bucket is speaking

Over the limit the answer is 429 rate_limited with a Retry-After header: the seconds left in the current window, not the window’s length. When a request counts against both tiers, the headers report the one that will refuse first.

GET /blockchains reports each chain’s measured settlement — p50_seconds and p90_seconds from broadcast to confirmation over a trailing week. Size a wait deadline as a small multiple of p90_seconds (e.g. 3×), floored at your own fallback; the percentiles are null until enough payments have settled.

Every error response has exactly three fields:

{
"code": "insufficient_token_balance",
"title": "Not enough balance",
"detail": "The paying wallet does not hold enough of the token to cover this amount. Top it up and retry."
}
  • code — stable and machine-readable. The only field to branch on.
  • title — a short label for a heading or a table cell.
  • detail — one or two sentences you can show a user as-is.

Request validation failures use the same envelope, with code: "validation_failed" and a detail naming the offending field.

FamilyExamplesRaised by
Request and state guardsnot_capturable, amount_exceeds_refundable, not_the_payee, token_expiredthe gateway, before any chain call
Time windowsauthorization_expired, authorization_not_expired, refund_expiredthe gateway, mirroring the contract’s own time checks
rail0 contract errorsnot_payee, already_capturedthe contract’s reverts
Token errorsinsufficient_token_balance, invalid_token_signature, authorization_already_usedthe stablecoin’s EIP-3009 implementation
Broadcast rejectionsinsufficient_gas_funds, nonce_too_low, replacement_underpricedthe chain’s RPC node

The last two families are the ones users hit most: no balance, a signature from the wrong key, a wallet without gas. They arrive classified, with the same codes whether the gateway caught them up front or the chain rejected them.

A transaction that failed — reverted on-chain or never broadcast — carries the same triple as error_code, error_title and error_detail, and the payment records the latest as last_error_code. One rendering path covers both API errors and failed operations.

StatusMeaning
400Malformed request or invalid parameter.
401Missing, invalid, expired or revoked token.
403Authenticated, but not allowed — wrong party, account required, wallet or account deactivated.
404Not found — including resources that exist but that you may not see.
409Conflict — a unique value already taken, or an idempotency key used by another account.
422Understood, but refused by a business rule — the code says which.
429Rate limited.
503A dependency is unavailable (database, job queue, email provider) — the code says which; retry.