Errors, idempotency, rate limits, versioning#
This is the Checkout API's complete error and retry reference. How it differs from the Partner API's errors — same field names, different codes, no partial success — is on the shared Errors page.
The error envelope#
Every non-2xx response has the same body:
{
"error": {
"code": "amount_mismatch",
"message": "Line items net to 44000 but amount is 45000.",
"details": [{ "field": "line_items", "issue": "sum_differs" }],
"request_id": "req_5c1b0a4e-2f6d-4a0b-9d3e-7b8c1f2a3d4e"
}
}Branch on code, show message to a developer (never to a shopper), and quote request_id when you write to support — it is also echoed in the x-request-id response header on every call, and you may send your own x-request-id (8–64 characters of A-Za-z0-9_-) to have it used instead.
details[] names the offending field (dotted path, (body) for the whole body) and an issue — for validation errors it is the validator's message; for business rules it is a stable token such as sum_differs, host_not_allowed, not_enabled, private_target.
Error codes#
| HTTP | Code | When |
|---|---|---|
| 400 | validation_error |
The body or query failed schema validation; details[] lists each field. Unknown fields are errors. |
| 400 | amount_mismatch |
line_items do not net to amount. |
| 400 | unsupported_currency |
currency or presentment.currency has no recorded minor-unit exponent. |
| 400 | unsupported_payment_method |
Reserved. |
| 400 | idempotency_key_required |
A creating POST without an Idempotency-Key, or one outside 8–128 characters of A-Za-z0-9_-. |
| 400 | otp_invalid, otp_expired |
Public surface — a wrong or stale SMS code. |
| 401 | invalid_api_key |
Missing, malformed, revoked or wrong-type key on a merchant route (a publishable key here is 401, not 403). |
| 401 | invalid_publishable_key |
Same, on /public/*. |
| 403 | insufficient_scope |
The key lacks a scope the route needs; the message names it. |
| 403 | mode_mismatch |
A live key acting on a test object or vice versa — e.g. registering a mode: "test" endpoint with a live key. |
| 403 | merchant_suspended |
Povito suspended the merchant. |
| 403 | phone_not_verified |
Public surface — an attempt before the required phone verification. |
| 403 | forbidden |
Anything else the caller may not do (e.g. decide on a non-test attempt). |
| 404 | session_not_found, refund_not_found, endpoint_not_found, event_not_found, attempt_not_found, customer_not_found |
Not found — also for another merchant's or another mode's objects. Never 403, so existence does not leak. |
| 409 | idempotency_key_conflict |
Same Idempotency-Key, different body (or different route) within 24 h. |
| 409 | session_not_open |
The transition needs an open session (expire, cancel, OTP), or a refund on a session with nothing captured. |
| 409 | attempt_in_progress |
Public surface — a new attempt while one is with a vendor. |
| 409 | refund_exceeds_captured |
Refund ceiling; the message states what remains. |
| 410 | session_expired, session_canceled |
Public surface — the page loaded a session that has expired or been cancelled. |
| 422 | method_not_enabled |
A payment_method_types entry is not enabled for this merchant and mode. |
| 422 | method_not_available |
Public surface — the method is enabled but unusable on this session (currency, limits, vendor not configured). |
| 422 | method_not_refundable |
The vendor cannot refund through the API, or needs a full-amount refund. |
| 422 | return_host_not_allowed |
success_url/cancel_url host is not on the allowed list. |
| 422 | expiry_out_of_range |
expires_at is under 5 minutes or over 24 hours away. |
| 429 | rate_limited |
Over a limit; Retry-After is set. |
| 429 | otp_locked |
Five wrong codes; Retry-After: 600. |
| 502 | gateway_error |
The vendor declined at initiation or could not start the payment; the attempt is failed and the session is open again. |
| 503 | gateway_not_configured |
The vendor has no credentials for this mode. |
| 500 | internal_error |
Povito's fault. Retry with backoff — see below. |
On any 5xx or a network failure never assume the operation did not happen: re-send with the same Idempotency-Key, or look the session up by reference_id.
Idempotency#
POST /v1/checkout/sessions and POST /v1/refunds require an Idempotency-Key header — any 8–128 characters of A-Za-z0-9_-; a UUID v4 is the convention. Povito stores the key with a hash of the body and the first response for 24 hours, scoped to your merchant, mode and route:
| Repeat request | Result |
|---|---|
| same key, same body | the stored response, same status code, plus Idempotent-Replayed: true |
| same key, different body | 409 idempotency_key_conflict |
| same key, other route | 409 idempotency_key_conflict |
| same key, first attempt still in flight or crashed before storing | processed again (the stored response is then the newer one) |
Independently of the header, session creation is reconciled on (merchant, mode, reference_id) while the previous session is open: the same amount, currency and line_items get that session back with 200 instead of 201; a different price expires the open session (checkout.session.expired) and creates a new one with 201, so a page still showing the old amount can never be paid. Once a session has completed, expired or been cancelled, the same reference_id simply creates a new session — keep reference_id unique per order attempt if that is not what you want.
GET, PATCH, DELETE, /expire, /redeliver, /rotate_secret and /test take no key; they are safe to retry by nature or produce distinct results on purpose.
Rate limits#
| Where | Limit | Window | Response |
|---|---|---|---|
| Public surface, per client IP | 300 requests | 1 min | 429 rate_limited |
| OTP requests, per phone | 5 | 10 min | 429 rate_limited |
| OTP requests, per IP | 20 | 10 min | 429 rate_limited |
| Wrong OTP codes, per challenge | 5 | until a new code is requested | 429 otp_locked, Retry-After: 600 |
| Payment attempts, per session | 10 | 10 min | 429 rate_limited |
| Merchant API, per key | generous; published before live traffic | 429 rate_limited |
Honour Retry-After (seconds). The public limits exist to protect shoppers from OTP abuse, not to shape merchant traffic; a merchant integration that only creates sessions and reads them back will not meet them.
Versioning#
The API is versioned in the path (/v1). Inside v1, changes are additive only: new fields, new enum values, new event types, new endpoints. Your integration must:
- ignore fields it does not know;
- treat unknown enum values as "other" rather than failing — new
statusvalues will not appear without a version bump, but newpayment_method_types,flows and event types will.
Renames and removals need /v2. Retiring a field inside v1 is announced with Deprecation and Sunset headers on the affected routes at least 6 months before the date.
The executable contract is checkout-v1.yaml; the reference is generated from it on every build, and CI fails on a breaking diff that does not bump the version.
Changelog#
1.0.0-draft.3 — 2026-09-10#
- Re-creating an open
reference_idwith a different price now expires the old session and creates a new one (201) instead of returning the stale one. - Iraqi local phone formats are normalised to E.164 on input.
cancel_urlmust behttps://likesuccess_url.- Public surface:
GET /public/sessions/{code}carrieslivemodeand, with a valid customer token,profile;POST …/otpanswers{ sent, retry_after }; the attempt poll returnscode,method,presentandcancel_urlas well; newGET /public/attempts/{attempt_id}(attempt lookup without the code, for the return page) andGET /public/test-gateway/{attempt_id}(what the simulated vendor page shows);session_canceled(410) split fromsession_expired.
1.0.0-draft.2 — 2026-09-10#
First public draft of v1 (Phase 0–1 of ADR-CHK-001). Sessions, payment methods, refunds, webhook endpoints, events, the public surface for the hosted page, the simulated test gateway, cash on delivery as an offline method. Customers and instruments are in the contract but not yet served.