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:

json
{
  "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" }
    }
  ]
}
FieldWhat it isWhy it matters
x402VersionProtocol versionA client that does not know the version must not guess
acceptsArray of requirementsSeveral assets or chains may be offered; pick one you recognise
schemeHow payment is provedexact today. An unknown scheme is a refusal, not a challenge
networkChain identifierBoth base and eip155:8453 appear in the wild — accept both
maxAmountRequiredAtomic units, as a stringUSDC has 6 decimals: "10000" is $0.01, not $10,000
assetToken contract addressNever assume. The domain you sign over comes from this contract
payToWhere the money goesThis is the address the index joins listings to settlements on
resourceCanonical URL being paid forDistinguishes a service fee from forwarded value in the record
maxTimeoutSecondsHow long the server will waitYour validBefore must comfortably exceed it
extraScheme-specificFor 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:

javascript
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_sign over 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.version and the asset address, 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.
  • nonce is 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:

json
{
  "x402Version": 1,
  "scheme": "exact",
  "network": "base",
  "payload": { "authorization": { "from": "0x…", "to": "0x…", "value": "10000", "validAfter": "0", "validBefore": "1766000600", "nonce": "0x…" }, "signature": "0x…" }
}
bash
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:

json
{
  "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 Transfer log from the token: payer → payee, for the amount.
  • An AuthorizationUsed log from the token, because the transfer went through EIP-3009 rather than a plain transfer call.

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#