API Gateway
Conventions & errors
Amounts
Section titled “Amounts”amount means different units in each direction — consistently:
| Direction | Unit | Example |
|---|---|---|
| Requests | human decimals | "10.50" |
| Responses | token base units | "10500000" (USDC has 6 decimals) |
| Filters and analytics | token base units | min_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.
Idempotency
Section titled “Idempotency”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
200instead of201. 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.
Identifiers
Section titled “Identifiers”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.
Filtering, sorting and pagination
Section titled “Filtering, sorting and pagination”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 is422 invalid_sort, naming the allowed set. The default is-created_at. - Paginate with
page(1-based) andper_page(default 25, max 100).
The body stays a plain array; paging metadata is in the response headers:
| Header | Meaning |
|---|---|
x-total-count | items across all pages |
x-total-pages | pages at this per_page (0 for an empty collection) |
x-page, x-per-page | the page you got |
link | RFC 8288 first / prev / next / last links, relative, carrying your filters |
Cursor paging
Section titled “Cursor paging”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=100GET /payments?after=<x-next-cursor>&per_page=100Rate limits
Section titled “Rate limits”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:
| Header | Meaning |
|---|---|
RateLimit-Limit | requests allowed in the window |
RateLimit-Remaining | requests still available |
RateLimit-Reset | seconds 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.
Settlement time
Section titled “Settlement time”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.
Errors
Section titled “Errors”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.
Where errors come from
Section titled “Where errors come from”| Family | Examples | Raised by |
|---|---|---|
| Request and state guards | not_capturable, amount_exceeds_refundable, not_the_payee, token_expired | the gateway, before any chain call |
| Time windows | authorization_expired, authorization_not_expired, refund_expired | the gateway, mirroring the contract’s own time checks |
| rail0 contract errors | not_payee, already_captured | the contract’s reverts |
| Token errors | insufficient_token_balance, invalid_token_signature, authorization_already_used | the stablecoin’s EIP-3009 implementation |
| Broadcast rejections | insufficient_gas_funds, nonce_too_low, replacement_underpriced | the 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.
Failed transactions
Section titled “Failed transactions”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.
Status codes
Section titled “Status codes”| Status | Meaning |
|---|---|
400 | Malformed request or invalid parameter. |
401 | Missing, invalid, expired or revoked token. |
403 | Authenticated, but not allowed — wrong party, account required, wallet or account deactivated. |
404 | Not found — including resources that exist but that you may not see. |
409 | Conflict — a unique value already taken, or an idempotency key used by another account. |
422 | Understood, but refused by a business rule — the code says which. |
429 | Rate limited. |
503 | A dependency is unavailable (database, job queue, email provider) — the code says which; retry. |