ChallengeRecord. After on-chain verification succeeds, delivery and the PAID -> DELIVERED transition happen in the same request.
For subscription plans, delivery means fetchResourceCredentials succeeds and the AccessGrant is returned. For route-based pay-per-call flows, delivery means the route handler responds successfully (embedded) or the proxied backend request returns 2xx (standalone).
If delivery fails — for any reason — the record stays in PAID state. The refund cron picks it up after a configurable grace period and sends USDC back to the buyer’s wallet.
PAID is transient in the happy path — it lasts milliseconds between payment verification and delivery. The refund cron is a safety net for failures only.Refund State Machine
In the full lifecycle,PAID is reached via PENDING -> PAID after on-chain payment verification. In the happy path, PAID -> DELIVERED happens immediately. The refund path only activates when delivery fails.
Route-Based Refund Path
Route-based pay-per-call flows share the same refund state machine but have different failure triggers: Standalone PPR (seller usesproxyTo/fetchResource):
- If the backend returns a non-2xx status, the challenge stays in
PAIDfor refund eligibility. TheResourceResponsecontaining the backend’s error body is still returned to the client so it knows what went wrong. - If
fetchResourcethrows (e.g. timeout, network error), the challenge stays inPAIDand the refund cron will process it. DELIVEREDis only set when the backend returns a 2xx response.
key0.payPerRequest() middleware):
- When a
storeis provided:markDeliveredis called viares.on("finish")(Express/Fastify) or afterawait next()(Hono) when the response status is 2xx. This is best-effort — if the process crashes between settlement andDELIVERED, the refund cron may refund the payment even though the response was already served. This is a safe fallback: a small risk of over-refunding is preferable to the buyer losing funds. - When no
storeis provided: noChallengeRecordis created. The payment is recorded on-chain but Key0 has no state to track delivery or trigger a refund. If settlement succeeds and your route handler crashes, the buyer cannot be automatically refunded.
Deployment Modes
- Standalone (Docker)
- Embedded (SDK)
When Configuration variables:
KEY0_WALLET_PRIVATE_KEY is set, the Docker container runs a BullMQ refund cron automatically — no extra setup needed.processRefunds() API Reference
Config
Return Value
processRefunds returns RefundResult[]. Each element is either a success or failure:
Double-Refund Prevention
ThePAID -> REFUND_PENDING transition is atomic. In Redis, a Lua script implements compare-and-swap:
PAID. The first Lua call succeeds and returns 1. The second sees REFUND_PENDING (already claimed) and returns 0, so it skips that record. Only one USDC transfer is ever broadcast.
findPendingForRefund
The cron uses a Redis sorted set key0:paid to efficiently find eligible records:
- On
PENDING -> PAID:ZADD key0:paid <paidAt_ms> <challengeId> - On
PAID -> anything:ZREM key0:paid <challengeId>
challengeId values whose paidAt is older than the grace period, in O(log N + M) time.
Each result is fetched from the hash and verified before being returned:
state === "PAID"— still in refundable statefromAddressis present — know where to send USDCaccessGrantis not set — records where token issuance succeeded but theDELIVEREDtransition failed should not be refunded (the buyer already has their credential)
REFUND_FAILED Handling
REFUND_FAILED is terminal — findPendingForRefund only returns PAID records, so failed refunds are never retried automatically.
Common causes:
- Seller wallet has insufficient ETH for gas
- RPC endpoint is down or rate-limited
sendUsdcthrew an unexpected error
refundError string is written to the ChallengeRecord, and the RefundResult returned by processRefunds has success: false with the error field set.
Recommended handling:
- Log and alert immediately — filter results for
!r.success. - Inspect the record via
store.get(challengeId)— therefundErrorfield contains the raw error message. - Fix the underlying cause (top up ETH, restore RPC), then retry manually by transitioning
REFUND_FAILED -> PAIDand letting the cron pick it up on the next tick.

