Overview
Both Key0 entry points share a singleChallengeEngine that owns the payment lifecycle. The engine exposes two phases — challenge issuance and settlement — while each endpoint 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 |
Two Endpoints
- x402 HTTP (/x402/access)
- A2A Executor (Express only)
The Express integration mounts both Response:The Response:Response:Response:Response: Response: If the backend returns a non-2xx status, the
GET /discover and POST /x402/access. The request determines which of four cases applies.Case 1a: Discovery (GET /discover)
CallGET /discover to browse all available plans. No PENDING record is created — this is a pure pricing query.Request: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: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 — Subscription (planId + payment-signature → AccessGrant)
The client includes thepayment-signature header containing a base64-encoded X402PaymentPayload with the signed EIP-3009 authorization.Request:Case 4: Route-Based Standalone (routeId + resource + payment-signature → ResourceResponse)
For top-level paidroutes 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:402 (same payment requirements as subscription)Settlement request:200 ResourceResponse (the backend’s response, not a JWT)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.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.AccessGrant (Phase 2 response — subscription)
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:

