Skip to main content

Overview

Both Key0 entry points share a single ChallengeEngine that owns the payment lifecycle. The engine exposes two phases — challenge issuance and settlement — while each endpoint handles HTTP framing differently.
+-------------------------------------------------------------+
|                      ChallengeEngine                        |
|                                                             |
|   requestAccess() / requestHttpAccess()    Phase 1          |
|        -> settlePayment() (transport layer)                 |
|             -> processHttpPayment()        Phase 2          |
+-------------------------------------------------------------+
                    ^               ^
                    |               |
         +----------+---------+   +-+--------------------+
         |  POST /x402/access |   |  POST /x402/access   |
         |  (x402 HTTP flow)  |   |  + X-A2A-Extensions  |
         |                    |   |  -> A2A Executor      |
         +--------------------+   |  (Express only)       |
                                  +----------------------+
The two modes differ only in how the 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

Two Endpoints

The Express integration mounts both GET /discover and POST /x402/access. The request determines which of four cases applies.

Case 1a: Discovery (GET /discover)

Call GET /discover to browse all available plans. No PENDING record is created — this is a pure pricing query.Request:
GET /discover
Response:
HTTP/1.1 200 OK
Content-Type: application/json
{
  "agentName": "My Agent",
  "description": "Payment-gated API",
  "plans": [
    {
      "planId": "basic",
      "unitAmount": "$0.10",
      "description": "Basic plan - $0.10 USDC"
    }
  ],
  "routes": []
}
The accepts array contains one entry per plan configured in SellerConfig.plans. Use the planId from extra.planId in your subsequent POST /x402/access request.

Case 1b: POST /x402/access without planId (HTTP 400)

POST /x402/access with an empty body or no planId now returns an HTTP 400 error pointing to GET /discover. This is not the discovery endpoint — it is an error response.Request:
POST /x402/access
Content-Type: application/json

{}
Response:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
  "error": "Please select a plan from the discovery API response to purchase access. Endpoint: GET /discover"
}

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 — Subscription (planId + payment-signature → AccessGrant)

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",
  "resourceEndpoint": "https://api.example.com/photos/photo-123",
  "resourceId": "photo-123",
  "planId": "basic",
  "txHash": "0xSettledTx...",
  "explorerUrl": "https://sepolia.basescan.org/tx/0xSettledTx..."
}

Case 4: Route-Based Standalone (routeId + resource + payment-signature → ResourceResponse)

For top-level paid routes with proxyTo or fetchResource set on SellerConfig, the client includes a resource field and the engine proxies to the backend instead of issuing a JWT.Challenge request:
POST /x402/access
Content-Type: application/json

{
  "routeId": "weather-query",
  "resource": { "method": "GET", "path": "/api/weather/london" }
}
Response: 402 (same payment requirements as subscription)Settlement request:
POST /x402/access
Content-Type: application/json
payment-signature: eyJ4NDAyVm...

{
  "routeId": "weather-query",
  "resource": { "method": "GET", "path": "/api/weather/london" }
}
Response: 200 ResourceResponse (the backend’s response, not a JWT)
{
  "type": "ResourceResponse",
  "challengeId": "http-a1b2c3d4-...",
  "requestId": "550e8400-...",
  "routeId": "weather-query",
  "txHash": "0xSettledTx...",
  "explorerUrl": "https://sepolia.basescan.org/tx/0xSettledTx...",
  "resource": {
    "status": 200,
    "body": { "city": "london", "tempF": 65, "condition": "Cloudy" }
  }
}
If the backend returns a non-2xx status, the ResourceResponse still contains the backend’s body and status code, and the challenge stays in PAID state so the refund cron can process it.

For the complete HTTP headers reference, see API Reference → Overview.

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
}

ResourceResponse (Phase 2 response — route-based standalone)

Returned after successful settlement for standalone route-based purchases. Contains the backend API response directly — no token issued.
{
  "type": "ResourceResponse",
  "challengeId": "http-a1b2c3d4-...",
  "requestId": "550e8400-...",
  "planId": "weather-query",
  "txHash": "0xSettledTx...",
  "explorerUrl": "https://sepolia.basescan.org/tx/0xSettledTx...",
  "resource": {
    "status": 200,
    "body": { "city": "london", "tempF": 65 }
  }
}

AccessGrant (Phase 2 response — subscription)

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",
  "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" }
  }
}