Architecture — a machine-native API commerce layer#
What Checkout402 is, what it deliberately isn't, and the delta between this document and the code today.
An earlier draft generalised into physical ecommerce and was cut back to the wedge below, on an argument worth keeping because of how it resolved: non-custodial settlement and physical returns looked structurally incompatible — money reaches the seller's wallet immediately, so there is nothing left to refund from. Escrow dissolved that incompatibility rather than the category: for goods that need recourse, the payment is held on-chain with a shipping window, a dispute path and an arbiter, still with no custody on our side. Physical goods now sell through marketplaces built on checkout402; the wedge remains digital-first because its delivery is instant and verifiable, not because the other direction is closed.
The invariant#
Everything here must preserve this, unchanged, forever:
curl API → 402 → pay → retry → success
No buyer account. No identity handshake. No custody. A feature that cannot be added without breaking this line is not a feature we add.
The test for any proposal: does an anonymous agent with a wallet and no prior relationship still complete a purchase in two requests?
Positioning#
Sell your API to AI agents. For developers: machine-native pay-per-use billing for any API.
Not x402 infrastructure, not an identity protocol, not a checkout system, not a wallet. Those are implementation details or future options, and naming the product after any of them describes the plumbing rather than the job.
The one primitive: Offer#
An Offer is what resource is sold, under what economic conditions. Nothing else.
{
"id": "offer_123",
"resource": { "method": "POST", "url": "https://api.acme.com/generate" },
"price": { "amount": "0.04", "currency": "GBP" },
"requirements": [
{ "type": "payment", "status": "unsatisfied",
"amount": { "value": "0.04", "currency": "GBP" } }
]
}
No carts, inventory, shipping, tax, variants or fulfilment logic. If a field does not affect what is sold or what it costs, it does not belong here yet.
That is a statement about which layer owns what, not about which commerce is possible. Physical goods sell through checkout402 today: a marketplace on top holds the listings, the shipping and the buyer relationship (see Build a marketplace), and settles each sale through us. The returns problem that once argued against physical goods was resolved by escrow, not by rejecting the category: the payment is held on-chain with a shipping window, a dispute path and an arbiter, and we still never take custody.
Today's Checkout row already is this, plus a purchase attempt bolted on.
The rename is honest and cheap; the split is deferred until something needs it.
Requirements — the extensible seam#
The rule that lets this grow without redesign:
A machine action is executable once its requirements are satisfied.
Payment is the first requirement type and, for now, the only one. Later a seller
might add {"type": "claim", "claim": "verified_organisation"} — and the
protocol does not change, because the shape was always a list.
This is the whole extensibility story. It is deliberately a list of typed conditions, not a state machine, because a state machine forces every purchase through every state and the fast path must stay two requests.
Identity: primitives, not a product#
Five concepts, kept separate so none becomes a mandatory flow:
| Principal | the economic entity responsible (anonymous, Acme Ltd) |
| Actor | the software acting (agent_123, a browser, a script) |
| Subject | a stable payer reference — not legal identity |
| Credential | evidence for a claim (key, JWT, wallet signature) |
| Authority | evidence an actor may act for a principal, with limits |
| Policy | what an actor may do (max £10/request) |
Level 0 — anonymous — is the default and must remain possible at every
level above it. Levels exist so a seller who asks "give this buyer 20% off"
gets a subject, and one who asks "only approved enterprises" gets a claim.
Identity grows out of a seller's commercial question. It is never the answer to a question nobody asked.
Payment must work without identity. Identity may enhance payment; it may never redefine it. The system asks can this request satisfy the economic requirements? — not who are you?
HTTP semantics#
Keep them clean and unoverloaded:
401 |
authentication required |
403 |
authenticated, not permitted |
402 |
payment required |
Errors as application/problem+json (RFC 9457), carrying the requirement that
is unsatisfied and the call that satisfies it.
Discovery#
GET /.well-known/checkout402 → seller, offers URL, capabilities
GET /v1/sellers/{slug}/offers → the machine-readable catalogue
GET /{slug} → the same catalogue, human face
Two faces again, the same as /c/{id}. A machine gets JSON; a person gets a
page they can read and share.
This is plausibly the strongest network effect available: a deterministic way for an agent to find out that an API is purchasable at all, and what it sells.
Receipts#
Every settled purchase produces one, and the shape must not change as identity gets richer:
{
"id": "receipt_123",
"offer": "offer_456",
"payment": { "id": "pay_789", "amount": {"value": "0.04", "currency": "GBP"} },
"subject": "subject_xyz", // optional, Level 1+
"timestamp": "..."
// later, without a protocol change: principal, actor, authority
}
Receipts are what eventually make audit, reconciliation, spend history and reputation possible — so the fields are reserved now even though nothing reads them.
Idempotency#
Machine clients retry by default, so this is not a later concern.
Idempotency-Key: agent-run-72831 on offer creation, payment attempts, and
execution.
A payment must never execute the paid resource twice unless the seller
opted in. The existing payment_payload_hash UNIQUE constraint is exactly the
right primitive — a database constraint rather than a check-then-act, so
concurrent retries lose at the database instead of racing.
Entitlements: the package key#
A package sells N deliveries for one settlement. That single sentence forces most of the design, and the parts that look arbitrary are the parts that fall out of it.
Why the key hangs off the payment, not the checkout#
package_key_hash lives on CheckoutPayment. It is tempting to hang it off
Checkout — one product, one key — and that is wrong for a reusable checkout,
where two buyers must get two independent counters. The entitlement is created
by an act of payment, so it belongs to the payment.
The consequence worth stating plainly: the buyer's entitlement is not a
property of the seller's listing. That is why it survives the checkout going
paid, expired or void. Retiring a checkout stops new sales; it does not
reach backwards into what people already bought.
Why it is scoped, and why the error lies a little#
if payment is None or payment.checkout_id != checkout_id:
raise PackageKeyError("unknown package key")
Without the second clause one package would unlock every checkout on the platform. A key for the wrong checkout gets the same 401 as a key that does not exist, because "valid, but not for this one" confirms the key exists — and the key is a bearer credential, so confirming existence is most of an attack.
Why the package branch runs before _load_open_checkout#
A single-shot checkout is paid the moment it sells. Loading it as an open
checkout first would 410 the very calls the package was sold to serve. So
GET /c/{id} checks for a bearer key before it checks the checkout's state —
an ordering that reads like an accident and is the whole feature.
Why the key carries no signature#
After settlement there is no chain interaction left to sign against, and the holder may be a script with no wallet loaded. So a package call is a bearer token plus raw query params.
That has one sharp consequence: anything a signature would have bound must be
enforced server-side instead. Per-tool MCP pinning is the live example — on a
single call the tool name is folded into the signed resource string, so swapping
it fails verification; with a package key there is no signature, so
mcp_tool_call_from_params enforces it. Without that, 500 calls of a 1¢ tool
could be spent on a $5 one.
Why only the hash is stored#
sha256_hex(raw_key), shown once at purchase. A database leak hands out no live
packages. The cost is that a lost key is unrecoverable, which is a deliberate
trade and needs saying loudly at the point of issue, not in a footnote.
The economics this exists to serve#
One settlement costs about a cent in gas, whatever the size of the sale. The relayer submits it once — paying gas only, never routed to, with all routing inside what the buyer signed — and then plays no part in any subsequent call.
So MIN_PACKAGE_USD = $1.00 is not a policy, it is arithmetic: a package
amortises the fixed cent across N calls, which is the only way per-call prices
below a cent exist at all. And single calls have no minimum for the mirror
reason — a cheap one is the trial that leads to a package.
Known exposure#
Stated here so it is not rediscovered as a surprise:
- A package is a forward commitment with no escrow. Non-custodial is
permanent (rule 2), so the money left in the settlement transaction. Any
protection for holders is necessarily informational — we can tell a buyer
the deal changed and make it visible to the seller; we can never return
anything.
checkout_reportsis that channel. Do not build anything that implies a remedy which cannot exist. - Entitlements expire —
package_ttl_days, 30 by default, stamped ontopackage_expires_atat mint and disclosed in the 402 before purchase. Keys minted before this carry NULL and never expire; applying it backwards would confiscate calls people had already paid for. package_key_revoked_atis honoured on read and never set. No route, no service, no CLI writes it. Sellers cannot claw back a sold package — correct as a default, but still a property of the door not being built rather than a decided policy.- Still open: nothing records what was promised at purchase. The checkout is
mutated in place, so "did the offer change since I bought?" remains
unanswerable. A snapshot of the offer on
CheckoutPaymentis one column and the prerequisite for closing it.
Stateless fast path, optional stateful Intent#
The stateless flow stays the preferred one. Intent is an escalation, not a
pipeline everything is forced through:
POST /v1/intents { "offer": "offer_123" } → { "status": "requires_payment" }
Worth it only for larger payments, expiring pricing, multiple requirements, delegated authority, or stronger auditability. Making it mandatory would cost the invariant at the top of this page.
Payments, internally neutral#
Model Payment, PaymentMethod, PaymentRequirement, PaymentProof,
Receipt — with PaymentMethod = x402 today.
No wallet, no custody. The agent's own wallet pays; we verify and the seller is paid directly. Policy, authorisation, routing, verification and receipts are all buildable without holding anyone's money, and holding it brings licensing and operational weight that no current demand justifies.
Sellers first#
Do not try to win both sides at once. The buyer side stays compatible with any external x402-capable client.
The seller's actual problem: "I built an API, agents can use it, I want to charge per call, and API keys and subscriptions are awkward."
Delta from the code today#
Exists and correct: the 402 → pay → retry loop · settlement, replay
protection, receipts · offer-card metadata (input_schema, output_schema,
freshness, …) · relay with method, params, and encrypted upstream credentials ·
seller auth, keys, MCP + OAuth · non-custodial split via the deployed splitter.
Since built (this list was "missing, in build order" and is kept as the
record that the order held): seller profiles, application/problem+json,
Idempotency-Key, subject on receipts, seller analytics — and past the
original list: escrow (buy with recourse, gasless, Base), gated
content (Pay to Read / Pay to Scrape at /x/), embedded checkouts with
modular parts, and a WordPress plugin.
Explicitly not building: carts, inventory, shipping, tax, Shopify or WooCommerce checkout replacement, cards, custody, ACP/UCP checkout, agent wallet, KYC, DID, standalone identity.
Two items left this list the way the list itself promised — "excluded until a seller asks". Returns and refund orchestration were the argument against physical goods; a marketplace asked, and escrow answered it without custody. A CMS plugin was out of scope; publishers asked, and the WordPress plugin ships with the paywall. The rest stays excluded on the same terms.
The moat#
Assume basic 402 middleware becomes commoditised — it should, it is a few hundred lines. The defensible layer is everything around it:
onboarding → offers → discovery → pricing → rails → execution
→ receipts → analytics → buyer history
Own the commercial layer around paid machine APIs, not the status code.