Overview
All three Key0 transports share a singleChallengeEngine that owns the payment lifecycle. The engine exposes two phases — challenge issuance and settlement — while each transport handles HTTP framing differently.
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:
Validate requestId
The
requestId must be a valid UUID. Malformed values are rejected with INVALID_REQUEST (400).Extract identifiers
resourceId defaults to "default" if not provided. clientAgentId defaults to "anonymous" (A2A) or "x402-http" (HTTP transports).Look up plan
The
planId is matched against SellerConfig.plans. If no matching plan exists, the engine throws TIER_NOT_FOUND (400).Idempotency check
The engine calls
store.findActiveByRequestId(requestId) and handles three cases:| Existing State | Behavior |
|---|---|
| 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 CANCELLED | Fall through and create a new challenge |
Create PENDING record
A new
ChallengeRecord is persisted via store.create() with state PENDING, the plan amount, chain configuration, and an expiration timestamp.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:
Double-spend guard
Call
seenTxStore.get(txHash). If the transaction hash has already been claimed, throw TX_ALREADY_REDEEMED (409).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.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.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).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).Build AccessGrant
Assemble the
AccessGrant object containing the access token, expiration, resource endpoint, transaction hash, and explorer URL.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.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.Phase 3 — Access Protected Resource
After receiving anAccessGrant, the client uses the accessToken as a Bearer token to access protected endpoints:
validateAccessToken middleware verifies the JWT and attaches decoded claims to the request:
| Framework | Claims location |
|---|---|
| Express | req.key0Token |
| Hono | c.get("key0Token") |
| Fastify | request.key0Token |
Three Transports
- REST (/x402/access)
- JSON-RPC Middleware
- A2A Executor
A plain REST endpoint mounted at Response:Response:Response:
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:Case 2: Challenge (planId, no payment-signature)
The client specifies aplanId 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:Case 3: Settlement (planId + payment-signature header)
The client includes thepayment-signature header containing a base64-encoded X402PaymentPayload with the signed EIP-3009 authorization.Request:HTTP Headers Reference
| Header | Direction | Format | Purpose |
|---|---|---|---|
payment-required | Server to Client (402) | Base64 JSON | PaymentRequirements or discovery response |
www-authenticate | Server to Client (402) | Payment realm=..., accept="exact"[, challenge=...] | HTTP spec compliance |
payment-signature | Client to Server | Base64 JSON | X402PaymentPayload with EIP-3009 signature |
payment-response | Server to Client (200) | Base64 JSON | X402SettleResponse with txHash |
x-a2a-extensions | Client to Server | Presence check | Routes 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.AccessGrant (Phase 2 response)
Returned after successful settlement and token issuance. TheaccessToken is used as a Bearer token to access protected endpoints.
payment-signature Header (decoded)
Thepayment-signature header carries a base64-encoded X402PaymentPayload:

