Skip to main content

Overview

All three Key0 transports share a single ChallengeEngine that owns the payment lifecycle. The engine exposes two phases — challenge issuance and settlement — while each transport handles HTTP framing differently.
+-------------------------------------------------------------+
|                      ChallengeEngine                        |
|                                                             |
|   requestAccess() / requestHttpAccess()    Phase 1          |
|        -> settlePayment() (transport layer)                 |
|             -> processHttpPayment()        Phase 2          |
+-------------------------------------------------------------+
             ^               ^               ^
             |               |               |
    +--------+---+   +------+-------+   +----+------------+
    | /x402/     |   | {basePath}/ |   | {basePath}/     |
    | access     |   | jsonrpc     |   | jsonrpc         |
    | (REST)     |   | (middleware)|   | (A2A executor)  |
    +------------+   +-------------+   +-----------------+
The transports differ only in how the HTTP request arrives and how the response is formatted. Under the hood, every payment passes through the same state machine, the same Redis/Postgres stores, and the same settlement logic.

Phase 1 — Challenge

Engine method: requestAccess() (A2A) or requestHttpAccess() (HTTP transports) When a client requests access to a resource, the engine creates a payment challenge:
1

Validate requestId

The requestId must be a valid UUID. Malformed values are rejected with INVALID_REQUEST (400).
2

Extract identifiers

resourceId defaults to "default" if not provided. clientAgentId defaults to "anonymous" (A2A) or "x402-http" (HTTP transports).
3

Look up plan

The planId is matched against SellerConfig.plans. If no matching plan exists, the engine throws TIER_NOT_FOUND (400).
4

Idempotency check

The engine calls store.findActiveByRequestId(requestId) and handles three cases:
Existing StateBehavior
PENDING (not expired)Return the existing challenge — no new record created
DELIVERED (with grant)Throw PROOF_ALREADY_REDEEMED (200) with the cached grant in details
EXPIRED or CANCELLEDFall through and create a new challenge
5

Generate challengeId

UUIDs for A2A transport, http-{uuid} prefix for HTTP transports.
6

Create PENDING record

A new ChallengeRecord is persisted via store.create() with state PENDING, the plan amount, chain configuration, and an expiration timestamp.
7

Return challenge

The engine returns an X402Challenge (A2A) or a challenge response object (HTTP) containing all the information the client needs to authorize payment.

Phase 2 — Settlement and Token Issuance

Engine method: processHttpPayment(requestId, planId, resourceId, txHash, fromAddress?) After the client signs an EIP-3009 authorization and the transport layer settles it on-chain, the engine processes the payment:
1

Look up plan

Verify planId exists in SellerConfig.plans. Throws TIER_NOT_FOUND (400) if missing.
2

Double-spend guard

Call seenTxStore.get(txHash). If the transaction hash has already been claimed, throw TX_ALREADY_REDEEMED (409).
3

Find or create PENDING record

Look up the PENDING record by requestId. If no record exists (the challenge phase was skipped or the original expired), auto-create one.
4

Atomic PENDING to PAID transition

Transition the record from PENDING to PAID atomically via a Lua script (Redis) or a conditional UPDATE (Postgres). The transition writes txHash, paidAt, and fromAddress to the record.
5

Mark transaction hash as used

Call seenTxStore.markUsed(txHash, challengeId) which executes SET NX. If this returns false (another challenge claimed the same hash in a race), the engine rolls back PAID to PENDING and throws TX_ALREADY_REDEEMED (409).
6

Issue token

Call config.fetchResourceCredentials() with { requestId, challengeId, resourceId, planId, txHash }. This call has a configurable timeout (tokenIssueTimeoutMs, default 15s) and retry policy (tokenIssueRetries, default 2 attempts with exponential backoff).
7

Build AccessGrant

Assemble the AccessGrant object containing the access token, expiration, resource endpoint, transaction hash, and explorer URL.
8

Persist grant (outbox pattern)

Write the full AccessGrant JSON to the record via a PAID to PAID update. This ensures the grant is durable before returning it to the client — if the next step fails, the grant is not lost.
9

Mark DELIVERED (best-effort)

Transition PAID to DELIVERED with deliveredAt. If this fails, the record stays in PAID with accessGrant set. The refund cron skips records that already have an accessGrant.
10

Fire callback and return

Fire onPaymentReceived asynchronously (non-blocking, errors are logged). Return the AccessGrant to the client.

Phase 3 — Access Protected Resource

After receiving an AccessGrant, the client uses the accessToken as a Bearer token to access protected endpoints:
POST /api/photos/photo-123
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
The validateAccessToken middleware verifies the JWT and attaches decoded claims to the request:
FrameworkClaims location
Expressreq.key0Token
Honoc.get("key0Token")
Fastifyrequest.key0Token

Three Transports

A plain REST endpoint mounted at POST /x402/access by the Express integration. The request shape determines which of three cases applies.

Case 1: Discovery (no planId)

The client sends an empty or planId-less body. No PENDING record is created — the server returns all available pricing plans.Request:
POST /x402/access
Content-Type: application/json

