Understand x402
Anatomy of a payment
Walk a single payment field by field, from the 402 challenge to the settlement receipt.
The field-level reference. Read it beside your own bytes when a payment is failing.
1. The challenge#
An unpaid request returns 402 with a JSON body:
{
"x402Version": 1,
"error": "payment required",
"accepts": [
{
"scheme": "exact",
"network": "base",
"maxAmountRequired": "10000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x1234…",
"resource": "https://api.example.com/forecast",
"description": "One weather forecast",
"mimeType": "application/json",
"maxTimeoutSeconds": 60,
"extra": { "name": "USD Coin", "version": "2" }
}
]
}| Field | What it is | Why it matters |
|---|---|---|
x402Version | Protocol version | A client that does not know the version must not guess |
accepts | Array of requirements | Several assets or chains may be offered; pick one you recognise |
scheme | How payment is proved | exact today. An unknown scheme is a refusal, not a challenge |
network | Chain identifier | Both base and eip155:8453 appear in the wild — accept both |
maxAmountRequired | Atomic units, as a string | USDC has 6 decimals: "10000" is $0.01, not $10,000 |
asset | Token contract address | Never assume. The domain you sign over comes from this contract |
payTo | Where the money goes | This is the address the index joins listings to settlements on |
resource | Canonical URL being paid for | Distinguishes a service fee from forwarded value in the record |
maxTimeoutSeconds | How long the server will wait | Your validBefore must comfortably exceed it |
extra | Scheme-specific | For exact, the token's EIP-712 name and version |
Caution
maxAmountRequired is atomic and a string. The single most expensive class of
bug in x402 clients is treating it as a decimal — a client that "pays 10000
USDC" for a one-cent resource will have the authorization rejected for
insufficient balance if it is lucky, and settled if it is not.
2. The authorization#
For the exact scheme you sign an EIP-3009
TransferWithAuthorization over the token contract's own
EIP-712 domain:
const authorization = {
from: account.address, // you
to: requirements.payTo, // exactly what the server asked for
value: requirements.maxAmountRequired,
validAfter: '0',
validBefore: String(Math.floor(Date.now() / 1000) + 600),
nonce: `0x${randomHex(32)}`, // 32 bytes, single-use, enforced by the token
};
const signature = await account.signTypedData({
domain: { name: 'USD Coin', version: '2', chainId: 8453, verifyingContract: requirements.asset },
types: {
TransferWithAuthorization: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' },
{ name: 'validBefore', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' },
],
},
primaryType: 'TransferWithAuthorization',
message: { ...authorization, value: BigInt(authorization.value), validAfter: 0n, validBefore: BigInt(authorization.validBefore) },
});Four rules, each of which has cost somebody a day:
- Sign typed data, never a message.
personal_signover the JSON requirement produces a signature no facilitator can verify. It fails at the far end with an error that reads like a rejection rather than a mistake. - The domain comes from the token contract, not from the server and not from a constant in your
code. Read
extra.name/extra.versionand theassetaddress, or fetch a descriptor that states them. - Addresses must be EIP-55 checksummed before anything hashes them. Typed-data libraries validate the checksum inside the hash function, so a lowercase address fails as "signature does not authorize this transfer" at the facilitator — a message that points nowhere near the cause.
nonceis 32 random bytes and single-use. The token contract enforces it. Reuse is a hard failure, not a retry.
3. The X-PAYMENT header#
The envelope is base64-encoded JSON:
{
"x402Version": 1,
"scheme": "exact",
"network": "base",
"payload": { "authorization": { "from": "0x…", "to": "0x…", "value": "10000", "validAfter": "0", "validBefore": "1766000600", "nonce": "0x…" }, "signature": "0x…" }
}X_PAYMENT=$(printf '%s' "$ENVELOPE_JSON" | base64 -w0)
curl -sL "https://api.example.com/forecast" -H "x-payment: $X_PAYMENT"Warning
Base64 is the header's encoding, not the payload's type. When you call a
facilitator's /verify or /settle directly, paymentPayload must be the
decoded object. Hand it the base64 string and the reference implementation
answers 500 No facilitator registered for x402 version: undefined, because it
reads x402Version off a value that is a string.
4. The receipt#
Success returns the resource plus a base64 X-PAYMENT-RESPONSE:
{
"success": true,
"txHash": "0xabc123…",
"networkId": "base",
"payer": "0x…"
}Log every one of these. The transaction hash is what lets you reconcile your own spend against the chain, and it is what turns "I think I paid" into a fact anyone can check — including Roundhouse, which will have indexed the same settlement from the other side.
5. What the chain records#
The settlement leaves two things behind that matter to an indexer:
- A
Transferlog from the token: payer → payee, for the amount. - An
AuthorizationUsedlog from the token, because the transfer went through EIP-3009 rather than a plaintransfercall.
That second log is on-chain proof the transfer was an authorized payment and not an ordinary transfer that happened to land on a known address. It is why Roundhouse can mark a settlement verified rather than merely probable — see coverage and confidence.
Next steps#
- Schemes and networks — what is supported where
- Errors — the five failures, and which ones retrying can fix
- Your first x402 payment — this page, done for a cent