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 | required — your payout wallet |
description |
string | |
fulfillment |
inline | relay | webhook |
default inline |
payload |
any | the goods, for inline |
origin_url |
string | your endpoint, for relay |
reusable |
bool | true = payment link; default single-shot |
test_mode |
bool | settles without a chain |
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 |
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.
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.
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. - 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 }
Receipts#
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.
{
"id": "pay_dVo7SDQsatf4HIFC",
"checkout_id": "chk_abc123",
"network": "eip155:8453",
"amount_usd": "0.100000",
"provider_amount": 100000,
"fee_amount": 5000,
"total": 105000,
"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.
Origin keys#
The seller's OWN upstream credentials, so a relay checkout can wrap an endpoint
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 from a checkout with origin_auth_secret_id, or pass
origin_auth_value to store a one-off.
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.
Webhooks#
POST /v1/webhooks |
Register. Returns the signing secret once |
GET /v1/webhooks |
List, with failure counts |
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.
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" }]
}