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
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.50 is right, 42.5 is wrong.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 authorisations concurrently.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": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "The card was declined because of insufficient funds.",
"payment_id": "pay_01JBQ7X2F9KDNW3M8T",
"request_id": "req_01JBQ8ZR4T7YC1N0",
"doc_url": "https://developers.paayed.com/errors/card_declined"
}
}| Status | Type | When |
|---|---|---|
| 400 | invalid_request_error | Malformed JSON, unknown parameter, wrong type |
| 401 | authentication_error | Missing, malformed, revoked or expired credential |
| 403 | permission_error | Valid credential, insufficient scope, or address not allowed |
| 404 | invalid_request_error | No such object, or one belonging to another account — deliberately indistinguishable |
| 409 | idempotency_error | The same idempotency key reused with a different body |
| 422 | validation_error | Well-formed but semantically invalid, with one entry per field |
| 402 | card_error | Declined, expired, or failed authentication — the only class of error to show a cardholder |
| 429 | rate_limit_error | Throttled or over quota, always with Retry-After |
| 500 / 503 | api_error | Our fault, or the acquirer unavailable. 503 is safe to retry with the same idempotency key |
The request_id on every response and every error 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, your account is pinned to a dated minor version from the moment your first key is created — additive changes ship to everyone immediately, behavioural changes ship under a new date, and you upgrade deliberately in the dashboard. Override the pin for a single request with a Paayed-Version header to test an upgrade before committing to it.