Skip to main content

Error shape

Every error is the same object:
Validation failures add the raw Zod issues, which are precise enough to point at the offending field:
There are no error codes on the agent-facing surface. Match on the HTTP status; treat 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:
The two reasons a request is confined — a sandbox-only account, or a 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
One more on 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
See the plan ceiling for the semantics — refused, never clamped. And one more, on an idempotency conflict — 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

On 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.
It is opt-in, and the failure mode is a duplicate purchase. With no header, two identical POSTs are two checkouts, two browsers and two orders. A POST that times out after we accepted it is indistinguishable, from your side, from one that never landed — and every HTTP client, job runner and agent framework retries a timeout by default. Set the header on every create you might retry, which is all of them.

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: omitting constraints 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.
24 hours is a retry window, not a duplicate-purchase guard. A key you reuse a week later buys the item again, with a 201 that looks like the first one. Make yours unique per purchase — your own order reference, or a UUID minted when the user confirmed — reuse it across the retries of that one purchase, and keep your own record of what you have already bought if your flow can come back to the same cart tomorrow.

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 with status: "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 at POST /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
The number in the message is your limit. Sandbox workspaces default to 5 concurrent; activation applies the plan’s number — Personal 1, Business 5, Scale 15. See Pricing.“Active” means any checkout not in 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)
A rolling 24-hour window over creation time, not a calendar day. It is a velocity brake: an agentic checkout platform without one is a card-testing tool waiting to be discovered. The default is the same on every plan.
Both are raisable per tenant — ask, with a sense of your volume. Neither limit sends a 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 on GET /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 is a strong guardrail, not a hard ceiling. The order-total check depends on reading a total off the merchant’s page. If no total can be parsed there is nothing to compare, and the run proceeds to ask you for a card.Two things follow. First, the real ceiling should be the card: use single-use instruments with their own limit. Second, you are the last check — you can always inspect the checkout before answering a card action, and decline if the run’s timeline does not look like what you asked for.
Omitting 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 at POST /v1/checkouts, before anything runs:
  • Omit constraints.maxCost and the ceiling is applied as your maxCost ({"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 400 carrying planCeilingUsd:
    400
Refused, not clamped: silently weakening an instruction about money is worse than rejecting it. If the API quietly turned your 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.
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.
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.
Dedupe on (checkoutId, status) or, better, on the state you fetch. See Webhooks.
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.
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.