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
hs_01JC4M8ZQ5T2X7RKD3VW9N6B1F  hosted session
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" }
  • Amounts are never strings and never minor units.
  • At most as many decimal places as the currency allows: 42.5 is valid for GBP, 42.505 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 authorizations concurrently.
  • Keys are remembered for twenty-four hours per account.

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.

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": "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.

StatusTypeWhen
400invalid_request_errorMalformed JSON, a missing Idempotency-Key, an unknown route
401authentication_errorMissing, malformed, revoked or expired key; a request that did not come through the gateway
403permission_errorValid 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
404invalid_request_errorNo such object, or one belonging to another account — deliberately indistinguishable
405invalid_request_errorWrong method for the route
409idempotency_error / invalid_request_errorThe same idempotency key with a different body; a request already in flight; a state conflict
422validation_errorWell-formed but wrong: an unsupported currency, an unknown customer, too many decimal places
429rate_limit_errorThrottled or over quota, always with Retry-After
500api_errorOur fault. Always with a request id
503api_errorThe 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.

Versioning

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.

Built with Sourcey