The loop
create → poll → answer actions → receipt.1
Create
POST /v1/checkouts with a URL and a sentence. Returns in milliseconds with a queued
object and an id. Nothing has happened yet.2
Poll
GET /v1/checkouts/{id} every 3–5 seconds. Reads are not rate limited. The object carries
the status, an append-only progressItems timeline, and a pendingUserAction when the run
is blocked.3
Answer
POST /v1/checkouts/{id}/actions/{actionId} with submit and the values the action’s
responseSchema describes — or decline to end the run. Then go back to polling.4
Receipt
A
succeeded checkout carries receipt.merchantOrderId and receipt.total. failed and
cancelled carry failure.reason. Those three statuses are terminal.If your agent runs inside a request handler with a 30-second timeout, do not run the poll loop
there. Create the checkout, persist the id, and drive the rest from a job, a cron, or a
webhook that wakes you on each status change.
Send an Idempotency-Key. Always.
This is the first thing to get right, ahead of the tool definitions, because the worst thing this product can do is buy the same thing twice — and your framework will retry a transport error by default. Every agent runtime worth using retries a timed-out tool call. APOST that
times out after we accepted it looks identical, from your side, to one that never landed.
POST returns the original checkout with 200 instead of starting a
second run: same id, same browser, one order. Omit it and you have a race between your retry
policy and your user’s card.
Three more properties worth knowing before you rely on it:
- A replay returns the checkout as it is now, not the original
queuedresponse. A retry ninety seconds later comes backrunning, possibly with apendingUserActionalready waiting. Branch onstatus; do not assumequeued. - The same key with a different body is a
409, and the error carries thecheckoutIdit already refers to. That is a bug in your handler — usually a key derived from the user’s turn rather than from the purchase — surfaced rather than silently resolved in favour of whichever body arrived first. - A key lives 24 hours, measured from the creation of the checkout that claimed it. That covers every retry your framework will ever make in one turn, and then some. It is a retry window and not a duplicate-purchase guard: the same key next week buys the item again, so if your flow can come back to the same cart tomorrow, keep your own record of what you have already bought.
Give the agent a narrow key
Scopes exist for exactly this situation. The holder of a key is an autonomous process, and the useful question is not “do I trust this tenant” but “what is the worst this agent can do if its prompt is turned against it”.write implies read on the same
resource), and list buyer profiles so the model can choose one. It cannot read your usage, it
cannot touch your webhooks, and it cannot mint itself a better key — a key can never grant
scopes it does not itself hold. See Authentication.
If your agent acts for more than one person
Add"subject": "<your user id>" to the key you mint, and that key can only ever see and create
rows for that one end user:
subject decides whose data it may do it to. That second
axis matters here specifically because your agent reads merchant pages, and list_buyer_profiles
is a completely plausible call for a model that has been talked into something. On a tenant-wide
key that call returns every buyer you have ever stored. On a bound key it returns one person, and
there is nothing the model can say to widen it. See End-user isolation.
Tool definitions
Four tools cover the whole product. They are written against the real request shapes — cross-check against the API reference oropenapi.json if you change them.
maxCostis flattened into two string parameters. Nested objects are where models reliably deviate from a schema, and this one is a spending limit. Flatten it and rebuild the{ amount, currency }object in your handler.create_checkoutdoes not exposeallowedDomainsormerchantContext. Both widen what the run may do —allowedDomainsliterally widens the navigation guard. They are your decision, from your config, not the model’s from its context.
Which questions may the agent answer?
When a run blocks,pendingUserAction.key says what is being asked for. This is set by the
engine raising the question, not inferred from the merchant’s markup — so branch on it, never
on the shape of responseSchema.
The three the agent must never answer
card, credentials and otp are values that come from a human, in that moment, for this
purchase. An agent must not supply them — not from its system prompt, not from earlier turns in
the conversation, not from a file it read, not from a “test card” it remembers.
Two reasons, and the second is the one people miss:
- A card number in an agent’s context is a card number in an agent’s context. It ends up in transcripts, logs, evaluation datasets and any tool the agent later calls.
- These are exactly the questions worth attacking. Merchant pages are untrusted input, and an agent that will hand over a card because a page persuaded it to is a much more attractive target than one that structurally cannot.
credentials field names are generated from
whatever the merchant’s login form happened to call them, and otp fields are equally
arbitrary. The only reliable discriminator is the action’s key, which your handler can read
and the model cannot forge:
You have ten minutes.
pendingUserAction.expiresAt is authoritative and there is no extension.
If nobody can answer in that window, decline — same terminal outcome, minutes sooner, and the
reason is recorded. Letting an action expire also holds one of your five concurrent-checkout
slots for the full TTL.Avoiding the questions
The best-handled action is one that never gets raised.- Attach a
buyerProfileIdand noshippingaction appears. - Put the option in
request—"buy this in medium, black"— and a single unambiguous match skips the variant prompt. Values are matched as whole words against the store’s option values. - Store a buyer session once and later runs at that merchant start logged in,
so
credentialsandotpstop recurring for that buyer.
card cannot be avoided, and that is deliberate: reaching it 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.
A worked loop
Plainfetch, no SDK. This is the tool-handler side — the part you wire behind whichever model
you are using.
farthing-tools.ts
Handling the ends
There is no retry, replay or clone of a finished run. Create a new checkout with the same body —
and a new
Idempotency-Key, because the old one points at the failure and keeps returning it
for as long as it is live.
The zero-code option: MCP
FARTHING_API_KEY and point your client at it.
At the card step the server leads with the merchant’s own order total, because that is the
last moment the price can be questioned before money moves.
farthing_create_checkout is annotated idempotentHint: false, honestly: the server sends no
Idempotency-Key and will not invent one on your behalf, so two calls are two purchases. Your
MCP client’s own retry or approval behaviour is what stands between a flaky connection and a
second order — another reason a production integration talks to the HTTP API.
It refuses card, credentials and OTP values as tool arguments, by design and for the reason
above: those are not things a model should be holding. When a run blocks on one of them, the
server surfaces the question and the human answers it out of band.
That refusal is also the reason MCP is not the whole answer for a production integration. You
still need somewhere for a person to type a card, which means a UI, which means the HTTP API.
MCP is the right way to try the product, script one-off purchases, and let an operator drive a
run from a chat window.
Next
User actions
Every key, its exact schema, TTLs, decline, and what happens to submitted values.
Checkout lifecycle
Every status and failure reason, and what actually causes each one.
Webhooks
Get pushed status transitions instead of polling a run that is parked for ten minutes.
Sandbox
Prove your decline, expiry and
max_cost_exceeded paths before a real card is involved.