ZZanoraDocs

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

Output
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.

POST/v1/x402/challenge

Body { capabilityId }. Returns a signed challenge. The price comes from the listing, not from you:

JSON
{ "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.

POST/v1/x402/authorize

Body { 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.

JSON
// 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? }.

POST/v1/x402/complete

Body { 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.

POST/v1/x402/fail

Body { 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.

kindWhenCounts toward the circuit breaker
provider_error (default)the handler threw, answered 5xx, returned an MCP isError, or never answeredyes
client_erroryou refused the buyer's request, e.g. an HTTP 4xxno. 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

  1. Call the capability without payment. You get a challenge: HTTP 402 with body { error, challenge }, or an MCP error result with the challenge in _meta["zanora.dev/x402-challenge"].
  2. Verify the challenge: remove signature and signingKeyId, serialise as canonical JSON, and check the ed25519 signature against GET /v1/platform/public-key. Refuse if it doesn't verify, if it has expired, or if amountMinor is above your own limit.
  3. Sign a proof with the wallet's private key over canonical JSON of:
JSON
{ "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).

  1. Call again with the proof attached (see the table below).
  2. Read the receipt. Verify its signature against GET /v1/platform/receipt-keys/:signingKeyId, and check that responseHash equals sha256 of the body you received (for MCP, of the structuredContent, or of the content blocks if there's none).

The two transports

HTTPMCP
Price quotestatus 402, body { error: { code: "PAYMENT_REQUIRED" }, challenge }isError result, _meta["zanora.dev/x402-challenge"] (also in structuredContent)
Paymentheader x-payment: base64(JSON(proof))params._meta["zanora.dev/x402-payment"] = base64(JSON(proof))
Receiptheader x-zanora-receipt: base64(JSON(receipt))result._meta["zanora.dev/x402-receipt"] = base64(JSON(receipt))
Failure signalstatus ≥ 400, or a thrown handler. 4xx is reported as client_errorisError: 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.