Protocol reference
The X402v1 wire contract:one frozen string, signed identically everywhere.
X402v1 is the wire contract every official SDK speaks to the platform. It defines exactly what gets signed, the headers a request carries, the challenge/verify HTTP flow, and how a route fails closed. The format is frozen: the same inputs produce byte-identical signatures in every language.
The X402v1 wire contract
Every SDK-to-platform call signs a canonical string. It is exactly six fields joined by five \n (0x0A) line-feeds — never CRLF, with no leading or trailing newline:
X402v1
<METHOD>
<path>
<timestamp>
<nonce>
<bodyhash>X402v1\n<METHOD>\n<path>\n<timestamp>\n<nonce>\n<bodyhash>- X402v1 — the literal scheme/version string.
- <METHOD> — HTTP method, uppercase ASCII (e.g. POST).
- <path> — the exact platform API path: /api/v1/challenge or /api/v1/verify. No host, no scheme, no query string.
- <timestamp> — Unix time, decimal integer seconds (no decimals, no padding). Non-integer timestamps are rejected.
- <nonce> — a fresh, unique string per request (UUIDv4 recommended); never reused.
- <bodyhash> — SHA-256 of the exact UTF-8 bytes of the request body, lowercase hex.
For JSON endpoints the body hash is over the compact JSON serialization — no spaces after : or ,, keys in insertion order (equivalent to JSON.stringify(payload)). An empty body hashes the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
The signature is HMAC-SHA256(secret, canonical_string), output as lowercase hex, exactly 64 characters.
The contract is frozen: the same inputs produce byte-identical signatures in every SDK and language. Changing the layout would require a new scheme version (X402v2).
Signed request headers
Every platform call carries four signing headers:
| Header | Meaning |
|---|---|
| X-X402-Key | Public key identifier (e.g. x402_live_… / x402_test_…). |
| X-X402-Timestamp | Unix seconds, decimal integer. |
| X-X402-Nonce | A fresh, unique nonce per request. |
| X-X402-Signature | HMAC-SHA256 of the canonical string, lowercase hex (64 chars). |
Plus Content-Type: application/json (required; omitting it yields HTTP 422). Header names are case-insensitive; values are used verbatim.
The platform rejects a timestamp outside ±300s (expired) and a reused nonce (replay), and verifies signatures with constant-time comparison.
The flow
- 01An agent calls a monetised route
An automated client hits a route the operator has priced. Nothing is served yet — the SDK intercepts the call.
- 02The SDK requests a challenge
POST /api/v1/challenge with { "route": "/premium", "method": "GET" }. The platform replies with a 402-style payment-required response quoting the price: amount, currency (e.g. "USDC"), resource, a one-time nonce, and expiresAt.
- 03The agent presents proof and retries
The agent retries the original request and signals payment proof via the X-Payment-Nonce header (its presence is the signal); optional X-Payment-Payer and X-Payment headers carry the payer identity and proof.
- 04The SDK verifies
POST /api/v1/verify with { route, method, nonce, payer, payment_proof }. The platform returns { "allowed": true } (200) to grant access, or { "allowed": false, "reason": "<reason>" } (402) to deny — reasons: no_such_route, bad_nonce, unpaid, replay.
- 05The route handler runs
On a true result the SDK lets the original request through to the route handler. On a false result the gated content is never served.
Auth failures on either endpoint return { "error": "<reason>" } with reasons invalid_signature, unknown_key, revoked_key, expired, and replay.
Fail-closed semantics
The SDK is a thin client — no payment logic runs in the host process. It relays to the platform's signed challenge/verify endpoints. If the platform is unreachable, the SDK returns 502 Bad Gateway (or the language equivalent) and never serves the gated content.
A route cannot accidentally give away a paid response during an outage — the safe failure mode is to deny.
Conformance — one known-answer test
Every official SDK pins the same known-answer vector, so signing is provably identical across languages. Reproduce this exact signature for these exact inputs and your implementation is byte-compatible — it is the conformance oracle. (The signature is a hash, not a secret — safe to publish.)
| secret | x402sk_test_deadbeef |
| method | POST |
| path | /api/v1/verify |
| timestamp | 1700000000 |
| nonce | nonce-1 |
| body | {"a":1} |
015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862X402v1\nPOST\n/api/v1/verify\n1700000000\nnonce-1\n015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862c325bfaf7e66735f1e6a977b4b3b3fa6c9ae98d010123b1f724bed5ce5959ab5Configuration
An integrator sets a handful of env vars in their app:
| Variable | Purpose |
|---|---|
| X402_API_KEY | Your public key identifier (test or live). |
| X402_SECRET | The secret used to sign the canonical string. |
| X402_ENV | sandbox (synthetic payments for CI/local) or live. |
| X402_BASE_URL | The platform base URL the SDK calls. |
In sandbox mode payments are synthetic, so the whole challenge→verify loop can be asserted in CI before going live. Switching to live is an env-var change, not a code change.