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:

Canonical string (layout)
X402v1
<METHOD>
<path>
<timestamp>
<nonce>
<bodyhash>
As one escaped string
X402v1\n<METHOD>\n<path>\n<timestamp>\n<nonce>\n<bodyhash>
Fields
  • 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:

HeaderMeaning
X-X402-KeyPublic key identifier (e.g. x402_live_… / x402_test_…).
X-X402-TimestampUnix seconds, decimal integer.
X-X402-NonceA fresh, unique nonce per request.
X-X402-SignatureHMAC-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

  1. 01
    An agent calls a monetised route

    An automated client hits a route the operator has priced. Nothing is served yet — the SDK intercepts the call.

  2. 02
    The 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.

  3. 03
    The 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.

  4. 04
    The 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.

  5. 05
    The 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.)

Inputs
secretx402sk_test_deadbeef
methodPOST
path/api/v1/verify
timestamp1700000000
noncenonce-1
body{"a":1}
body SHA-256
015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862
canonical string (escaped)
X402v1\nPOST\n/api/v1/verify\n1700000000\nnonce-1\n015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862
signature
c325bfaf7e66735f1e6a977b4b3b3fa6c9ae98d010123b1f724bed5ce5959ab5

Configuration

An integrator sets a handful of env vars in their app:

VariablePurpose
X402_API_KEYYour public key identifier (test or live).
X402_SECRETThe secret used to sign the canonical string.
X402_ENVsandbox (synthetic payments for CI/local) or live.
X402_BASE_URLThe 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.