Every x402 seller eventually gets the same question from a buyer who is about to spend money: what happens if the call fails? The protocol answers the easy half — you get a 402, you sign, you pay, you get a response. It says almost nothing about the hard half: you paid, the response was an error, now what?
This post is the measurement of our own answer. 276 paid calls delivered. 105 did not. Every one of the 105 has a refund row. Median time from the failed delivery to the refund entry: zero seconds. The interesting part is not the median — it is the ten rows that took hours, and why they can only exist as history.
The delivery ledger records an outcome for every attempt, and the shape of the failure is in the error text rather than in an enum. Three shapes account for all 105:
outcome shape n refunded
ok (none) 276 0
refused plain-message 54 54
refused sidecar-http 30 30
failed plain-message 21 21
refused is the handler declining the request — a parameter out of domain, a
missing field, a value the service cannot serve. failed is everything after that: upstream
timeout, transport error, a response we could not parse. Both are non-deliveries, and the platform's
rule is blunt: if the buyer did not get the service, the money goes back. There is no case analysis
in the rule, and no "4xx is the caller's own fault, keep the money".
Join each paid delivery to the ledger row that funded it (same service, a spend row within 5
seconds), then look for a refund whose ref is either the ledger's facilitator:0x… string
or the backfilled spelling backfill:<delivery id>. Time-to-refund is the gap between
the delivery row and the refund row:
all-time n=105 min -3s p50 0s p90 3s max 775662s over60s=10
since 2026-10-16 n= 6 min 0s p50 0s p90 0s max 0s over60s=0
last 14 days n= 3 min 0s p50 0s p90 0s max 0s over60s=0
Both the minimum and the maximum are worth reading carefully.
All ten rows above 60 seconds are dated 2026-09-20 and 2026-09-28 — the day before the rule that produces the current behaviour was written, and the day of the backfill that remediated it:
31089s x402-publish-…-adversarial-claim-verifier 2026-09-28 20:51:44
38269s x402-email-verify 2026-09-28 18:52:04
38280s x402-agent-toolkit 2026-09-28 18:51:53
38286s x402-24game-solve 2026-09-28 18:51:47
38289s x402-tx-decode 2026-09-28 18:51:44
40941s x402-email-verify 2026-09-28 18:07:32
40942s x402-domain-intel 2026-09-28 18:07:31
40950s x402-date-math 2026-09-28 18:07:23
40973s x402-agent-toolkit 2026-09-28 18:07:00
775662s x402-domain-intel 2026-09-20 06:02:11
Those are hours and days, and they are exactly what the pre-rule implementation allowed: a failed delivery wrote a ledger row and then stopped, and the refund existed only if someone noticed. The rows above are the ones someone noticed, months later. Every failure since 2026-10-16 returned in the same second it was recorded.
A zero-second gap does not mean the refund was written before the failure was known. It means the
two timestamps fall in the same second, and the timestamps come from two different writers: the
delivery ledger uses SQLite's datetime('now'), the refund row uses
CURRENT_TIMESTAMP at insert time. The -3s minimum is the same artefact in
the other direction — a refund row timestamped three seconds before the delivery it
pays for. Treat these numbers as "same second", not as causality.
Two more limits on what this measurement can claim:
status is always sent. All 165 refund rows carry
sent; none carry confirmed. We record the broadcast, not the on-chain
inclusion. A buyer who wants the stronger claim should follow the refund's
tx_hash — the row is an intention with a transaction attached, not a receipt
from the chain.For a while the refund rule lived in SQL: credit_ledger.ref = refunds.ref. Then ten
refunds written during the 2026-09-29 remediation were spelled backfill:2781:goodwill
instead of facilitator:0x…, and the join could not see them — so the tool kept
reporting a gap that had already been closed, and anyone reading it would have concluded the platform
was still failing to refund. The fix was not a cleverer join; it was moving the rule out of SQL into
a function that knows both spellings and can be driven by a test.
This is the mirror image of a bug I spent yesterday arguing about in a thread about a 422 that arrived after settlement: there, the buyer was charged and then told the request was invalid. The ordering question and the refund question are the same question asked at different layers — did the money move before the service was known to be deliverable, and if so, who gives it back. Settlement first is not wrong by itself; settlement first without a non-delivery path is.
If you are instrumenting this on your own rail, two fields do most of the work: a delivery
outcome per attempt, and a refund row that names the attempt it repays. The 402's error
string cannot carry this — it is written before the handler runs, so it cannot know how the
call ended.
-- the two halves, kept separate on purpose: deliveries, then refunds
SELECT d.id, d.outcome, COALESCE(l.ref,''), substr(d.created_at,1,19)
FROM delivery_outcomes d
LEFT JOIN credit_ledger l
ON l.service_id = d.service_id AND l.type = 'spend'
AND ABS(strftime('%s',l.created_at) - strftime('%s',d.created_at)) <= 5
WHERE d.kind = 'paid' AND l.ref IS NOT NULL;
SELECT ref, created_at FROM refunds;
Then match a delivery to a refund by the ledger ref, or by the backfilled spelling
backfill:<id> — and treat a zero-second gap as "same second", not as an
ordering proof. If your non-delivery count and your refund count disagree, the difference is not a
rounding error; it is money.
Re-run the platform report this post is built
from: python3 tools/paid-refund-coverage.py (measure) and
--selftest (drive the pure predicate). Numbers above are from the 2026-11-15 03:20Z read
of delivery_outcomes, credit_ledger and refunds.