I run a small paid HTTP API on a Cloudflare Worker — x402-catalog-history, priced in USDC on Base with the x402 exact scheme. This post is the delivery-evidence pattern behind it: how to make a paid-route 200 its own recorded event, so a real delivery can be told apart from a 402 price check, from a settled payment whose buyer never sent the second request, and from a client that could not parse the challenge at all. Everything you need to copy it is here — the function, the storage, and the exact commands to read the records back — and every number below comes from my own deployment logs and test runs.
The service, in one line. GET /history, GET /changes and POST /compat are 0.01 USDC per delivery on Base (eip155:8453); GET /changes/bundle is 0.50 USDC for up to 100 units. GET / is the free machine-readable catalogue — every product, its price, and a machine-readable curl purchase example — and the same link is at the end of this post. A purchase is two calls by design: the first returns 402 with a PAYMENT-REQUIRED header, your x402 client signs it, and you resend the same request with the payment header to receive the 200 payload. Section 1 has the full spec, section 4 is the delivery-logging function, and section 7 is the checklist.
1. What the service is
- Cloudflare Worker
x402-catalog-history(main = "worker.mjs"), Workers Logs enabled withhead_sampling_rate = 1andredact_query_string = true. - Free routes:
/(also/index.json,/product.json) returns the product description plus the newest change events as a free sample;/healthreports status and timestamps. - Paid routes:
GET /history,GET /changes,POST /compat,GET /changes/bundle. The first three cost 10000 atomic units = 0.01 USDC per response;/changes/bundlecosts 500000 atomic = 0.50 USDC for up to 100 units, and a larger request is rejected with 400 before payment rather than silently truncated. - Settlement is USDC (6 decimals) on
eip155:8453(Base), schemeexact,maxTimeoutSeconds: 60; prices live in one env-overridableCONFIGobject. A request with no payment header gets HTTP 402 with aPAYMENT-REQUIREDheader (v2) plus a JSON body — section 5 explains why that body is v1-shaped.
2. The problem: 402 is contact, 200 is delivery
Two events, two code paths, two questions.
- The entry log (
logRequest) fires on every request — 402, 400, 404, 405, and free routes. It answers "a buyer came and saw the price." - Delivery is a paid route returning 200 with the payload. It answers "a buyer paid and received the content."
Counting 402s as sales counts price checks and unpaid retries as revenue. Payment does not imply delivery either: the facilitator's /verify and /settle endpoints accept unauthenticated calls (a malformed signature returns HTTP 400 invalid_payload, not 401/403), so a buyer can settle without making the second request to the Worker that carries the payment signature. Whether a caller tried that request and failed, or never sent it, is not observable from my side — so I made the 200 delivery itself a recorded event.
3. Three evidence paths for delivery
Why three rather than one. The obvious single record was unavailable: the Workers Logs stored-log query API returned 403 with the OAuth token available to wrangler (/workers/observability/telemetry/query was 403 too), and creating a token with the right scope failed — /user/tokens/permission_groups returned 403 and dashboard token creation also failed. But wrangler kv namespace list succeeded with the same OAuth login, no new token needed. So KV became the durable record, and Workers Logs stayed usable only as a live stream (wrangler tail --format json; past logs remain unqueryable).
| # | Path | Written when | What it proves | Retention |
|---|---|---|---|---|
| 1 | KV DELIVERY_LOG
|
paid route returned 200, one key per request; the write can fail | delivery happened — durable record of record |
30 days (expirationTtl: 2592000) |
| 2 | Logs {"evt":"paid_delivery",…}
|
paid route returned 200, emitted before the KV put
|
delivery happened — independent of KV, survives a KV outage or missing binding | 3 days (Free) |
| 3 | Logs {"evt":"delivery_log_write_failed",…}
|
KV write threw (console.error, level error) |
tells "the write failed" apart from "no 200 ever happened" | 3 days (Free) |
Reading the combination: path 2 present with no path 1 key means the delivery happened and the KV write failed (path 3 should appear). Neither path 2 nor path 3 means no 200 happened — contact only (402), or the request never reached a paid route.
The KV key is delivery:<ISO timestamp>:<6 hex chars> (e.g. delivery:2026-10-13T12:56:07.614Z:df503c) and the value is {at, method, path, status, user_agent, cf_ray, country}, pathname only. Query strings are never stored, and neither are payment headers, signatures, or keys. In production the stored expiration matched creation time + 2592000 seconds (a measured difference of 2592001 s, from millisecond truncation).
Two traps, both measured. Run wrangler kv from the deploy folder; elsewhere you get No KV namespaces are configured in your wrangler config file. And always pass --remote: without it a local empty KV returns [] (2231 ms) and reads as "zero deliveries." With --remote, three consecutive list calls took 1965 / 2718 / 1775 ms. The production records also behaved as designed: a /health 200 probe and two /compat probes with user_agent: delivery-log-probe/1.0 were excluded (no matching payment), self-purchases of /history produced real keys, and the 402s in the same window produced zero keys.
4. The code that makes this trustworthy
recordDelivery returns early unless the response is exactly 200 on a paid path, emits the KV-independent line first, then attempts the write:
export async function recordDelivery(env, request, response, now = Date.now()) {
let path = null;
try {
if (!response || response.status !== 200) return null;
path = new URL(request.url).pathname.replace(/\/+$/, "") || "/";
if (!isPaidPath(resolveConfig(env), path)) return null;
// Emitted before put, so a KV failure cannot erase the evidence.
console.log(JSON.stringify({ evt: "paid_delivery", ...buildDeliveryRecord(request, response, now) }));
const store = env && env[DELIVERY_LOG_BINDING];
if (!store || typeof store.put !== "function") return null;
const key = buildDeliveryKey(now);
await store.put(key, JSON.stringify(buildDeliveryRecord(request, response, now)),
{ expirationTtl: DELIVERY_LOG_TTL_SECONDS }); // 2592000 = 30 days
return key;
} catch (e) { // one line, no rethrow: logging must never break delivery
try { console.error(JSON.stringify({ evt: "delivery_log_write_failed", path,
status: response && typeof response.status === "number" ? response.status : null, error: e && e.message ? e.message : String(e) })); } catch {}
return null;
}
}
Three load-bearing choices: emit before write (otherwise a KV outage looks identical to "nobody bought anything"); never swallow a failure silently (the catch does not rethrow — delivery must not depend on logging — but it writes one error line, because a swallowed failure is indistinguishable from no traffic); and never change the response (the function returns null on failure; the buyer still gets the 200 and the payload). In fetch the order is explicit: build the response, logRequest, await recordDelivery, return the response.
selftest.mjs runs against a mocked KV with no network calls (network calls: 0), intercepting console to assert that paid_delivery fires exactly once for a paid 200, that the error line fires on a forced write failure, and that neither fires for a 402 or a free route; it grew from 40/40 to 43/43. check-live.mjs stayed 10/10 across the delivery-log deploy (later 14/14), confirming 402/400/405 behavior was unchanged. One non-resident wrangler tail --format json run showed the real lines:
{"timestamp":"2026-10-13T12:56:07.614Z","method":"GET","path":"/history","status":200,"user-agent":"node","cf-ray":"a49e80a01a5bf9f9","country":"JP"}
{"evt":"paid_delivery","at":"2026-10-13T12:56:07.614Z","method":"GET","path":"/history","status":200,"user_agent":"node","cf_ray":"a49e80a01a5bf9f9","country":"JP"}
The matching KV key was delivery:2026-10-13T12:56:07.614Z:df503c (production KV went from 4 to 5 keys), while the free /health 200 and the unpaid /history 402 in the same session produced no paid_delivery line. The failure branch delivery_log_write_failed is exercised in selftest.mjs against the mock; in production every KV write has succeeded, so the branch has stayed dormant and Workers Logs has shown the paid_delivery line on each paid 200.
5. Measured x402 client compatibility: v2 header, v1 body
A 200 that only v2 clients can reach is a delivery problem for everyone else. Against a v2 402 (x402Version: 2, accepts[].network: "eip155:8453"), the older client x402-fetch@1.2.0 threw a ZodError — invalid_enum_value, path: ["network"], received: "eip155:8453", Expected 'abstract' | 'base' | … — before signing, in 444–601 ms, so payment never happened. Meanwhile @x402/axios@2.28.0 (a different transport from the same official monorepo) completed 402 → automatic signing → re-fetch 200 in 1781 ms, and the delivery appeared in KV (delivery:2026-10-13T13:24:02.788Z:7b06b2).
So the v2 402 was correct and still unusable for a v1 client. What the ecosystem actually assumes:
- The facilitator's
/supportedlisted 36 kinds = 16 v1 / 20 v2, including v1{x402Version: 1, scheme: "exact", network: "base"}. Posting a v1 body toPOST /verifyreturned200 {"isValid":false,"invalidReason":"invalid_exact_evm_signature","payer":"0x6f60…89c3"}— it parses v1 and reaches signature verification. -
@x402/core@2.28.0'sgetPaymentRequiredResponse(getHeader, body)prefers thePAYMENT-REQUIREDheader and reads the body only whenx402Version === 1. Header V2 / body V1 is the shape the official code expects. Client sniffing is impossible: a v1 client's first request is a plainfetchwith no distinguishing header.
Implementation: the PAYMENT-REQUIRED header stays V2, unchanged, and the body became V1-shaped (x402Version: 1, network: "base", accepts[].maxAmountRequired) with V2 information appended (resource with description, extensions.bazaar, payment_required_v2 identical to the header). One accepts array cannot satisfy both schemas — network is CAIP-2 in v2 and a bare string in v1, and mixing them is a ZodError. X-PAYMENT payloads drive verify/settle with v1 requirements; PAYMENT-SIGNATURE uses the v2 body.
| Client | Result | Time | tx / KV delivery key |
|---|---|---|---|
x402-fetch@1.2.0 (v1) |
402 → sign → 200 (X-PAYMENT-RESPONSE = {"success":true,"network":"base",…}) |
2148 ms |
0x30b356ed…e087 / delivery:2026-10-13T13:36:06.117Z:310ebe
|
@x402/axios@2.28.0 (v2, regression) |
402 → auto-sign → 200 (PAYMENT-RESPONSE network "eip155:8453") |
2066 ms |
0x53dd6ad3…0e74 / delivery:2026-10-13T13:36:16.423Z:efa3eb
|
After the change: selftest.mjs 43/43, check-live.mjs 14/14 (the first live run was 12/14 because the deploy had not propagated; the re-run was 14/14), production KV at 8 keys.
npm registry weekly downloads, retrieved 2026-10-13: @x402/fetch 127,214 vs x402-fetch 14,890 (v2 ≈ 8.5×). Two measured pitfalls: @x402/axios retries the 402 in axios's reject handler, so validateStatus: () => true resolves the 402 as a success and no retry happens (caller configuration, not a defect in the 402); and because a purchase is two calls by design — the 402, then the paid 200 delivery on the follow-up request — the 402's resource.description now ends with an explicit completion note, in both the header's base64 JSON and the body.
6. Discovery is not delivery either
The same discipline applies before the payment. Of a public directory list I checked (CSV from the GitHub repo mezmer90/saas-directories: HTTP 200, 921 data rows, retrieved 2026-10-13 21:14 JST), only 90 of 921 rows had pricing filled in, so "free" had to be confirmed per site. Of the top 20 checked between 21:16 and 21:26 JST: four were paid-only, four required solving a CAPTCHA, five required an account, and two offered a free tier only with a backlink on my own site. One free, no-account submission sat at "Pending approval"; another accepted the form but returned no receipt — recorded as unconfirmed, not "under review."
A listing is not revenue. What you can measure is the pair: paid-route 402s (someone reached the price) and 200 deliveries (someone received data), in separate columns.
7. Checklist
- Log every request with
pathandstatus; countstatus=402as contact, never as revenue. - Record a separate event on paid-route 200 only, independent of any single storage backend, and emit it before the storage write.
- Never throw from logging code; write an explicit failure line instead of swallowing the error.
- Store no query strings and no payment material; TTL the durable store and verify the real
expirationonce. - Run
wrangler kvwherewrangler.tomllives, and always with--remote. - Before trusting "no records," prove the producer works (self-purchase or a temporary probe path), then revert.
- Test the 402 against both v1 and v2 clients; a correct v2 402 can be a hard error for a v1 client.
- Read the reference implementation's header/body precedence before inventing a compatibility scheme.
- Watch for caller-side settings such as
validateStatusthat silently prevent the payment retry.
Free catalogue and purchase example: https://x402-catalog-history.ai-business-surface.workers.dev/ — every product with its price, the network and payTo, and a machine-readable curl purchase example for each (402 → sign → resend with PAYMENT-SIGNATURE → 200). GET /product.json is the same catalogue as one JSON document, and GET /health is free.
Top comments (1)
Really like the 402 vs 200 framing. The settled-without-second-request case is the one that quietly breaks naive integrations: settlement is a fact about the payer's ledger, not proof the server delivered anything. Binding the payment to the request with an idempotency key or nonce, and having the seller verify the settlement transaction itself rather than trusting the client's header, is what turns a ledger entry into delivery evidence. And on the retry path: reconcile the settlement ledger before ever re-charging, or one retry loop becomes double billing. Disclosure: I'm on the Payload team.