{}
Response:
HTTP/1.1 402 Payment Required
payment-required: eyJ4NDAyVm... (base64)
www-authenticate: Payment realm="https://api.example.com", accept="exact"
{
  "x402Version": 2,
  "resource": { "url": "...", "method": "POST" },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:84532",
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "amount": "100000",
      "payTo": "0xSellerWallet...",
      "maxTimeoutSeconds": 900,
      "extra": {
        "name": "USDC",
        "version": "2",
        "description": "Basic plan - $0.10 USDC"
      }
    }
  ],
  "error": "Payment required"
}

Case 2: Challenge (planId, no payment-signature)

The client specifies a planId but does not include a payment-signature header. The engine creates a PENDING record and returns the challenge.requestId is auto-generated as http-{uuid} if not provided.Request:
POST /x402/access
Content-Type: application/json

{
  "planId": "basic",
  "requestId": "550e8400-...",
  "resourceId": "photo-123"
}
Response:
HTTP/1.1 402 Payment Required
payment-required: eyJ4NDAyVm... (base64)
www-authenticate: Payment realm="...", accept="exact", challenge="http-a1b2c3d4-..."
{
  "x402Version": 2,
  "accepts": [ ... ],
  "extensions": {
    "key0": {
      "inputSchema": { ... },
      "outputSchema": { ... },
      "description": "..."
    }
  },
  "challengeId": "http-a1b2c3d4-...",
  "error": "Payment required"
}

Case 3: Settlement (planId + payment-signature header)

The client includes the payment-signature header containing a base64-encoded X402PaymentPayload with the signed EIP-3009 authorization.Request:
POST /x402/access
Content-Type: application/json
payment-signature: eyJ4NDAyVm... (base64-encoded X402PaymentPayload)

{
  "planId": "basic",
  "requestId": "550e8400-...",
  "resourceId": "photo-123"
}
Response:
HTTP/1.1 200 OK
payment-response: eyJzdWNjZXNz... (base64-encoded X402SettleResponse)
{
  "type": "AccessGrant",
  "challengeId": "http-a1b2c3d4-...",
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "tokenType": "Bearer",
  "expiresAt": "2025-03-05T13:15:00.000Z",
  "resourceEndpoint": "https://api.example.com/photos/photo-123",
  "resourceId": "photo-123",
  "planId": "basic",
  "txHash": "0xSettledTx...",
  "explorerUrl": "https://sepolia.basescan.org/tx/0xSettledTx..."
}

HTTP Headers Reference

HeaderDirectionFormatPurpose
payment-requiredServer to Client (402)Base64 JSONPaymentRequirements or discovery response
www-authenticateServer to Client (402)Payment realm=..., accept="exact"[, challenge=...]HTTP spec compliance
payment-signatureClient to ServerBase64 JSONX402PaymentPayload with EIP-3009 signature
payment-responseServer to Client (200)Base64 JSONX402SettleResponse with txHash
x-a2a-extensionsClient to ServerPresence checkRoutes to A2A handler, skips x402 middleware

Message Types

X402Challenge (Phase 1 response)

Returned by the engine after creating a PENDING record. Contains everything the client needs to authorize an on-chain payment.
{
  "type": "X402Challenge",
  "challengeId": "a1b2c3d4-...",
  "requestId": "550e8400-...",
  "planId": "basic",
  "amount": "$0.10",
  "asset": "USDC",
  "chainId": 84532,
  "destination": "0xSellerWallet...",
  "expiresAt": "2025-03-05T12:30:00.000Z",
  "description": "Send $0.10 USDC to 0xSeller... on chain 84532.",
  "resourceVerified": true
}

AccessGrant (Phase 2 response)

Returned after successful settlement and token issuance. The accessToken is used as a Bearer token to access protected endpoints.
{
  "type": "AccessGrant",
  "challengeId": "a1b2c3d4-...",
  "requestId": "550e8400-...",
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "tokenType": "Bearer",
  "expiresAt": "2025-03-05T13:15:00.000Z",
  "resourceEndpoint": "https://api.example.com/photos/photo-123",
  "resourceId": "photo-123",
  "planId": "basic",
  "txHash": "0xabcdef1234567890...",
  "explorerUrl": "https://sepolia.basescan.org/tx/0xabcdef..."
}

payment-signature Header (decoded)

The payment-signature header carries a base64-encoded X402PaymentPayload:
{
  "x402Version": 2,
  "network": "eip155:84532",
  "scheme": "exact",
  "payload": {
    "signature": "0xSignedEIP3009...",
    "authorization": {
      "from": "0xBuyer...",
      "to": "0xSeller...",
      "value": "100000",
      "validAfter": "0",
      "validBefore": "1741180560",
      "nonce": "0xRandomNonce..."
    }
  },
  "accepted": {
    "scheme": "exact",
    "network": "eip155:84532",
    "asset": "0x036CbD...",
    "amount": "100000",
    "payTo": "0xSeller...",
    "maxTimeoutSeconds": 900,
    "extra": { "name": "USDC", "version": "2" }
  }
}