Skip to main content
Every payment in Key0 is tracked by a single 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 from PAID:
  • Happy path: PAID transitions to DELIVERED within milliseconds — the SDK issues the access token and marks delivery in the same request.
  • Refund path: If token issuance fails (e.g., fetchResourceCredentials throws or the server crashes), the record stays PAID. 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

StateMeaning
PENDINGAwaiting payment
PAIDPayment verified on-chain, awaiting token issuance and delivery
DELIVEREDFinal success state — resource served
EXPIREDChallenge timed out
CANCELLEDManually cancelled
REFUND_PENDINGCron claimed record, refund tx being broadcast
REFUNDEDRefund sent on-chain — final state
REFUND_FAILEDRefund tx threw — needs operator attention

Allowed Transitions

FromToTriggerFields Written
(new)PENDINGcreate()All base fields
PENDINGPAIDprocessHttpPayment()txHash, paidAt, fromAddress
PENDINGEXPIREDExpiry check on access
PENDINGCANCELLEDcancelChallenge()
PAIDPAIDGrant persisted (outbox)accessGrant (full JSON)
PAIDDELIVEREDToken issued successfullyaccessGrant (full JSON), deliveredAt
PAIDPENDINGmarkUsed() race rollback (extremely rare)
PAIDREFUND_PENDINGRefund cron claims record
REFUND_PENDINGREFUNDEDRefund tx confirmedrefundTxHash, refundedAt
REFUND_PENDINGREFUND_FAILEDRefund tx failedrefundError
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.

Atomic Transitions — Lua Script

All state transitions use a single Lua script executed atomically by Redis. If the current state does not match the expected fromState, the transition is rejected and no fields are written.
local current = redis.call('HGET', KEYS[1], 'state')
if current ~= ARGV[1] then
  return 0  -- state mismatch, transition rejected
end
local fromState = ARGV[1]
local toState = ARGV[2]
local challengeId = ARGV[3]
local score = ARGV[4]
redis.call('HSET', KEYS[1], 'state', toState)
for i = 5, #ARGV, 2 do
  redis.call('HSET', KEYS[1], ARGV[i], ARGV[i+1])
end
if toState == 'PAID' and score ~= '' then
  redis.call('ZADD', KEYS[2], score, challengeId)
elseif fromState == 'PAID' then
  redis.call('ZREM', KEYS[2], challengeId)
end
return 1
Script parameters:
ParameterValue
KEYS[1]key0:challenge:{challengeId} — the challenge hash
KEYS[2]key0:paid — the sorted set tracking PAID records
ARGV[1]fromState — expected current state
ARGV[2]toState — target state
ARGV[3]challengeId — used as the sorted set member
ARGV[4]paidAt epoch ms (or empty string) — used as the sorted set score
ARGV[5..N]Field/value pairs to write to the hash
ZADD / ZREM logic:
  • When transitioning to PAID, the script adds the challenge to the key0:paid sorted set with the paidAt timestamp as the score. This makes the record visible to the refund cron.
  • When transitioning from PAID (to DELIVERED, 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 prefix key0 (configurable via keyPrefix).

Challenge Record Hash

Key: key0:challenge:{challengeId} Stored as a Redis Hash (HSET/HGETALL). Each field is a string.
Hash FieldTypeSet WhenExample
challengeIdstringCREATE"http-a1b2c3d4-..." or UUID
requestIdstringCREATE"550e8400-e29b-..." (client-generated UUID)
clientAgentIdstringCREATE"did:web:agent.example" or "x402-http"
resourceIdstringCREATE"photo-123" or "default"
planIdstringCREATE"basic"
amountstringCREATE"$0.10"
amountRawstring (bigint)CREATE"100000" (USDC 6-decimal micro-units)
assetstringCREATE"USDC"
chainIdstring (number)CREATE"84532" (Base Sepolia) or "8453" (Base)
destinationstring (0x)CREATE"0xAbCd..." (seller wallet)
statestringCREATE, updated on transitions"PENDING" / "PAID" / "DELIVERED" / etc.
expiresAtISO-8601 stringCREATE"2025-03-05T12:30:00.000Z"
createdAtISO-8601 stringCREATE"2025-03-05T12:15:00.000Z"
txHashstring (0x)PENDING to PAID"0x1234..."
paidAtISO-8601 stringPENDING to PAID"2025-03-05T12:16:00.000Z"
fromAddressstring (0x)PENDING to PAID"0xBuyer..." (payer wallet)
accessGrantJSON stringPAID to DELIVEREDFull AccessGrant object
deliveredAtISO-8601 stringPAID to DELIVERED"2025-03-05T12:16:05.000Z"
refundTxHashstring (0x)REFUND_PENDING to REFUNDED"0xRefund..."
refundedAtISO-8601 stringREFUND_PENDING to REFUNDED"2025-03-05T12:21:00.000Z"
refundErrorstringREFUND_PENDING to REFUND_FAILED"insufficient gas"

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.
KEY:   key0:request:550e8400-e29b-...
VALUE: http-a1b2c3d4-...
TTL:   900s (challengeTTLSeconds)

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.
KEY:   key0:seentx:0x1234abcd...
VALUE: http-a1b2c3d4-...
TTL:   7 days (604,800s)
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.
OperationWhen
ZADDState transitions to PAID (score = paidAt epoch ms)
ZREMState transitions from PAID (to DELIVERED, REFUND_PENDING, etc.)
ZRANGEBYSCORE 0 <cutoff>Refund cron queries records older than minAgeMs

TTL Management

KeyDefault TTLNotes
key0:challenge:{id}7 days (604,800s)Set at creation
key0:challenge:{id} (after DELIVERED)12 hours (43,200s)Shortened on delivery — storage optimization
key0:request:{requestId}900s (challengeTTLSeconds)Matches challenge expiry window
key0:seentx:{txHash}7 days (604,800s)Must outlive the challenge record for double-spend safety
key0:paid (sorted set)No expiryMembers are added/removed by the Lua script on transitions

Redis Commands Per Operation

OperationRedis Commands
createPipeline: EXISTS (guard) + HSET (challenge hash) + EXPIRE (7d) + SET EX (request index, 900s)
getHGETALL
findActiveByRequestIdGET (request index) then HGETALL (challenge hash)
transitionEVAL (Lua: check state + HSET + conditional ZADD/ZREM) + conditional EXPIRE (if DELIVERED)
markUsedSET NX EX (7d)
findPendingForRefundZRANGEBYSCORE then N x (HGETALL + conditional ZREM for ghost entries)
healthCheckPING