Error handling
The problem-document format, the full slug catalogue, and which errors are worth retrying.
Every error is an RFC 7807 problem document with content type application/problem+json. Errors are not wrapped in the data/meta envelope.
{
"type": "urn:qup:error:checkout-reservation-expired",
"title": "reservation has expired",
"status": 404,
"detail": "the reservation has expired, please place a new one",
"instance": "urn:qup:correlation:0f4c8e1a-9b2d-4c7e-8a1f-2d3b4c5e6f70"
}| Field | Always? | Use it for |
|---|---|---|
status | Yes | The HTTP status, repeated in the body. |
title | Yes | Human-readable summary. Do not branch on it — it is prose and gets reworded. |
type | No | urn:qup:error:<slug>. The stable, machine-readable identity of the failure. Branch on this. |
detail | No | A safe explanation of the specific failure, suitable for showing a developer. |
instance | Yes | urn:qup:correlation:<uuid>. Log it. Quote it when you report a problem. |
When type is absent
type is absenttype and detail accompany a named failure — one the platform can describe safely. Two
kinds of response arrive without them:
- Unexpected server faults. A
500carrying only a title means the cause was not something we
can put in a response body. Quote theinstancecorrelation id and we can trace it. - Orders and tickets. The fulfillment routes answer with
status,titleandinstance. Branch on the HTTP status there, and ontypeelsewhere.
Raw infrastructure errors are never echoed back, so a missing detail means the underlying cause
was not safe to share rather than that the response is incomplete. The correlation id is the way
in.
Status codes and what they mean here
| Status | Meaning in this API | Retry? |
|---|---|---|
400 | Malformed request — bad body, missing required field, unparseable date, missing Idempotency-Key | No. Fix the request. |
401 | Missing or invalid credential — yours, or one required by a connected external system | No. Re-authenticate, then retry once. |
403 | Valid token, insufficient scope, or credentials bound to another campaign | No. Contact us. |
404 | Resource absent — also returned when a resource exists but is not yours | No. |
409 | State conflict — idempotency collision, code already redeemed, tickets not ready yet | Yes, with backoff. |
410 | Voucher permanently exhausted | No. |
422 | Well-formed but cannot be processed — unavailable product, expired reservation, date too close | No. Change the request. |
429 | Throttled (voucher validation is rate-limited) | Yes. Respect Retry-After. |
500 | Unexpected server fault | Yes. |
502 | A downstream system — PSP, ticket provider, loyalty partner — failed or rejected | Yes, cautiously. See below. |
504 | Upstream timeout, e.g. catalog search | Yes. |
404 hides ownership on purpose
404 hides ownership on purposeWhere a resource belongs to a specific customer — a fulfillment order, a checkout bound to a signed-in user — asking for someone else's returns 404, not 403. The two cases are indistinguishable by design, so order ids cannot be enumerated across users. Do not read a 404 as proof the id is wrong.
The exception is guest checkouts: a checkout created without a session has no owner, so the checkout_id itself is the capability. Anyone holding it can read it. Treat a guest checkout_id as a secret.
502 needs thought, not blind retries
502 needs thought, not blind retriesA 502 means we reached a third party and it failed. Whether a retry is safe depends on the endpoint: on a read it always is; on confirm checkout the safe retry is the same request with the same Idempotency-Key, which either replays the original result or re-attempts cleanly. Never re-issue a confirm with a fresh key after a 502 — that is how you double-charge someone.
Retry policy
Retry only 429, 500, 502, 503 and 504. Use truncated exponential backoff with full jitter:
delay = min(30s, 1s * 2^attempt) * random(0, 1)
| Attempt | Max delay before jitter |
|---|---|
| 1 | 2 s |
| 2 | 4 s |
| 3 | 8 s |
| 4 | 16 s |
| 5 | 30 s (capped) |
Stop after 5 attempts and surface the failure. When a Retry-After header is present, honour it and ignore the formula for that interval — it is a real signal, not a guess. For anything carrying an Idempotency-Key, retry with the same key; see Idempotency.
Slug catalogue
Every type a retailer-facing endpoint can return, grouped by area. The slug is the part after urn:qup:error:.
Checkout
| Slug | Status | Meaning |
|---|---|---|
checkout-not-found | 404 | No checkout with that id |
checkout-reservation-expired | 404 | The 30-minute reservation window elapsed |
checkout-reservation-invalid | 422 | Reservation no longer valid — start a new checkout |
checkout-basket-empty | 400 | No product lines submitted |
checkout-basket-line-invalid | 400 | A line requests an invalid quantity |
checkout-basket-spans-providers | 400 | Basket mixes products from different fulfillment providers |
checkout-basket-spans-merchants | 400 | Basket mixes products from different merchants |
checkout-date-in-past | 400 | Requested visit date is before today |
checkout-shipping-address-required | 400 | Basket has a physical product; address fields are incomplete |
checkout-timeslot-required | 400 | A product needs a time of day, not just a date |
checkout-locale-invalid | 400 | Accept-Language missing or unusable |
checkout-product-not-priceable | 422 | A product is unavailable for the requested date |
checkout-product-not-fulfillable | 422 | A product cannot currently be fulfilled |
checkout-billing-detail-rejected | 422 | Billing/shipping details rejected — unknown country, invalid e-mail |
checkout-fulfillment-request-rejected | 422 | The ticket provider refused the booking |
checkout-order-failed | 502 | The payment system could not confirm the order |
checkout-order-empty-redirect | 502 | The payment system returned no payment URL |
checkout-reservation-placement-failed | 502 | Reservation could not be placed downstream |
checkout-fulfillment-reservation-failed | 502 | Fulfillment could not be started |
checkout-discount-resolution-failed | 502 | Discounts could not be resolved |
checkout-basket-pricing-failed | 500 | Our pricing failed — not your request |
checkout-discount-allocation-failed | 500 | Discount could not be applied to the basket |
checkout-merchant-lookup-failed | 500 | Merchant details could not be resolved |
checkout-debtor-code-unresolved | 500 | The settlement account for the basket could not be resolved |
checkout-product-classification-failed | 500 | Basket classification failed |
campaign-not-found | 404 | The campaign is not configured — tell us |
Sessions and authentication
| Slug | Status | Meaning |
|---|---|---|
authorization-code-not-found | 404 | Code never existed or expired |
authorization-code-already-redeemed | 409 | Code already spent |
authorization-challenge-failed | 401 / 502 | Magic-link challenge could not be created or exchanged |
refresh-token-invalid | 401 | Refresh token expired, revoked or malformed — re-authenticate |
identity-invalid | 401 | Partner identity proof is invalid |
identity-token-expired | 401 | Partner identity proof expired |
identity-not-allowed | 401 | Identity issuer is not on the allow-list |
identity-verify-failed | 403 | Identity could not be verified |
identity-provider-unavailable | 502 | Partner identity provider is down |
session-mint-failed | 500 | Session could not be created |
user-magic-link-send-failed | 502 | Magic-link e-mail could not be sent |
user-no-callback-url | 400 | No usable callback_url could be resolved |
Catalog
| Slug | Status | Meaning |
|---|---|---|
merchant-not-found | 404 | No such merchant in this campaign |
product-not-found | 404 | No such product |
merchant-search-timed-out | 504 | Search backend timed out — retry |
merchant-search-failed | 502 | Search backend failed |
merchant-internal-error, product-internal-error | 500 | Server-side fault — retry, then quote the correlation id |
Vouchers
| Slug | Status | Meaning |
|---|---|---|
voucher-not-found | 404 | Unknown code in this campaign |
voucher-revoked | 409 | Code was revoked |
voucher-exhausted | 410 | No redemptions remaining |
voucher-not-yet-valid | 422 | Before valid_from |
voucher-expired | 422 | After valid_until |
voucher-type-not-configured | 422 | Campaign has no award configured for this voucher type |
voucher-redemption-effect-auth-required | 401 | The campaign's connected system needs a credential that was not supplied |
voucher-redemption-effect-auth-rejected | 401 | Partner rejected the credential — refresh it |
voucher-redemption-effect-failed | 502 | Partner could not record the redemption |
Loyalty wallet and memberships
| Slug | Status | Meaning |
|---|---|---|
wallet-balance-not-found | 404 | No balance for this user in this campaign |
wallet-sync-auth-required | 401 | The connected system needs a credential that was not supplied |
wallet-sync-provider-failed | 502 | Loyalty partner returned no balance |
wallet-sync-campaign-unreachable | 500 | Campaign metadata unavailable |
membership-lookup-failed | 500 | Memberships could not be read |
Agreements
| Slug | Status | Meaning |
|---|---|---|
agreement-auth-required | 401 | The connected system needs a credential that was not supplied |
agreement-auth-rejected | 401 | Partner rejected the credential |
agreement-invalid-request | 422 | Partner rejected the agreement ids |
agreement-provider-unavailable | 502 | Partner CRM unreachable |
Payments and analytics
| Slug | Status | Meaning |
|---|---|---|
payment-not-found | 404 | No such payment |
analytics-empty-batch | 400 | events was empty |
analytics-ingest-failed | 502 | Batch could not be enqueued |
What to log
For every failed call, record: the endpoint, the HTTP status, type, and instance. That is enough for us to resolve almost anything without a round trip. Never log the request body of a checkout confirm — it contains customer name, e-mail and address.
Updated 41 minutes ago
