If the thing answering these is an LLM agent, read
which questions the agent may answer itself
first.
variant and shipping are answerable in software; card, credentials and otp are
not, and the reason is not squeamishness.How an action arrives
An action appears on the checkout object you are already polling:string
What is being asked for:
variant, shipping, card, credentials, otp, or a slug the
tier-2 agent chose. Branch on this. Omitted on actions raised before this field existed.string
UUID. Goes in the submit URL. A new action gets a new id every time — never cache one.
string
Written for a person, in the context of this specific page. Render it as-is.
object
A JSON Schema for the object you must send back as
values. Always
{"type":"object","additionalProperties":false,...} with a required array and a
properties map.object
What the merchant’s own checkout says this order costs — shipping and tax included —
read off the live page at the moment the run stopped to ask.
{ "amount": "61.40", "currency": "GBP" }.Present on card actions. Show it to your user before you answer. After that the money
has moved: receipt.total only exists once the checkout has succeeded, which is too late to
be a confirmation step.Best-effort, so treat it as optional. It is omitted entirely when no total could be parsed,
and currency can be absent when the page shows a bare number — a confident wrong price
would be worse than no price, so nothing is guessed.string
ISO 8601. Past this instant, answering fails and the checkout fails with
user_action_expired.Driving a UI from the schema
The schema is a real JSON Schema subset, and it is deliberately narrow so you can render it without a schema library:
Every property is
type: "string", always. Send strings — the API accepts any JSON in
values, but the engines read them as strings, so a number will be stringified somewhere you
cannot see.
required lists every property name, and additionalProperties: false means extra keys are
meaningless. Send exactly the listed keys.
Identifying an action
Rendering a free-text field and a card field the same way is not acceptable — you need to know which kind of question this is.pendingUserAction.key is that answer: variant, shipping, card, credentials, otp. It
is set by the engine raising the action, not inferred from the merchant’s markup.
The reliable read:
key is set by the engine when it raises the action, so it tells you what is being asked
for regardless of what the merchant’s form happened to call its fields. Branch on it.
Actions raised before this field existed have no key. If you need to handle those, or want
a belt-and-braces check before putting a card number on the wire, the card action is the only
one whose properties include card_number:
The action keys
- card
- variant
- shipping
- credentials
- otp
- anything else
Raised by both engines, always with exactly this schema. Clients key on its shape,
so it is pinned in code rather than generated — on the tier-2 path the model’s own
field list is discarded in favour of it.
card_expiry is typed into the merchant’s field character by character, so send it in
a form a human would type: 12 / 30 or 12/30. Spaces and slashes are normalised
before verification.You are never asked for a cardholder name. It comes from the buyer profile.Reaching this action is the guarantee that nothing has been charged yet: the code path
that fills a card and clicks pay is only reachable from a resume that carries card
values.Submitting
GET /v1/checkouts/{id}, with pendingUserAction now gone.
The request body is a discriminated union on action, and nothing else is accepted:
values is required for submit, even if empty. A body that is neither shape gets 400
with the Zod issues attached.
Declining
failed with failure.reason: "user_cancelled", and the browser is torn
down. reason is optional, free text, and stored on the action for your own audit.
Use it whenever you are asked for something you will not supply — a login you do not have,
an OTP nobody can read, a price you do not like. Declining is much better than letting the
action expire: same terminal outcome, ten minutes sooner, and the reason is recorded.
Expiry
Actions default to a 10-minute TTL, andexpiresAt on the action is authoritative.
When it passes:
- The orchestrator’s wait times out.
- The action row is marked
expired. - The checkout fails with
failure.reason: "user_action_expired". - The browser is torn down.
409:
409 covers Action already submitted and Action already declined — which is
also your idempotency signal. If a retry of your own submit returns 409 Action already submitted, the first one landed; poll the checkout rather than treating it as a failure.
An action is also marked expired when the checkout goes terminal for any other reason —
you cancelled it, or the run died while the question was open. That is what guarantees a
succeeded, failed or cancelled checkout never comes back still advertising a
pendingUserAction. If your submit races a cancellation, expect the 409.
Ten minutes is chosen against the merchant, not against you. A parked browser holds a real
merchant checkout session, and those expire on their own timetable — a longer window would
mostly produce runs that resume onto a dead cart.
What happens to submitted values
This is the part worth reading carefully before you put a card number in avalues object.
1
A redacted copy is written to the action's audit record
Redaction is driven by the action’s
key, not by what the fields happen to be called.card,credentialsandotp— every submitted value becomes"[redacted]", whatever its field name.- Every other key — values whose field name looks card-shaped (
card,cc-num,pan,cvc,cvv,security code,expir) or secret-shaped (password,passcode,secret,token,otp,one-time,verification code,auth code,pin) are redacted; the rest are stored as sent.
credentials action are
chosen by a model reading a merchant’s login form, so they are not ours to predict —
name-matching alone let plaintext passwords through, which is why it is now a backstop
rather than the primary defence.2
The raw values go to a transient, delete-on-read store
A separate table keyed by action id, with a short TTL of its own. This is the only place
raw values exist.
3
The resume event carries no values at all
The orchestrator’s event bus is a third-party system with durable event payloads and
durable step outputs. Values never enter it. The event carries the action id and the
outcome; the values are read inside the one step that types them.
4
The row is deleted once that step completes
Not on read — a mid-flight retry would otherwise be stranded with nothing to type. It is
deleted after the step that used it succeeds, and a background sweep clears anything left
past its TTL regardless.
pendingUserAction disappears once
resolved, and no endpoint exposes resolved actions or their values — the audit copy is
internal.
Cards specifically
Send single-use or agent-issued card numbers. The posture above is designed so that a database dump does not yield reusable PANs, and it holds — but “the raw number existed in a row for the duration of one checkout step” is a very different claim from “we are a card vault”, and we are not making the second one. Values are also scrubbed on the way to the timeline: on the tier-2 path, agent tool inputs pass through a filter that masks both the exact values you submitted and anything PAN-shaped, so a card number typed through a generic “fill this field” tool cannot surface inprogressItems.