These conventions are shared by every resource in the API, and by the accounting and CRM modules when their public APIs ship.
Every object has a prefixed, URL-safe identifier — never a database primary key.
pay_01JBQ7X2F9KDNW3M8T payment
ref_01JBQ7X8T2M0PA9RCE refund
cust_01JBQ7Y1L4WZ2K8QDX customer
pmth_01JBQ7YB3R7VN5HFJ0 payment method
subs_01JBQ7YK2D4NRX8WQT subscription
hs_01JC4M8ZQ5T2X7RKD3VW9N6B1F hosted session
evnt_01JBQ7YM9C1XB6TZQ4 event
hook_01JBQ7Z0K8DFR2WNYS webhook endpointNone of our prefixes are shared with Stripe. An integrator migrating from Stripe holds both sets of identifiers for a while — if the prefixes matched, a Stripe identifier sent to us by mistake would look plausible and fail somewhere deep instead of being rejected up front with a clear "belongs to a different system" message.
The random portion is a ULID, so identifiers order by creation time in an index and in a log — useful when you're eyeballing a support ticket.
Decimal major units as a JSON number, with the currency alongside. £42.50 travels as 42.50 with a currency of GBP — never minor units, never a float you have to divide.
{ "amount": 42.50, "currency": "GBP" }42.5 is valid for GBP, 42.505 is a 422 naming the limit.Mandatory on every POST. A request without Idempotency-Key is rejected with 400.
Idempotent-Replay: true. It does not re-execute.409 — this catches a caller reusing keys carelessly, in development rather than in production.409 with a "request in progress" code, so a retried timeout can't run two authorizations concurrently.The practical rule: generate the key when you decide to take the payment, store it with the order, and reuse it on every retry of that decision.
Cursor-based, not offset — a ledger that's being written to while it's read doesn't tolerate offset pagination.
GET /v1/payments?limit=25
GET /v1/payments?limit=25&starting_after=pay_01JBQ7X2F9KDNW3M8TThe response carries has_more. limit is up to 100, default 25.
One envelope, from every endpoint, for every failure — including gateway-level rejections. An integrator should never be able to tell whether the edge or the origin rejected them.
{
"error": {
"type": "validation_error",
"code": "parameter_invalid",
"message": "The amount has more decimal places than GBP allows.",
"param": "amount",
"request_id": "req_01JBQ8ZR4T7YC1N0",
"errors": [ { "param": "amount", "message": "The amount has more decimal places than GBP allows." } ]
}
}type is the class, code the specific reason, message is for your logs and never for your customer, param names the field at fault when there is one. A validation_error carries errors with one entry per field.
| Status | Type | When |
|---|---|---|
| 400 | invalid_request_error | Malformed JSON, a missing Idempotency-Key, an unknown route |
| 401 | authentication_error | Missing, malformed, revoked or expired key; a request that did not come through the gateway |
| 403 | permission_error | Valid key, wrong environment, insufficient scope, or address not on the allowlist; usage_plan_missing in an account's first few minutes, which is safe to retry |
| 404 | invalid_request_error | No such object, or one belonging to another account — deliberately indistinguishable |
| 405 | invalid_request_error | Wrong method for the route |
| 409 | idempotency_error / invalid_request_error | The same idempotency key with a different body; a request already in flight; a state conflict |
| 422 | validation_error | Well-formed but wrong: an unsupported currency, an unknown customer, too many decimal places |
| 429 | rate_limit_error | Throttled or over quota, always with Retry-After |
| 500 | api_error | Our fault. Always with a request id |
| 503 | api_error | The acquirer or a dependency is unavailable. Safe to retry with the same idempotency key |
A declined or failed attempt is still a payment object. POST /v1/payments and a 3-D Secure confirmation return that object with HTTP 402 when its status is declined or failed; inspect status and decline_code. Retrieving the payment with GET /v1/payments/{id} returns HTTP 200, including for a failed or declined payment. Choose the message you show the cardholder from decline_code, never from message.
The request_id on every error, and the Paayed-Request-Id header on every response, is what to quote in a support conversation — it traces the full lifecycle of that one request.
The major version lives in the path (/v1). Within that, each key is pinned to the dated version current when it was created, and the version that served a request is in the Paayed-Version response header — additive changes ship to everyone immediately, behavioral changes ship under a new date, and you upgrade deliberately in the dashboard. There is one version so far; overriding a key's pin for a single request arrives with the second.