Protocol
x402 protocol
The payment handshake step by step, over HTTP and over MCP. For anyone writing a buyer client or seller middleware in a language without a Zanora package.
You only need this page if you're implementing the protocol yourself. The SDKs and middleware do all of it.
The sequence
Buyer Seller middleware Gateway
│ call (no payment) ───────► │ POST /v1/x402/challenge ──────► │
│ ◄──── 402 + challenge ──── │ ◄─────────── signed challenge ── │
│ verify challenge, sign proof │
│ call + proof ────────────► │ POST /v1/x402/authorize ──────► │ policy, balance, hold
│ │ ◄─────── { ok, transactionId } ─ │
│ │ run handler │
│ │ POST /v1/x402/complete ───────► │ signed receipt
│ ◄── response + receipt ─── │ (or /v1/x402/fail → refund) │Seller side: four gateway routes
All four need scope payments:write and a provider key that owns the capability.
/v1/x402/challengeBody { capabilityId }. Returns a signed challenge. The price comes from the listing, not from you:
{ "capabilityId": "cap_…", "providerId": "prv_…", "amountMinor": "100", "currency": "USD",
"nonce": "…", "expiresAt": "…", "payTo": "wal_…", "signature": "…", "signingKeyId": "…" }The nonce can be used once, and the challenge expires. Return it to the buyer unchanged.
/v1/x402/authorizeBody { proof }, where proof is the buyer's decoded payment proof. The gateway checks the signature against the wallet's registered public key, rejects nonce replays and expired challenges, applies the buyer's policy and budget, checks the balance, and takes the money into a hold.
// 200
{ "ok": true, "transactionId": "txn_…" }
// 402
{ "ok": false, "code": "POLICY_DENIED", "reason": "denied by rule: price > 20", "decision": { "…": "…" }, "approvalId": "apr_…" }On ok: false, don't run the handler. Return the refusal to the buyer: status 403 for POLICY_DENIED or APPROVAL_REQUIRED, 402 otherwise, with body { error: { code, message }, decision?, approvalId? }.
/v1/x402/completeBody { transactionId, responseBodyBase64 }, the exact bytes you'll send the buyer, base64-encoded. Returns the signed receipt, with responseHash = sha256(bytes). Send it to the buyer.
/v1/x402/failBody { transactionId, error, kind? }. Marks the call failed and refunds the buyer. Returns a failed receipt. Call this whenever the handler throws or returns an error.
kind | When | Counts toward the circuit breaker |
|---|---|---|
provider_error (default) | the handler threw, answered 5xx, returned an MCP isError, or never answered | yes |
client_error | you refused the buyer's request, e.g. an HTTP 4xx | no. The buyer is still refunded |
The gateway itself records timeout for calls nobody completed, and those count.
If neither complete nor fail arrives, the gateway refunds the buyer after 15 minutes.
Buyer side
- Call the capability without payment. You get a challenge: HTTP
402with body{ error, challenge }, or an MCP error result with the challenge in_meta["zanora.dev/x402-challenge"]. - Verify the challenge: remove
signatureandsigningKeyId, serialise as canonical JSON, and check the ed25519 signature againstGET /v1/platform/public-key. Refuse if it doesn't verify, if it has expired, or ifamountMinoris above your own limit. - Sign a proof with the wallet's private key over canonical JSON of:
{ "walletId": "wal_…", "agentId": "invoice-bot", "capabilityId": "cap_…", "providerId": "prv_…",
"amountMinor": "100", "currency": "USD", "nonce": "<challenge.nonce>", "issuedAt": "2026-09-24T10:12:03.114Z" }and add the result as signature (base64).
- Call again with the proof attached (see the table below).
- Read the receipt. Verify its signature against
GET /v1/platform/receipt-keys/:signingKeyId, and check thatresponseHashequalssha256of the body you received (for MCP, of thestructuredContent, or of thecontentblocks if there's none).
The two transports
| HTTP | MCP | |
|---|---|---|
| Price quote | status 402, body { error: { code: "PAYMENT_REQUIRED" }, challenge } | isError result, _meta["zanora.dev/x402-challenge"] (also in structuredContent) |
| Payment | header x-payment: base64(JSON(proof)) | params._meta["zanora.dev/x402-payment"] = base64(JSON(proof)) |
| Receipt | header x-zanora-receipt: base64(JSON(receipt)) | result._meta["zanora.dev/x402-receipt"] = base64(JSON(receipt)) |
| Failure signal | status ≥ 400, or a thrown handler. 4xx is reported as client_error | isError: true, or a thrown handler (both provider_error) |
The encodings are identical, so REST and MCP are one protocol over two transports. _meta["x402/payment"] is also accepted as the payment key, for compatibility with generic x402 MCP clients.
Canonical JSON
Sort object keys at every level, write amounts as strings, add no whitespace, and leave out the signature and signingKeyId fields. It's the same for challenges, proofs and receipts. canonicalJson, signPayload and verifyPayload in @zanora/core are the reference implementation.