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.
Zanora's payment is not an on-chain x402 payment
The handshake follows x402↗: an unpaid call gets 402 Payment Required with a price, and the buyer retries with a signed payment. The payment itself is Zanora's own: an ed25519 proof signed with an agent wallet's key, authorized against that wallet and settled on Zanora's ledger. It is not an x402 PaymentPayload settled in USDC on-chain through a facilitator.
So a generic x402 client (Coinbase's x402 SDKs, an on-chain agent wallet) can read the price a Zanora seller asks, but can't pay it. To buy from a Zanora seller, use a Zanora wallet through @zanora/sdk, @zanora/mcp or @zanora/mcp-proxy.
Accepting standard x402 payments, and paying x402 sellers outside Zanora, are built but not enabled on api.zanora.dev yet. See Standard x402 payments (preview).
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_…", "amountMicros": "1000000", "currency": "USD",
"nonce": "…", "expiresAt": "…", "payTo": "wal_…", "signature": "…", "signingKeyId": "…" }The nonce can be used once, and the challenge expires. Return it to the buyer unchanged.
A capability with a package address gets 501 NOT_SUPPORTED here, and from authorize: only hosted capabilities can be paid for today.
/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 ifamountMicrosis 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_…",
"amountMicros": "1000000", "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, the name generic x402 MCP clients use. Only the key is shared: the value must still be a Zanora proof.
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.
Standard x402 payments (preview)
Not enabled on api.zanora.dev
Both halves below exist in the gateway and the packages, and are off until they clear review. A deployment turns them on, and then each seller or workspace opts in. Until then the routes answer X402_NOT_ACCEPTED or 501 NOT_SUPPORTED.
Getting paid by any x402 client
A seller that opts in (in the console, Seller → Settings → Payments from any x402 client; or POST /v1/providers/:id/x402 with { "enabled": true }, scope capabilities:write) is also offered to generic x402 clients: exact, USDC on Base. The offer comes from your listing's price and pays a Zanora collection address, never your own. You're paid the same way as for a Zanora wallet: the same price, receipt and settlement, and you withdraw as usual.
What changes when you opt in:
- The unpaid call carries both offers. Over HTTP the Zanora challenge stays in the body, and the x402 offer is in the
PAYMENT-REQUIREDheader. Over MCP,structuredContentis a valid x402PaymentRequiredobject (x402Version,accepts,resource,error), with the Zanora challenge beside it underchallenge. - The payment is settled only after your handler succeeds. The middleware holds your answer until the payment settles. If settlement fails, your answer is withheld and the buyer gets a fresh
402. If settlement is still unconfirmed (X402_SETTLEMENT_PENDING), your answer is withheld with no fresh offer, because the payment may yet land. If it does, the sale is recorded and you're paid for it. That call earns you nothing, and per-payer limits bound how often it can happen. A response of400or more is never settled, and costs the buyer nothing. - These buyers have fewer protections. An x402 payer has no Zanora wallet, so no workspace policy, budget or approval applies.
The middleware routes, all with your provider key (payments:write):
/v1/x402/quoteBody { capabilityId, resource? }. Returns { challenge, x402? }. x402 is present only when you've opted in and the listing isn't free.
/v1/x402/inbound/authorizeBody { capabilityId, payment }, where payment is the buyer's decoded PAYMENT-SIGNATURE (or MCP _meta["x402/payment"]). Verifies the payment with the facilitator, screens the payer, checks the USDC peg and the payer's limits. Nothing moves. Returns { ok: true, transactionId, payer }, or { ok: false, code, reason }. A refusal leaves the handler unrun.
/v1/x402/inbound/completeBody { transactionId, responseBodyBase64 }. Settles the payment, then records the sale. Returns { ok: true, receipt, settlement }: send the answer with PAYMENT-RESPONSE: base64(JSON(settlement)) and the receipt. On { ok: false }, withhold the answer. Offer a fresh payment only for X402_SETTLEMENT_FAILED. Any other code may mean the payment is still landing, and a new offer invites the buyer to pay twice. To abandon the call instead, use POST /v1/x402/fail as usual: nothing was taken.
The two payments are told apart by shape. An x402 payment is an object with x402Version. A Zanora proof names a wallet and has an ed25519 signature. A request carrying both is refused. x402 v1 (X-PAYMENT) isn't supported, and the refusal says so.
Paying x402 sellers outside Zanora
A Zanora wallet can pay any x402 seller, including ones not in the Zanora registry, once its workspace turns this on:
PUT /v1/workspaces/:id/outside-sellers
{ "enabled": true, "maxPerCallMicros": "1000000", "approvalAboveMicros": "250000",
"dailyMicros": "5000000", "allowedHosts": ["api.example.com"] }maxPerCallMicros is required. The other fields are optional limits: approval above an amount, a daily total, and allow-lists of hosts or payee addresses. Your policies and daily budget apply as well, except rules on provider.*: an outside seller has no provider record to verify or rate, so those rules are skipped for outside purchases rather than holding or refusing every one. These outside-seller settings are what judge the seller. The DSL sees external == true, host and payTo.
The agent asks the gateway to pay (POST /v1/x402/outbound/pay, scope payments:external), signing the request with its wallet key. Zanora signs the x402 payment from its own USDC, and debits the agent's wallet. The agent then sends the payment to the seller and reports what came back (POST /v1/x402/outbound/:id/report). The SDK does all of this when the agent is created with outsideSellers: true. In @zanora/mcp and @zanora/mcp-proxy, set ZANORA_OUTSIDE_SELLERS=on.
Outside purchases are not refundable
The seller's own facilitator settles the payment on-chain, and Zanora can't take it back. The receipt (kind: "x402_external_purchase") records what Zanora paid and the hash of what came back. It says nonRefundable: true and sellerAttested: false: no Zanora middleware ran at the seller. Money funded within the clearing period can't be spent this way yet. If the seller never settles, the wallet is credited back once the payment's time limit has run out and Zanora has confirmed on-chain that it was never used, usually about ten minutes later.