ChallengeRecord that moves through a deterministic state machine. All transitions are atomic — enforced by a Lua script running inside Redis — so concurrent requests can never corrupt a record.
State Diagram
There are two branches fromPAID:
- Happy path:
PAIDtransitions toDELIVEREDwithin milliseconds — the SDK issues the access token and marks delivery in the same request. - Refund path: If token issuance fails (e.g.,
fetchResourceCredentialsthrows or the server crashes), the record staysPAID. A refund cron picks it up after a grace period and sends USDC back to the buyer.
DELIVERED and REFUNDED are both terminal success states. EXPIRED, CANCELLED, and REFUND_FAILED are also terminal. Once a record reaches any terminal state, no further transitions are possible.States
Allowed Transitions
The
PAID to PAID self-transition is the outbox pattern: the SDK persists the accessGrant to the record before returning it to the client, so a failure in the subsequent DELIVERED transition does not lose the grant. The refund cron skips records that already have accessGrant set.Per-Request Challenges
Per-request plans createChallengeRecords and use the same state machine, but with different delivery triggers:
Standalone PPR (seller uses proxyTo/fetchResource):
PENDINGis created whenPOST /x402/accessreceives aresourcefield but nopayment-signature.PENDING → PAIDtransitions immediately after on-chain settlement.PAID → DELIVEREDis triggered when the proxied backend request returns a 2xx response.- If the backend returns non-2xx, the challenge stays in
PAID— noDELIVEREDtransition occurs, and the refund cron picks it up.
key0.payPerRequest() middleware):
PENDINGis created when the middleware returns a 402 (nopayment-signatureon the route).PENDING → PAIDtransitions immediately when thepayment-signatureis present and settlement succeeds.PAID → DELIVEREDis triggered viares.on("finish")(Express/Fastify) or afterawait next()(Hono) when the response status is 2xx.- When no
storeis passed to the middleware: noChallengeRecordis created. State transitions do not occur. The payment is settled on-chain but refunds are not possible.
PAID table note about the outbox pattern (accessGrant) does not apply to PPR records — per-request challenges do not store an accessGrant since no token is issued. The refund cron identifies them by the absence of accessGrant and presence of a resource field.
Atomic Transitions — Lua Script
All state transitions use a single Lua script executed atomically by Redis. If the current state does not match the expectedfromState, the transition is rejected and no fields are written.
ZADD / ZREM logic:
- When transitioning to
PAID, the script adds the challenge to thekey0:paidsorted set with thepaidAttimestamp as the score. This makes the record visible to the refund cron. - When transitioning from
PAID(toDELIVERED,REFUND_PENDING, etc.), the script removes the challenge from the sorted set. This prevents the refund cron from picking up records that have already moved on.
The
EXPIRE call that shortens TTL to 12 hours on DELIVERED is done outside the Lua script. TTL adjustment is not a correctness invariant — it is a storage optimization.Redis Schema
All keys use the prefixkey0 (configurable via keyPrefix).
Challenge Record Hash
Key:key0:challenge:{challengeId}
Stored as a Redis Hash (HSET/HGETALL). Each field is a string.
Request Index
Key:key0:request:{requestId}
A simple SET key mapping requestId to challengeId. Used for idempotency: if the same requestId is submitted again, the existing challenge is returned instead of creating a new one.
Seen Transaction Set
Key:key0:seentx:{txHash}
A SET NX key for double-spend prevention. Maps txHash to challengeId. The NX flag ensures only the first write succeeds — if a second request tries to claim the same transaction hash, the SET returns false and the engine rejects it.
Paid Set (Sorted Set)
Key:key0:paid
A Redis Sorted Set tracking PAID records for the refund cron. The score is the paidAt epoch timestamp in milliseconds, enabling efficient range queries for records older than the grace period.

