POST /v1/checkouts returns in milliseconds with
a queued row; the actual purchase happens in an orchestrator that survives restarts,
retries individual units of work, and can sit paused for ten minutes waiting for a human.
Everything a client needs is on the object returned by GET /v1/checkouts/{id}. There is one
shape and it is the same one you get back from create, from a poll, and from submitting an
action.
Status machine
succeeded, failed and cancelled are terminal — nothing transitions out of them, and a
late orchestrator replay that lands on a terminal row exits immediately and reclaims the
browser.
Answering an action returns the checkout to running
Submitting a pendingUserAction resumes the run and the status goes back to running, so
awaiting_user_action always means a human really is being waited on. A checkout can make
this round trip several times — variant, then card, then done.
Even so, prefer testing for the action itself rather than the status string: status is a
summary, pendingUserAction is the thing you actually need to render.
status === "awaiting_user_action" alone. The same applies to webhook
consumers: you can receive two consecutive awaiting_user_action events for one checkout,
and there is nothing in between telling you it went back to work.
The converse is guaranteed: a terminal checkout never reports a
pendingUserAction.
Cancelling or failing a run expires any question still open, so succeeded, failed and
cancelled checkouts always come back with the field absent. A run that died while parked
on a card prompt does not keep advertising that prompt forever.Failure reasons
failure.reason is one of exactly four values. The human-readable detail is not in
failure — it is in the final terminal progress item’s data.message.
max_cost_exceeded — the purchase would have cost more than you allowed
max_cost_exceeded — the purchase would have cost more than you allowed
Enforced at two points, both before any card is requested and long before anything is
charged:
- Item price (major platforms). Where the variant’s price is known before checkout, it is compared
against
constraints.maxCost. This check ignores currency — the storefront product JSON does not state one — and covers the item price only, not shipping or tax. Message:Item price 45.00 exceeds maxCost 40.00. - Order total (both tiers). At the moment the engine wants to raise the
cardaction, it reports the total displayed on the page — which does include shipping and tax. If that exceedsmaxCost, the run fails instead of asking you for a card. Message:Order total 61.40 USD exceeds maxCost 60.00 USD.
user_cancelled — you declined a pending action
user_cancelled — you declined a pending action
Set when you
POST {"action":"decline"} to a pending action. Declining is a normal,
supported end state: you looked at what it was asking for and decided not to buy.Not to be confused with DELETE /v1/checkouts/{id}, which produces status cancelled
with no failure object at all.user_action_expired — nobody answered in time
user_action_expired — nobody answered in time
A pending action has a TTL (10 minutes by default). If it passes with no submit and no
decline, the orchestrator’s wait times out, the action row flips to
expired, and the
checkout fails.The browser is torn down at that point. There is no way to answer late and no way to
extend the window — an expired checkout has to be recreated. This is intentional: a
browser parked on a merchant’s payment page is a real resource with a real cost, and
merchant checkout sessions themselves expire.automation_failed — everything else
automation_failed — everything else
The catch-all, and the one you will actually see. Real causes, with the message you get:The merchant page did not cooperate
Could not read product JSON: product JSON returned 403— the storefront blocked or changed its product endpoint (retried with backoff first).Add to cart failedUnexpected phase at https://example-store.com/...— the run landed on a URL it could not classify as product / cart / checkout / confirmation.Could not reach the payment step— no continue button was found after several attempts.
No card fields found at the payment step; frames=3 {...}— card inputs never appeared. The diagnostics string reports the frame and input counts we actually saw.Could not fill card field cc-number (filled: cc-exp, cc-csc); ...— a field would not hold its value. We refuse to click pay on a half-filled form: a merchant-side decline is a real, user-visible failure, so we stop while it is still harmless.Pay button not found at the payment stepPayment rejected by merchant: There was an issue processing your payment...— the merchant declined the card. The quoted text is the visible error we read off the page.Timed out waiting for order confirmation— the pay click landed, no confirmation page appeared within 90 seconds, and no visible error explained why.
Agent stuck repeating click {"ref":20} with no page changeAgent made no observable progress for 12 consecutive turnsAgent exceeded step budget— 60 turns in a single unit of work.Agent could not complete checkout, or whatever reason the agent itself reported.
Exceeded max checkout steps— 12 units of work, each being a phase transition, a checkout step, an agent run, or a user-action pause.- Anything thrown after the orchestrator exhausted its retries: the browser died, the browser connection dropped, the engine raised. The thrown error’s message is recorded.
Timed out waiting for order confirmation deserves care in your own handling. It means
we could not prove the order went through — not that it definitely did not. Check the
merchant before retrying.The progressItems timeline
progressItems is an append-only, gap-free, per-checkout sequence of everything that
happened. It is the whole audit trail, and it is what you should render instead of a spinner.
Track the highest
sequence you have rendered and append anything above it — that is the
whole diffing algorithm you need.
Event kinds
kind
Exactly one, always
sequence: 1, label Started checkout. Written synchronously by
POST /v1/checkouts, so it is already in the 201 response.kind
Everything the engine does. Examples you will actually see:
Browser session started—datacarries{ "exitCountry": "GB" }when the run went out through a region-matched network egress.Loaded saved merchant session—data: { "cookieCount": 14 }. A stored buyer session was preloaded. See Buyer sessions.Checkout phase: product(major platforms) —data: { "url": "..." }.Selected Widget — M / Black—data: { "variantId": 44127788, "price": 4210 }, price in minor units.Filled contact and shipping detailsSelected cheapest available shipping methoddismissed obstruction—data: { "via": ["#onetrust-accept-btn-handler"] }. A cookie banner or promo modal was closed.agent: click — page changed(tier 2) —data: { "ref": 20, "targetLabel": "Add to bag" }. Tier-2 timelines are one line per agent turn, with the outcome appended to the label:page changed,no effect,blocked (repeat), orfailed: <reason>.Filled card details—data: { "fields": ["cc-number", "cc-exp", "cc-csc", "cc-name"] }.Placing order— the irreversible click is about to happen.Order confirmed—data: { "merchantOrderId": "1042" }.
kind
Written when the run asks a human for something.
data.key carries the action’s key
(variant, card, credentials, otp, …) and the label is the same message you
see on pendingUserAction.This is currently the only place the action key is exposed — see
Identifying an action.On major platforms the card step writes two entries: a bare
Waiting for payment details (no data.key) immediately before the action is persisted,
then the keyed one. Read the newest entry that has data.key.kind
Exactly one, and always last:
Checkout succeeded—data: {}Checkout failed: automation_failed—data: { "message": "..." }when a detail existsCheckout cancelled—data: {}
The receipt
Present only onstatus: "succeeded".
object | null
{ amount, currency } with amount as a decimal string. Read from the confirmation page,
taking the largest “total”-labelled figure — confirmation pages list subtotal, shipping
and total, and taking the first match reported subtotals as order values.null when the run reached an already-confirmed order page without us parsing a total
(for example, a resume that landed straight on the merchant’s order page).string | null
The merchant’s own order number, scraped from the confirmation page or the order URL.
null when the page did not expose one in a form we recognised. Its format is entirely the
merchant’s — do not parse it.object
Proof of what we saw. In practice
{ "url": "..." }: the confirmation page URL at the
moment we declared success. {} if nothing was captured.The
receipt is our observation of the merchant’s confirmation page, not a financial
record. It is what the browser saw. For anything reconciliation-grade, use the card’s own
transaction record and treat merchantOrderId as the join key.Cancelling
202 with an empty body. Cancellation is asynchronous: it stops the run even while
it is parked waiting on a user action, flips the row to cancelled, and reclaims the
browser. Poll (or wait for the webhook) to observe status: "cancelled".
A DELETE on an already-terminal checkout also returns 202 and does nothing. The 202 is
an acknowledgement that the request was accepted, not a claim about what state the checkout
ended in.
Budgets and timing
There is no wall-clock timeout on a checkout as a whole. A run parked on a user action for
its full TTL, resumed, and parked again is behaving normally. Expect minutes end to end,
and considerably more for food-delivery-shaped flows than for retail.