API reference#
Base URL https://api.checkout402.com. The live OpenAPI schema is at /openapi.json, with a browsable version at /docs.
Authentication#
Authorization: Bearer c402_live_...
Sellers only — buyers never authenticate. A browser session cookie works anywhere a key does, which is how /create and /seller operate.
| Status | Meaning |
|---|---|
401 |
Missing, malformed, or revoked key |
403 |
Key lacks the required scope |
Checkouts#
POST /v1/checkouts#
Create a checkout. Returns 201.
| Field | Type | |
|---|---|---|
title |
string | required |
price_usd |
string | required — decimal string, e.g. "0.10" |
pay_to |
string | your payout wallet. Optional — omit it and your account's standing payout wallet for each chain is copied onto the checkout at create |
description |
string | |
fulfillment |
inline | relay | mcp | webhook |
inferred from what you send |
payload |
any | the goods, for inline |
origin_url |
string | your endpoint, for relay; your MCP server for mcp |
origin_method |
GET | POST | PUT | PATCH | DELETE |
default GET; forced to POST for mcp. Body-taking methods receive the buyer's reserved body param (a signed JSON string, ≤8000 chars) as the request body |
mcp_tool |
string | mcp only — sell ONE named tool. Sending it implies mcp. Omit it and one price buys any tool on the server |
max_deliveries (calls) |
int | sell a package. relay and mcp only, minimum $1.00 |
package_ttl_days |
int | how long a package entitlement stays spendable. Default 30 |
reusable |
bool | true = payment link; default single-shot |
schedule_interval_days, schedule_periods |
int | a series: the buyer signs schedule_periods payments, one every schedule_interval_days days, price_usd each. Both or neither; 2 to 24 periods; Base only; not with escrow |
test_mode |
bool | not armed — the whole checkout rehearses (simulated settlement, no funds) until you go-live. Test keys and OAuth creates default to it |
test_delivery_daily_cap |
int | rehearsal budget for origin-backed (relay/mcp) test deliveries, per day. Default 25, minimum 1 |
expires_in_minutes |
int | default 7 days |
input_schema, output_schema |
object | offer card |
example_input, example_output |
object | offer card |
freshness, coverage, source, constraints |
string | offer card |
content |
object | makes it a gated article: the id comes back cnt_ and it answers at /x/{id} instead of /c/{id}. See Gated content below, and the guide |
curl -X POST https://api.checkout402.com/v1/checkouts \
-H "Authorization: Bearer $C402_KEY" -H "content-type: application/json" \
-d '{"title":"Financials","price_usd":"0.10","pay_to":"0x...","payload":{"revenue":1}}'
Idempotency-Key (optional header). Send one if you might retry. A repeat
with the same key returns the original checkout and Idempotency-Replayed: true
instead of creating a second one — so a timeout that you retry does not leave you
selling the same thing twice.
Keys are scoped to your account, so pick whatever you like without worrying about collisions with other sellers. A create that fails releases the key, so you can fix the request and retry with the same one.
curl -X POST https://api.checkout402.com/v1/checkouts \
-H "Authorization: Bearer $C402_KEY" -H "Idempotency-Key: order-8891" \
-H "content-type: application/json" -d '{ … }'
GET /v1/checkouts#
Your checkouts, newest first. { "checkouts": [...] }.
PATCH /v1/checkouts/{id}#
Update title, description, and any offer-card field.
Price and payout are deliberately immutable
price_usd and pay_to cannot be patched — they change what a buyer may already have been quoted. Create a new checkout instead. An unknown field is a 422, never a silent no-op.
Lifecycle#
POST /v1/checkouts/{id}/go-live |
Arm an unarmed checkout for real money — in place: same id, same URL. Idempotent |
DELETE /v1/checkouts/{id} |
Retire it. Soft — the row survives so receipts still resolve. Returns {voided: [...]} |
POST /v1/checkouts/{id}/restore |
Un-retire. Status is recomputed, not remembered: a one-shot that was already paid comes back paid, not open |
POST /v1/checkouts/{id}/extend |
Move or clear the deadline — in place, same URL. An expired link a seller already shared starts selling again, byte-identical. Void and paid refuse: their state is not a deadline problem |
POST /v1/checkouts/{id}/payouts |
Add a payout rail — additive only, same id, same URL. Price and existing addresses untouched; a chain that already has an address is a 409. The test face gains the rail with no extra step |
Arming does not mint anything. The rehearsal is the same checkout's
/c/{id}/test face, which can never be armed — so the address you rehearse at
and the address that takes money differ by a path segment that says which is
which, not by two opaque ids. The old fear — a URL in a colleague's terminal
silently starting to take money — is answered structurally: scripts belong on
the test face, and the bare URL's 402 discloses its own state before and
after.
GET /v1/checkouts returns one entry per product, each carrying its rehearsal
address under test: {id, url} (same id, /test URL) and as test_url on
the checkout itself. Pass ?include_voided=true to see retired ones.
The checkout URL#
GET /c/{id}#
Content-negotiated. Accept: text/html gives the pay page; anything else gives 402 plus accepts[] and the offer card.
Send X-PAYMENT on this same URL to settle and receive the goods in one round trip.
POST /c/{id}/prepare#
Build the exact EIP-712 typed data for a buyer to sign.
{ "payer": "0xYourAddress", "network": "eip155:8453" }
Returns typed_data, a payload_stub, and the resolved network. The server signs nothing and holds no key — this exists so a browser doesn't reimplement consensus-critical crypto in JavaScript.
POST /c/{id}/pay#
Same as sending X-PAYMENT to GET /c/{id}, for clients that prefer an explicit POST.
GET /c/{id}/handoff/{nonce} — buyer attribution#
The pairing poll for a payment made somewhere else. Append ?h=<nonce>
([A-Za-z0-9_-]{16,64}, yours to mint) to any pay URL — plain, escrow,
/test — and the settlement stores it. This endpoint answers 404 until a
settled — or escrow-held — payment carries that nonce, then:
{"payment_id": "pay_…", "receipt_url": "…", "escrow_state": null, "test_mode": false}
A held deposit reports escrow_state: "held" — attach the buyer, never book
revenue before release. A wrong nonce is indistinguishable from a payment
that never happened. Also at GET /e/{id}/handoff/{nonce}, and the same
nonce arrives server-side as handoff_id on checkout.paid and every
escrow event. The hosted pay page cooperates: wallet payments forward the
page's ?h=, and qr.svg?h=… folds it into the QR (that response is
no-store; the plain QR caches for a day). The full platform pattern:
build a marketplace.
GET /c/{id}/test — the rehearsal face#
Every checkout has one, forever. Same product, same origin call, simulated
settlement: synthetic 0xtest_… hash, no funds, test_mode: true on the
payment and receipt. /pay and /prepare exist here too (/c/{id}/test/pay,
/c/{id}/test/prepare), so a whole client can run against the test face
unchanged.
- The path decides. Which face settles simulated is the path plus the checkout's armed state — never the payment payload. A test payload on an armed live face is refused.
- The test face can never be armed, lists testnet rails only, and its
accepts[].resourcenames/c/{id}/test. - Rehearsals never flip
status— a single-shot is still buyable after any number of them. - The faces cross-link: a live face's
402carriesendpoint.rehearsal.test_url; a test face states in words that it settles without real money, pluslive_url(or that the seller has not gone live). - Rehearsal budget: origin-backed (
relay/mcp) test deliveries are capped per checkout per day — default 25,test_delivery_daily_capon create to change it. Past the cap is a429with alive_urlhint. Inline and webhook rehearsals are unmetered.
URLs of test twins from the retired twin-row model 307-redirect to
/c/{live_id}/test, preserving method, body and headers — a payment already
in flight survives the hop. Package keys bought on an old twin row keep
working against their original row.
Seller money surfaces stay clean of all this: /v1/receipts and
/v1/receipts/export default to real payments and take ?test_mode=true for
the rehearsal ledger instead — a switch, never a mix.
Recurring series#
See the guide for the model. A checkout created
with schedule_interval_days + schedule_periods reads back with a
schedule block and advertises extra.schedule in its 402.
POST /c/{id}/prepare#
Returns period 0 as always, plus schedule[]: one {period, valid_after,
valid_before, typed_data, payload_stub} per held period.
POST /c/{id}/pay#
Period 0 in the payment header; the held periods in the body as
{"schedule": [payload, ...]}. All are verified before anything settles.
The response carries schedule (id, url, status, periods,
periods_settled, paid_through, next_charge_at).
GET /v1/schedules · GET /v1/schedules/{id} · POST /v1/schedules/{id}/cancel#
The seller's view. GET /v1/schedules?checkout_id=&status= lists; the
detail carries periods_detail[] (status, window, receipt per period);
cancel stops the series ({"reason": "…"} optional).
GET /schedule/{id} · POST /schedule/{id}/cancel#
The buyer's view, accountless. The page carries cancel.message (sign it
EIP-191 and POST {"signature"}) and cancel.onchain[] (one
CancelAuthorization typed data per held period; sign and POST
{"authorizations": [{"nonce", "signature": {"v","r","s"}}]} and we submit
USDC cancelAuthorization for each). A test-mode series cancels with
{"payer"}.
Escrow#
The full story — model, windows, disputes, embedding — is in the escrow guide; this is the endpoint surface.
POST /v1/escrow-checkouts#
Create an escrow checkout: same body as POST /v1/checkouts plus the escrow
terms. Base only for now — Solana's escrow program is deployed but
withheld, because its deposit needs buyer-funded account rent that the
program refunds to the buyer, not to us. A seller with no payout wallet on
an escrow-capable rail is refused at create, in words, rather than given a
checkout no one can pay. Ids start esc_, the page lives at /e/{id}, and
the fee is 1% + $0.02, which REPLACES the standard 0.5% + $0.01 rather than
adding to it — an escrow lands a deposit and a release, plus any relayed
buyer actions we pay for.
{
"price_usd": "250.00",
"title": "Repo transfer: acme/parser",
"escrow_preset": "standard", // instant | standard | shipping
"escrow_settle_window": 259200, // optional per-window overrides, seconds
"escrow_hold_window": 259200,
"escrow_backstop_window": 2592000,
"arbiter_evm": "0x…", "arbiter_svm": "…" // optional; see arbiter rules below
}
Windows clamp to ≥1h; settle/hold ≤30d; backstop ≤180d and > hold. All terms freeze at create — they end up inside the buyer's signature.
Arbiter resolution: explicit on the create → your account's
(PUT /v1/account/escrow) → the checkout402 default. The last step is for
direct sellers only: a platform account creating for a sub-seller must
name its own arbiter or the create is refused — B2B2X disputes are the
platform's to judge, never ours. A chain you name no arbiter for offers no
escrow rail.
On a PLAIN checkout, escrow_offer: "optional" auto-mints a linked escrow
twin and advertises it in the 402 (extra.escrowAvailable); "required"
returns the escrow face as the primary URL.
GET /e/{id} · POST /e/{id}/prepare · pay#
The escrow face speaks the same x402 protocol as /c/{id}. Differences the
buyer sees in the 402: payTo is the escrow contract (Base) or program
(Solana), and accepts[].extra.escrow carries the full terms — windows,
arbiter, both exit routes, and a sentence saying what happens if nobody acts.
Everything in that block is inside the signed nonce, so what is shown is what
is enforceable. On Solana, /prepare returns a partial transaction plus
payer_salt — present both at pay time; the buyer also funds ~0.003 SOL of
account rent, returned when the escrow closes.
A deposit is not a sale. The payment appears in /v1/receipts and fires
checkout.paid only at RELEASE. The escrow.* webhook events
(held, on_hold, escalated, released, refunded) carry the earlier
lifecycle.
Buyer controls — accountless#
POST /e/{id}/actions/{hold|approve|request-refund}?payment_id=…
POST /e/{id}/actions/{action}/submit?payment_id=…
No auth: the endpoints return something only the right wallet can sign — the contract, not us, decides who may act.
Buyers never need gas, on either chain. The prepare call returns
typed_data (Base) or a partially-signed transaction (Solana); sign it and
POST it back to /submit, and checkout402's relayer pays the fee. On Base
that means {v, r, s, deadline} from the EIP-712 payload — the signature is
the authority, so the contract obeys the buyer, the seller or the arbiter and
never us. transaction is still returned for anyone who prefers to broadcast
themselves; escrows minted before the relaying contract (2026-08-26) offer
only that path, which is why gasless in the response says which one you
have. The seller's one control, cancel (concede and
refund), uses the same shape. GET /e/{id}/status?payment_id=… returns the
live state and a plain sentence saying what happens next.
Disputes — for sellers and platforms#
GET /v1/escrows?status=held|on_hold|escalated
GET /v1/escrows/{payment_id}
POST /v1/escrows/{payment_id}/resolve?to_seller=true|false
POST /v1/escrows/{payment_id}/resolve/submit?to_seller=… (Solana only)
resolve prepares the ruling as an UNSIGNED transaction for the arbiter
wallet named in from_ — checkout402 never holds that key. family says how
to finish: "evm" → sign and broadcast yourself; "svm" → sign your slot and
hand it to /resolve/submit, which broadcasts on checkout402's gas (your
arbiter wallet needs no SOL). The only two values of to_seller are the two
exits the buyer's deposit committed to.
The dashboard's Live escrows panel carries the same controls for a human;
the MCP tools (list_escrows, get_escrow, resolve_escrow,
resolve_escrow_submit, and the accountless escrow_hold / escrow_approve /
escrow_request_refund / escrow_submit) carry them for an agent.
GET /v1/account/escrow · PUT /v1/account/escrow#
Standing escrow settings: escrow_policy (disabled | seller_choice |
always), escrow_default_preset, escrow_arbiter_evm, escrow_arbiter_svm.
Defaults copied at create, never pointers followed later — an account edit
cannot change terms a buyer already signed. {"arbiter": "payouts"} on the
PUT copies your payout wallets as arbiters (a snapshot, not a link), and a
PUT that changes arbiters answers with their readiness.
GET /v1/account/escrow/readiness#
Can the named arbiter rule today, per chain: gas balance and
rulings_affordable on EVM (a ruling is a signed transaction — no ETH, no
ruling, whoever holds the key), and the plain statement that Solana rulings
ride our relayer's gas. readiness: null means no arbiter is named and
checkout402 adjudicates. The same block arrives as arbiter_readiness in
every escrow.escalated webhook — the warning delivered at the moment it
is actionable. Balance says nothing about custody; the response's
custody_note says so.
Embedding#
GET /v1/account/embed
PUT /v1/account/embed {"embed_origins": ["https://shop.example"]}
GET /embed/check?checkout_id=…&origin=… (public, open CORS)
Nothing may frame /c/, /e/ or /r/ until the seller allowlists origins —
those pages carry wallet-signing buttons. The list REPLACES on each PUT;
wildcards and non-localhost http are refused. embed/check is the
diagnostic embed.js uses to explain a blank frame; the snippet itself is
two lines:
<div data-c402="chk_…"></div>
<script src="https://api.checkout402.com/static/embed.js" async></script>
Create the checkout with "wallet_button": true or the embed is a read-only
product page. Host pages hear c402:ready/paid/held/on_hold/released/escalated/resize
as DOM events (payload fields per event: escrow guide → Listening);
credit a sale on c402:paid only — c402:held money can still refund.
Other embed shapes, because a frame is not always the right one:
- Button mode —
data-c402-mode="button"(+data-c402-label) renders a Buy link instead of the frame: zero config, no allowlist, no wallet_button. - QR asset —
GET /c/{id}/qr.svg(and/e/{id}/qr.svg, plus/test/variants) is a stable, cacheable SVG QR of that face's URL, for surfaces that are not web pages: invoices, packaging, print, slides. - Other attributes:
data-c402-test="true"(rehearsal face),data-c402-height(frame height before the first resize),data-c402-theme(light/dark/auto— match the host page, not the visitor's OS),data-c402-accent(#RRGGBBfor price and highlights),data-c402-content(a gated piece — see Gated content below),data-c402-price/data-c402-blurb(the lock panel's price and wording),data-c402-open(set by us on the mount once the reader is through),data-c402-grant(a pass your own server issued for a reader you have already authenticated — the article opens with no wall and no payment),data-c402-mediawithdata-c402-kind(a gated photo, clip or recording;data-c402-blurblurs the preview while locked),data-c402-format(flat— no card chrome),data-c402-radius(0–32),data-c402-font(system),data-c402-part(pay/wallet/qr/agent/price— modular surfaces of one checkout; siblings flip to paid together, and the paying parts always carry name + total),data-c402-receipt="pay_…"(a receipt in the frame — the after-sale half; id from thecheckout.paidwebhook or the paid/held events).
In-frame paying works with the wallets buyers already have: any EIP-6963 EVM wallet (multi-wallet users get a picker) and any Wallet-Standard Solana wallet; mobile buyers without an extension get open-in-wallet deep links, and desktop buyers without one get the QR handoff.
Connecting an integration (OAuth)#
A plugin or app can connect to a seller's account without anyone pasting an
API key. It is the same OAuth server Claude uses for /mcp — dynamic client
registration at POST /oauth/register, then /oauth/authorize and
/oauth/token — with one scope that the REST API accepts:
| scope | what it reaches |
|---|---|
checkouts.write |
create, change, retire and go live with checkouts; issue reader passes |
The MCP scopes are not accepted here, deliberately. mcp.write maps
internally to full, and on this surface full also mints API keys and
repoints payout wallets — neither of which any consent screen mentions. A
token carrying only mcp.read/mcp.write gets 401 on REST, so no token
already issued gains reach.
checkouts.write is capped at the payments rung. It cannot
PUT /v1/account/wallets and cannot POST /v1/keys: an OAuth token is
revocable and expires, and one able to mint an API key would have escaped its
own leash. Both refuse with 403.
Ask for it by name in the authorize request:
/oauth/authorize?...&scope=checkouts.write
Gated content (Pay to Read / Pay to Scrape)#
The paid part of an article does not stay in the publisher's HTML; it is served
from /x/{id}. Guide with screenshots: Pay to
Read.
Creating one#
POST /v1/checkouts with a content object. The id comes back prefixed
cnt_ and its url is on the /x/ face.
content field |
|
|---|---|
teaser |
the free part. Falls back to description, then the opening of the body |
author, published |
byline, in the markup and on the wall |
canonical_url |
the seller's URL. Ours is noindex and points its canonical here. Its origin is also added to this checkout's frame-ancestors, so the declared page can embed the pay control without a second allowlist entry |
keywords |
seller tags. In the JSON-LD and leading the 402's discovery tags |
section, language, license_url |
passed through to the structured data |
pass_scope |
seller (default) opens everything that account sells; content opens one piece |
pass_hours |
pass lifetime. Default 24, max 8760 |
Escrow and content are refused together (400): content is delivered at
settlement, so there is nothing to hold. Fulfillment must be inline or
relay.
GET /x/{id}#
One URL, three answers, decided by what the caller carries and what it accepts. Never cacheable: the same URL becomes the article the moment a pass is presented.
| the caller | the answer |
|---|---|
a valid pass (header or ?grant=) |
the body. text/html wrapped in .c402-paid, otherwise verbatim |
no pass, Accept: not HTML |
402 with accepts[], plus endpoint.article (teaser, byline, tags, pass terms) |
no pass, Accept: text/html |
the wall |
An ordinary checkout id here is a 404, and a cnt_ id at /c/ is a 404:
the faces are separate namespaces, not aliases. The face is inside the signed
resource, so prepare and pay cannot straddle two of them.
POST /x/{id}/prepare · POST /x/{id}/pay#
Identical to the /c/ pair — the same implementation serves all three faces.
The difference is the response to pay, which carries the pass:
{ "receipt": { "…": "…" },
"grant": "v1.…", "grant_scope": "seller", "grant_expires_in": 86400 }
Presenting a pass#
X-C402-Grant: v1.…
or ?grant=… for a browser, because the publisher's page holds it and passes
it into the frame. It is a signed string, not a database row: verifying it
costs no query and works on any instance. A pass minted for one seller is not
even parseable as valid by another.
GET /v1/content/{id} · editing an article#
The read half of the mini CMS: the article as the seller wrote it, plus the full declaration. Owner only; the body is the paid goods and appears on no public surface.
Edit with the ordinary PATCH — payload (the article text, gated content
only; an ordinary checkout's goods stay frozen) and content (the whole
declaration). PATCHing content replaces the declaration, so
read-modify-write: fetch it here, change the field, send it all back. Edits
are live for the next read, including readers already holding passes. The
dashboard's Manage panel does exactly this.
POST /v1/grants#
A pass for every article this account gates, minted without naming one — for the moment a reader signs in to the publisher's own system, before anyone knows what they will open. Seller credential only.
| field | |
|---|---|
subject |
required. Who it is for, in YOUR terms |
ttl_seconds |
default 24h. Capped at 31 days |
uses |
optional fetch limit |
One per reader per session, cached beside the session. Not one per page view.
POST /v1/content/{id}/grants#
Let a reader you have already authenticated read without paying. Seller credential only: the pass is free access to your own paid content, so the only party who may issue one is the party whose revenue it costs.
| field | |
|---|---|
subject |
required. Who it is for, in YOUR terms. Recorded, never interpreted |
scope |
seller (default) opens everything you gate; content opens this piece |
ttl_seconds |
defaults to the content's pass_hours. Capped at 31 days |
uses |
optional fetch limit |
Returns grant, grant_scope, grant_expires_in. Give it to a browser as
data-c402-grant on the mount, or to an agent as X-C402-Grant.
A chk_/esc_ id here is a 404: only gated content has passes.
GET /x/{id}/snippet#
Public, unauthenticated, and everything in it is public by design. Returns the
block to paste (snippet), plus gate_url, teaser, jsonld and
price_usd.
Platforms (B2B2X)#
GET /v1/account/platform · PUT /v1/account/platform#
One request is the whole setup: {"platform_account": true,
"platform_fee_bps": 200, "platform_fee_to": "0x…"}. The fee is basis
points (0–2000) taken from the sub-seller's price — the buyer's total
never changes — paid to your fee wallet(s). With the mode on, creates
default to inherit_account_payouts: false (your wallets never leak onto a
sub-seller's checkout) and the standing fee fills in where a create names
none. Defaults are copied at create: existing checkouts keep the fee
they froze. GET returns the mode, the standing fee, fee_wallets per
chain, and max_bps so a UI can state the cap before the API enforces it.
A per-create platform_fee_bps always wins over the standing default.
GET /v1/platform/quote#
?price_usd=50.00&platform_fee_bps=250 → buyer_pays /
seller_receives / platform_cut / protocol_fee. Public and
unauthenticated — price a platform before you have an account. Computed by
the same modules that settle, so the preview cannot drift from a payment.
The two rules the numbers teach: your cut comes OUT of the seller's price
(cut + seller == price, exactly); the protocol fee goes ON TOP to the buyer.
Packages#
Set max_deliveries when creating a checkout and you have sold a package: one
payment, one on-chain settlement, N calls. Minimum package price is $1.00.
Because settlement happens once, the per-call cost collapses — a $1 package of 1,000 calls pays the protocol's fixed component once instead of a thousand times. That is how sub-cent API pricing works here.
The purchase response carries the key, once:
{
"fulfillment": { "payload": … },
"package_key": "pk_x7Kd…",
"deliveries_remaining": 999
}
Spend it with any parameters you like:
curl "https://api.checkout402.com/c/chk_abc123?q=globex" \
-H "Authorization: Bearer pk_x7Kd…"
{ "fulfillment": { "payload": … }, "calls_remaining": 998 }
Notes that matter:
- The key is shown once. Only its hash is stored, so it cannot be re-issued. Lose it and you buy the package again.
- It is scoped to one checkout. A key for another offer gets
401, the same answer as an unknown key. - Parameters are free per call. This is why a package needs a key at all:
re-presenting
X-PAYMENTcan only ever repeat one query, because the params are bound into the signature. - A per-tool MCP checkout still only buys that tool. The key carries no
signature, so
mcp_toolis enforced on every call instead — otherwise 500 calls of a $0.01 tool would buy 500 calls of a $5 one. - Exhaustion is
402, withcalls_usedandcalls_includedin thehelpblock — buy again to continue. - A failed origin call is refunded to your allowance; you are not charged for a call that returned nothing.
Offers advertise their size, so a package and a single call are distinguishable before purchase:
{ "id": "chk_abc123", "price": {"amount": "10.00"}, "calls_included": 1000 }
Package terms are in the 402#
An agent decides from this block, so the terms of a package are in it before anything is signed:
"endpoint": { "package": { "calls": 500, "expires_in_days": 30 } }
Entitlements expire 30 days after settlement by default; set
package_ttl_days on the checkout to change it. Keys minted before expiry
existed carry expires_at: null and never expire. An expired key is a 410,
not a 401 — it has already proved it exists, and its holder needs to know which
of the two happened.
Failure, and who is charged#
| Origin returns | Charged | HTTP to the buyer |
|---|---|---|
5xx, timeout, unreachable |
no | 502 |
401, 403, 429 — the seller's credential or capacity |
no | 502 |
400, 404, 422 — the buyer's request |
yes | the origin's own status |
Failure bodies carry "fault": "seller" \| "buyer" \| "platform" and a
help.report block naming where to report the problem.
GET /c/{id}/usage#
Free, spends nothing, Authorization: Bearer pk_….
{ "calls_included": 500, "calls_used": 2, "calls_remaining": 498,
"expires_at": "2026-08-31T…Z", "receipt": "/r/pay_…",
"calls": [ { "seq": 1, "at": "…", "status": "delivered",
"counted": true, "fault": null, "latency_ms": 39, "bytes": 64 } ],
"burn": { "calls_per_day": 12.4, "projected_days_remaining": 21.3,
"runs_out_first": "expiry", "basis": "trailing_7d" } }
counted says whether a call cost you; fault says why it did not.
runs_out_first is "calls" or "expiry" — pacing against the wrong one loses
the balance you were preserving. burn is null with
basis: "insufficient_usage" until there is enough evidence to project from.
Spending is rate-limited (60/min per key by default); responses carry
X-RateLimit-* so a caller can self-throttle rather than discover a 429.
Reports#
A buyer who paid and got nothing can say so, with no account. Settlement is non-custodial, so this cannot produce a refund — it makes the problem visible to the seller and to us, with our own delivery records attached.
POST /v1/checkouts/{id}/reports |
Public. {category, detail?, receipt_id?} |
GET /v1/checkouts/{id}/reports, GET /v1/reports |
Seller. Carries the joined delivery rows |
PATCH /v1/reports/{id} |
Seller acknowledges/resolves with a note |
There is no DELETE — a record of a complaint is not the seller's to erase,
same rule as receipts. Supplying a receipt_id or package key files it as a
buyer report with the payment attached; without one it is anonymous and rate
limited harder. Categories are a closed set so they aggregate:
not_delivered, wrong_data, stale_data, schema_changed,
shape_not_as_described, too_slow, unauthorized_charge, suspected_fraud,
other.
Sellers can subscribe to the checkout.reported webhook.
Liabilities#
GET /v1/analytics carries a liabilities block: unspent calls across every
live package key, per checkout, valued from provider_amount.
"liabilities": { "live_keys": 3, "calls_unspent": 1240,
"unearned": { "amount": "62.00", "currency": "USD" },
"offers": [ … ] }
A package is cash received against calls not yet served, so revenue and this
belong on one screen. Revoked and expired entitlements are excluded — neither is
still a claim. DELETE /v1/checkouts/{id} returns the same figure rather than
letting a seller retire something and forget they still owe it; it never blocks
the retirement.
Receipts#
Receipts are minted by settlement — there is no create endpoint, by design: a receipt that could be POSTed into existence could not prove anything. Every settled payment gets one automatically; on escrow checkouts it appears at release (a deposit is refundable money, not a sale).
parties (optional, on any create): platform-declared identities —
{"buyer": {"name","email","ref"}, "seller": {"name","ref"}} — rendered on
the receipt page and download ("Billed to …", declared seller name), echoed
verbatim in checkout.paid and receipt rows, flattened to
buyer_name/buyer_ref in the CSV export. Opaque to checkout402: you
assert, we carry, the chain proves. Appears on the (unguessable, public)
receipt page — share its URL accordingly.
For platforms: GET /v1/receipts rows carry sold_by, and
?sold_by=<seller> returns one sub-seller's statement; the CSV export
carries the column. The checkout.paid webhook payload includes
receipt_url and sold_by, so a backend relays the receipt and routes the
sale with nothing derived and nothing joined. Sub-sellers have no accounts to
pull receipts with — the platform distributes; the public /r/{payment_id}
page is the artifact to hand onward.
GET /r/{payment_id}#
JSON or HTML. Contains payer, seller, network, the exact split, tx_hash, and delivery outcome.
subject is a stable pseudonym for the buyer, scoped to you. The same wallet
buying from you again produces the same subject, so you can recognise a repeat
customer without asking anyone to register — but the same wallet buying from a
different seller produces a different one, so nobody can correlate your buyers
against theirs. It is derived, not reversible to a wallet address.
Buyers are anonymous by default and nothing here changes that: subject is an
identifier we compute, not an account anyone created.
Recovery, files, and the work queue#
GET /r/{payment_id}/download |
The receipt as a keepable file. Public like the page — the id is the capability, and a buyer with no account still needs proof of what they paid for |
GET /r/by-tx/{tx_hash} |
Recover a lost receipt from its transaction hash, view-only. The durable trail for a throwaway wallet: exchange withdrawal history names the wallet → the chain names the tx → this names the purchase |
GET /v1/receipts/export |
Every settled payment, as CSV — carries sold_by and the flattened parties columns for sub-seller subtotals |
GET /v1/receipts/awaiting-fulfilment |
Sales you were paid for and have not closed out — the work queue that webhook mode implies. Mark done with the fulfilment call your flow already uses |
{
"id": "pay_dVo7SDQsatf4HIFC",
"checkout_id": "chk_abc123",
"network": "eip155:8453",
"amount_usd": "0.100000",
"provider_amount": 100000,
"fee_amount": 10500,
"total": 110500,
"payer": "0x7099...",
"subject": "sub_9f2c41ab7e05d3b8c6a1f402",
"pay_to": "0xSeller...",
"tx_hash": "d84fa280...",
"test_mode": false,
"settled_at": "2026-07-28T08:15:54Z"
}
Keys#
POST /v1/keys |
Mint one. {name, mode, scope}. Shown once. mode defaults to test |
GET /v1/keys |
List (prefixes only, never the secret) |
DELETE /v1/keys/{id} |
Revoke immediately |
Scopes escalate read_only < payments < full.
What each scope may do#
| Scope | Can | Cannot |
|---|---|---|
read_only |
List and read checkouts, receipts, payments, analytics, usage | Create anything, move money, change account settings |
payments |
Everything above, plus create and update checkouts, take checkouts live, mark a receipt fulfilled | Change payout wallets, manage webhooks or keys |
full |
Everything | — |
Endpoints that move money or change where it goes require more than
read_only, and say so:
| Scope | |
|---|---|
PUT /v1/account/wallets |
full |
POST /v1/webhooks |
full |
DELETE /v1/webhooks/{id} |
full |
POST /v1/receipts/{id}/fulfilled |
payments |
POST /v1/account/wallets/preview |
read_only |
A key below the required scope gets 403 naming the scope it needed — not a
401, because the key is valid; it is the authority that is missing.
Changed — a read_only key used to be enough
These four endpoints previously accepted any valid key, because the scope
was resolved and then discarded. A read_only key — the one you hand out
believing it is harmless — could repoint the account's default payout
wallet. If you are driving any of the above with a read_only key, mint a
key with the scope in the table; nothing else changes.
An API key cannot mint a browser session
POST /auth/session/cookie now refuses automation keys with 403. A
session cookie carries full account authority no matter which key asked for
one, so a read_only key could otherwise trade itself for a full one in a
single request. Sessions obtained from a magic link — including the bearer
session token the dashboard holds — are unaffected.
Scopes are not a sandbox for untrusted code: a key you would not want used to
create a checkout should be read_only, and a key you would not want at all
should be revoked. Pick the lowest scope that does the job.
Spending wallets#
The rail an agent buys with. Custodial, and the only custody in the
service: checkout402 generates the wallet, holds its key encrypted, and signs
with it when an agent pays an x402 challenge. Its own namespace, never under
/v1/account/wallets, which is where you get paid. {chain} is base or
solana, one active wallet per chain.
| Who may call | ||
|---|---|---|
GET /v1/spending-wallets/health |
nobody | Public self-check: does the schema answer, does the encryption key load, do the generators import. Names what is wrong |
GET /v1/spending-wallets |
key or session | Addresses only. Never reads the chain |
POST /v1/spending-wallets/{chain} |
session only | Generate one. Returns secret (phrase + key) once; the phrase is never stored |
GET /v1/spending-wallets/{chain}/balance |
key or session | Reads the chain. nagging: true plus a warning above $999 |
POST /v1/spending-wallets/{chain}/pay |
live key, OAuth or session | {url, max_usd?, params?}. Pays any x402 URL under the account's limits (a max_usd only lowers the ceiling) and returns {paid, url, receipt, goods}. A checkout's /test URL is a free rehearsal that needs no funds |
GET /v1/spending-wallets/{chain}/limits |
key or session | The person's limits: per_payment_usd, per_day_usd, spent_today_usd, left_today_usd |
PUT /v1/spending-wallets/{chain}/limits |
session only | {per_payment_usd, per_day_usd}. Defaults $1 and $10. The agent can read them, never move them |
GET /v1/spending-wallets/{chain}/funding?amount=5 |
session only | A payment request with token, chain, address and amount filled in: uri (EIP-681 or Solana Pay), links per wallet app |
GET /v1/spending-wallets/{chain}/funding.svg?amount=5 |
session only | The same request as a QR |
POST /v1/spending-wallets/keys |
session only | A live key for an agent, shown once, without the payout-wallet rule |
GET /v1/rehearsal-checkout |
key or session | A checkout whose /test face any agent can pay for free: {url, ask} |
POST /v1/spending-wallets/{chain}/reveal |
session only | The private key. This is the withdrawal |
DELETE /v1/spending-wallets/{chain} |
session only | Archive it. Funds are untouched; reveal first |
GET /v1/spending-wallets/{chain}/events |
key or session | created, spent, revealed, flagged, revoked, with the actor |
Two authorities, kept apart on purpose. Spending takes any credential that
is not a test key, because the point is that an agent buys without a human,
and the dashboard offers the same call so a person can check a freshly funded
wallet actually pays. Revealing takes a browser session
and nothing else: a request that carries an API key is refused with 403 even
when a valid cookie rides along, no OAuth token reaches it, and no MCP tool
exposes it. The credential that spends must not be the credential that
exfiltrates, or every max_usd ceiling is decorative.
The ceiling on a payment is the smallest of the agent's max_usd (if any),
the per-purchase limit, and what is left of the day. A c402_test_ key
cannot pay (403): the money is real and someone else's endpoint has no
rehearsal face. The balance is read from the chain before anything is signed;
a short wallet answers 402 with insufficient balance and spends no gas.
Every refusal is recorded as a refused event with its reason.
There is no transfer-out endpoint and none will be added. A holder who wants their funds elsewhere reveals the key and moves them. See the spending wallets guide.
Origin keys#
The seller's OWN upstream credentials, so an origin-backed checkout (relay, file or mcp) can wrap something
that answers 401. Distinct from /v1/keys, which authenticate you to us.
POST /v1/origin-keys |
{auth_type, value, name?, header_name?} → stored encrypted |
GET /v1/origin-keys |
list, with checkouts_using per key |
PATCH /v1/origin-keys/{id} |
{value} — rotates everywhere at once |
DELETE /v1/origin-keys/{id} |
revoke (soft; warns if checkouts still use it) |
auth_type is bearer | basic | api_key | custom; the last two need
header_name.
Reference one with origin_auth_secret_id from any origin-backed checkout —
relay or mcp — or pass origin_auth_value to store a one-off
encrypted copy. POST /v1/mcp/tools accepts either.
The value is never returned
Not on create, not on list, not on rotate. There is no read path — it is
decrypted only inside the relay, on the way out to your origin. A credential
belonging to another account returns 404.
MCP servers you sell#
POST /v1/mcp/tools#
Ask a seller's own MCP server what tools it advertises. Seller-authenticated
(payments scope), because it makes an outbound request to a URL the caller
chooses.
{ "origin_url": "https://mcp.yourco.com/mcp" }
Optional: origin_auth_type, origin_auth_value, origin_auth_header, or
origin_auth_secret_id to reuse a stored origin key. Sending the credential the
checkout will use is the point — a server that 401s the probe would 401 a
buyer.
Returns { origin_url, tools: [{ name, description, input_schema }] }.
502 if the server cannot be reached or refuses the handshake.
Nothing is stored
This is not a catalogue. The answer is not saved, not published, and exists
so a mcp_tool is a name the server actually has. A guessed name creates a
checkout that takes the money and then fails on a tool the origin never had.
Calling a paid tool#
The buyer's parameters become the tool's arguments. On a whole-server checkout
tool=<name> picks the tool; on a per-tool checkout the name is fixed and a
different tool= is refused. Parameters are strings because they are signed, so
a tool needing numbers or nesting takes arguments as a JSON object encoded
as a string:
GET /c/chk_abc123?tool=search_companies&arguments={"limit":5}
origin_method is forced to POST: MCP over Streamable HTTP is JSON-RPC over
POST. The initialize handshake runs before every call, and replies are read
from application/json or text/event-stream.
Discovery#
How a machine that has never been here finds out what is purchasable — public and unauthenticated by design, because the agent that most needs a catalogue is the one without a key.
GET /.well-known/checkout402 |
This service: capabilities, rails, links |
GET /v1/sellers/{slug}/offers |
One seller's catalogue, machine-readable |
GET /schedule/{slug} |
The same catalogue, human face |
GET /v1/sellers/{slug}/embed |
The catalogue as an embeddable fragment |
Webhooks#
POST /v1/webhooks |
Register — or re-key: the same url again returns a fresh secret for the same endpoint (rekeyed: true), old secret dead from the next delivery. Run it per deploy; it is also secret recovery and revocation |
GET /v1/webhooks |
List, with failure counts (never the secret) |
GET /v1/webhooks/verification-key |
Public, no auth: the Ed25519 key that verifies every delivery's X-Checkout402-Ed25519 header — receivers that use it need no stored webhook secret |
DELETE /v1/webhooks/{id} |
Remove a receiver; its pending deliveries go with it |
See Webhooks for signature verification.
Auth and profile#
POST /auth/magic-link/request |
{email} → emails a sign-in link |
GET /auth/verify?token= |
Consumes the link, sets the session cookie |
POST /auth/wallet/challenge |
{address, network} → a message to sign |
POST /auth/wallet/verify |
{message, signature} → account + first key |
GET /auth/me |
Your full profile |
PATCH /auth/me |
Update name, bio, slug, website_url, avatar_url, profile_type, social_links |
POST /auth/logout |
Clears the session |
Empty string clears a profile field; omitting it leaves it unchanged. A taken slug returns 409.
Activity log#
GET /v1/account/events |
Your account's state changes, newest first |
Owner-scoped: you see your own timeline and nobody else's. limit (default
50, max 200), action as a prefix filter (checkout. is the whole domain),
before as a cursor (an event id or an ISO timestamp). Each row:
{id, action, object_id, detail, actor, test_mode, at} — actor names the
credential (session, key:<prefix>, oauth:<client>, wallet). Secrets
never appear; what is and is not recorded is spelled out in
the records guide.
Facilitator#
x402 facilitator endpoints, one set per family:
/x402/base/{supported,verify,settle}
/x402/solana/{supported,verify,settle}
/x402/stellar/{supported,verify,settle}
Errors#
Errors are RFC 9457 problem documents,
served as application/problem+json. Branch on type — it is a stable URI, and
unlike a prose message it will not change wording under you.
{
"type": "https://checkout402.com/problems/payment-required",
"title": "Payment required",
"status": 402,
"detail": { "message": "invalid payment: signature does not match payer", "help": { … } },
"help": {
"fix": "re-read the 402 and sign the CURRENT requirement",
"prepare": "POST /c/chk_.../prepare {\"payer\": \"0x...\"}",
"retry_is_safe": "re-presenting the SAME X-PAYMENT redelivers; it never charges twice"
}
}
The help block is the useful part: it names the fix, not just what went wrong.
It is available both at the top level and inside detail, which is preserved
unchanged for existing clients.
type |
Status | Meaning |
|---|---|---|
/problems/unauthenticated |
401 | No valid key or session |
/problems/payment-required |
402 | The payment was presented and rejected |
/problems/forbidden |
403 | Authenticated, but not yours |
/problems/not-found |
404 | No such checkout, receipt or key |
/problems/conflict |
409 | An Idempotency-Key request is still in flight |
/problems/invalid-request |
422 | Validation failed; see errors[] for the fields |
/problems/upstream-failed |
502 | The seller's origin failed after payment |
/problems/unavailable |
503 | No settleable network right now |
The 402 is not an error#
GET /c/{id} answering 402 is the protocol working, not a failure, so it is
not a problem document. Its body is the x402 payment-required shape with
accepts[], and the same requirements are in the PAYMENT-REQUIRED header. Pay
and retry — see Buy as an agent.
Validation errors name the field#
{
"type": "https://checkout402.com/problems/invalid-request",
"status": 422,
"detail": { "message": "request failed validation" },
"errors": [{ "field": "price_usd", "reason": "Field required" }]
}