# 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

```text
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.

| `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

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

4. Call again with the proof attached (see the table below).
5. 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

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