Home
Concepts

Core Concepts

Conventions that apply across every endpoint — identifiers, money, idempotency, pagination.

These conventions are shared by every resource in the API, and by the accounting and CRM modules when their public APIs ship.

Identifiers

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 endpoint
Why not Stripe's prefixes

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

Sortable by design

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.

Money

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" }
  • Output always carries the currency's exact number of decimal places: 42.50 is right, 42.5 is wrong.
  • Input is parsed as a decimal string, never a binary float. More decimal places than the currency allows is a 422 naming the limit.
  • Parse amounts with a decimal type in your own code, not a float — every code sample here does the same.

Idempotency

Mandatory on every POST. A request without Idempotency-Key is rejected with 400.

  • The key is any string up to 128 characters — a UUID v4 is a sensible choice.
  • A replay with the same key and the same body returns the stored response with Idempotent-Replay: true. It does not re-execute.
  • A replay with the same key and a different body returns 409 — this catches a caller reusing keys carelessly, in development rather than in production.
  • A request that arrives while the first is still in flight returns 409 with a "request in progress" code, so a retried timeout can't run two authorisations concurrently.

Pagination

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_01JBQ7X2F9KDNW3M8T

The response carries has_more. limit is up to 100, default 25.

Errors

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"
  }
}
StatusTypeWhen
400invalid_request_errorMalformed JSON, unknown parameter, wrong type
401authentication_errorMissing, malformed, revoked or expired credential
403permission_errorValid credential, insufficient scope, or address not allowed
404invalid_request_errorNo such object, or one belonging to another account — deliberately indistinguishable
409idempotency_errorThe same idempotency key reused with a different body
422validation_errorWell-formed but semantically invalid, with one entry per field
402card_errorDeclined, expired, or failed authentication — the only class of error to show a cardholder
429rate_limit_errorThrottled or over quota, always with Retry-After
500 / 503api_errorOur 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.

Versioning

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.