Skip to main content
When a buyer pays for a resource, the SDK records the payment in a 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 uses proxyTo/fetchResource):
  • If the backend returns a non-2xx status, the challenge stays in PAID for refund eligibility. The ResourceResponse containing the backend’s error body is still returned to the client so it knows what went wrong.
  • If fetchResource throws (e.g. timeout, network error), the challenge stays in PAID and the refund cron will process it.
  • DELIVERED is only set when the backend returns a 2xx response.
Embedded PPR (seller uses key0.payPerRequest() middleware):
  • When a store is provided: markDelivered is called via res.on("finish") (Express/Fastify) or after await next() (Hono) when the response status is 2xx. This is best-effort — if the process crashes between settlement and DELIVERED, 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 store is provided: no ChallengeRecord is 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.
For embedded PPR in production, always pass a store to key0Router / key0App / key0Plugin. Without it, settlement failures after on-chain payment are not recoverable via the refund cron.

Deployment Modes

When KEY0_WALLET_PRIVATE_KEY is set, the Docker container runs a BullMQ refund cron automatically — no extra setup needed.
Configuration variables:

processRefunds() API Reference

Config

Return Value

processRefunds returns RefundResult[]. Each element is either a success or failure:

Double-Refund Prevention

The PAID -> REFUND_PENDING transition is atomic. In Redis, a Lua script implements compare-and-swap:
If two cron workers fire at exactly the same time, both read 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>
Query:
This returns all 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 state
  • fromAddress is present — know where to send USDC
  • accessGrant is not set — records where token issuance succeeded but the DELIVERED transition failed should not be refunded (the buyer already has their credential)
REFUND_FAILED is a terminal state. The cron will not pick it up again. See the section below for operator guidance.

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
  • sendUsdc threw an unexpected error
The refundError string is written to the ChallengeRecord, and the RefundResult returned by processRefunds has success: false with the error field set. Recommended handling:
  1. Log and alert immediately — filter results for !r.success.
  2. Inspect the record via store.get(challengeId) — the refundError field contains the raw error message.
  3. Fix the underlying cause (top up ETH, restore RPC), then retry manually by transitioning REFUND_FAILED -> PAID and letting the cron pick it up on the next tick.

Timing Diagrams

A2A Flow

HTTP x402 Flow

Refund (Both Paths)

Store TTLs

Records are automatically cleaned up based on their final state via Redis key expiry.