Error shape
Every error is the same object:message as text for a human, and issues[].path as the machine-readable part when it is
present. (The session-only workspace routes are the exception: they add a code field —
no_workspace, workspace_exists — for the dashboard to key its onboarding off. See
dashboard sessions.)
One error carries an extra machine-readable field. A sandbox confinement 403 includes the
hosts you can reach, so you never have to parse the message:
ck_test_ key — are
distinguished in the message. See Sandbox.
Three others carry a machine-readable field, all to do with
scopes:
403 — out-of-scope request
400 — unknown scope at key creation
403 — a key trying to mint a broader key
POST /v1/checkouts, when your maxCost asks for more than your plan allows —
planCeilingUsd carries the ceiling, so you can cap and retry without parsing the message:
400 — maxCost above the plan's per-checkout ceiling
checkoutId names the checkout the
key already refers to:
409 — Idempotency-Key reused with a different body
Two endpoints break the JSON pattern:
DELETE /v1/checkouts/{id} returns 202 with an empty
body, and the /embed routes return plain text (Invalid or expired embed token) because
they are consumed by a browser, not an API client.Status codes
The 404s
Every lookup is scoped to the authenticated tenant. Another tenant’s checkout is a
404, not
a 403 — you learn nothing about whether it exists.
The 409s
POST /v1/checkouts/{id}/actions/{actionId}, this is also your idempotency signal. If your
own submit retries and comes back Action already submitted, the first attempt landed — poll
the checkout rather than treating it as a failure.
On POST /v1/checkouts, a 409 means something different: an Idempotency-Key you have used
before, with a different body. See below.
Idempotency
POST /v1/checkouts accepts an Idempotency-Key request header. Send the same key twice and
the second call returns the original checkout with 200 instead of creating a second one.
What “same body” means
The body is hashed after parsing, and object keys are sorted first, so two clients serialising the same request are under no obligation to agree on key order. Defaults are applied before hashing too: omittingconstraints and sending "constraints": {} are the same request.
Reusing a key with a genuinely different body is a caller bug, so it is refused rather than
quietly answered with a checkout for something else. Anything other than a fingerprint match is a
409 — including the pathological case of a checkout that holds a key but no fingerprint at all,
which replays nothing rather than replaying for any body you send:
409
checkoutId points at what the key already refers to, so you can inspect it and decide whether
your retry was the mistake or the key was.
Scope and lifetime
- Keys are scoped per (tenant,
subject), not globally. Two of your end users may pick the same string —"order-1", a timestamp, a retry counter — and neither can replay the other’s checkout. A tenant-wide key shares one namespace across everything it creates untagged. - A key lives 24 hours, measured from the creation of the checkout that claimed it. Inside the window a retry is a retry. Outside it the key is released and the same string starts a new purchase, which is the same 24 hours Stripe gives you and the same assumption most HTTP clients already make.
- Release is a background sweep, not a clock read on the request, and it runs hourly — so a key can outlive its window by up to an hour before it stops replaying. Do not build a flow that depends on the boundary landing at a particular second.
- Expiry frees the key, it does not touch the checkout. The row, its timeline and its receipt stay exactly where they were; only the key and the body fingerprint are cleared off it.
- The namespace belongs to the account and the end user, never to the credential. Rotate your API key and a replay of an hour-old key still works; two keys bound to different subjects never collide.
- An empty or whitespace-only header is treated as absent.
What a replay actually returns
The checkout as it is now, not a recording of the original response. A retry ninety seconds later returns the same id withstatus: "running" and a timeline that has grown — possibly with
a pendingUserAction waiting on you. Treat the 200 as “this already exists, here is its
current state”, and branch on status rather than assuming queued.
A replay also does not consume quota, does not launch a second browser, and does not re-enqueue
the run.
Idempotency covers creation only. It is not a retry of a failed run: while the key is still
live, reusing it on a checkout that has already failed hands you back that same failed checkout
with a
200. To try again inside the window, create a new checkout with a new key.This is also why the window exists. Without it, a failed checkout replayed its own failure for as
long as the row lived, and a caller that had sensibly derived its key from the purchase had no way
to retry at all short of inventing a key it had no reason to think it needed.No other endpoint reads the header. Answering a user action is made safe by its own 409
instead — see The 409s.Quotas
Two independent per-tenant limits, both enforced atPOST /v1/checkouts, both returning
429. Per tenant, not per end user: subject partitions who can see what, never who gets a
slot, so one end user’s parked runs can 429 another’s next create. See
what isolation does not cover.
set by your plan
succeeded, failed or cancelled — including
queued and including one parked on a user action.This is a resource cap as much as an abuse cap: every non-terminal checkout is holding a
real browser. The practical consequence is that an unanswered action ties up a slot for its
full 10-minute TTL. DELETE checkouts you have given up on rather than waiting them out.200 per rolling 24h (default)
Retry-After header; for the concurrency limit, retry when one of your own runs finishes.
Signup limits
POST /v1/signup is unauthenticated, so it carries its own limits: 3 per hour per source
IP and 200 per 24 hours platform-wide. See Sandbox.
What is not rate-limited
Reads. There is no limit onGET /v1/checkouts/{id}, so polling is free. Be sensible — a
3–5 second interval is right for a process that takes minutes — but you will not be throttled
for it.
maxCost enforcement
constraints.maxCost is your spending ceiling for a single checkout:
amount is a decimal string. currency defaults to "USD" if you omit it. Both checks run
before a card is ever requested, so a breach costs you nothing.
1
Item price — major platforms only
When the variant resolves, its price is compared against
maxCost. This is the item
price alone: no shipping, no tax. It also ignores currency, because the storefront
product JSON does not state one.Fails with max_cost_exceeded and the message
Item price 65.00 exceeds maxCost 60.00.2
Order total — both tiers
At the moment the engine wants to ask for a card, it reports the total displayed on the
page — which does include shipping and tax. If that exceeds
maxCost, the run fails
instead of raising the card action.The comparison runs when both values parse and the currencies do not contradict each
other. If the page’s currency cannot be determined, the check still enforces against the
number: a false stop is far cheaper than an uncapped purchase.Fails with max_cost_exceeded and the message
Order total 61.40 USD exceeds maxCost 60.00 USD.maxCost means no cost enforcement at all — unless your plan has a ceiling, in
which case the ceiling steps in. For anything touching a real merchant, set it.
The plan ceiling
On plans with a per-checkout spend ceiling — Personal, at $200 — the ceiling is enforced atPOST /v1/checkouts, before anything runs:
-
Omit
constraints.maxCostand the ceiling is applied as yourmaxCost({"amount": "200.00", "currency": "USD"}), enforced by the same two checks above. -
Ask for more than the ceiling and the create is refused with a
400carryingplanCeilingUsd:400
250.00 into 200.00, a purchase could
succeed under a constraint you never agreed to; a 400 at create time costs nothing and
leaves the decision with you.
The ceiling is a dollar figure, and the refusal compares USD amounts — a maxCost in
another currency passes this gate and is enforced only by the ordinary maxCost checks
above. Business and Scale have no ceiling; maxCost on those plans is entirely yours.
Other input limits
Known gaps
Things a reasonable integrator expects that do not exist yet. Listed so you design around them rather than discover them.No shipped-to address on a past checkout
No shipped-to address on a past checkout
A checkout stores
buyerProfileId, never a copy of the address it used. PATCH or
DELETE the profile and there is no longer any way to recover where a past order actually
went. Record it yourself at creation time if refunds or disputes depend on it.No way to list an action's history
No way to list an action's history
Only the currently pending action is exposed, on the checkout object. Once resolved it
disappears from the API. The timeline retains the fact that it happened — kind
user_action, with data.key — but not what was submitted.Webhooks have no event id and no ordering guarantee
Webhooks have no event id and no ordering guarantee
Dedupe on
(checkoutId, status) or, better, on the state you fetch. See
Webhooks.No retry, replay or clone of a checkout
No retry, replay or clone of a checkout
A failed checkout is final. Create a new one with the same body — and a new
Idempotency-Key, since reusing the old one hands you back the failure for as
long as that key is still live. Idempotency-Key deduplicates the call, not the run.No search across checkouts
No search across checkouts
List supports
subject, status, limit and cursor. You cannot filter by metadata, by
merchant, or by date range — keep your own index keyed on the checkout id you got at
creation.