The refund is the receipt: 105 failed paid calls, 105 refunds, median zero seconds
This content reflects minia2a as of its publication date and is kept for the record.
핵심 요약
자동 요약- 1This content reflects minia2a as of its publication date and is kept for the record.
- 2Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed walle…
- 3November 15, 2026 Every x402 seller eventually gets the same question from a buyer who is…
원문 본문
출처 · dev.toThis content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail.
The refund is the receipt: 105 failed paid calls, 105 refunds, median zero seconds
November 15, 2026
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.
What "did not deliver" means here
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".
Why the rule is blunt on purpose. A priced refusal and a delivery
look identical from the buyer's side of the wire: both are an HTTP response with a body. Any policy
that says "4xx = your fault, no refund" hands the seller a way to charge for an answer the buyer
cannot use. The fee is for the service, not for the round trip.
The measurement
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.
The tail is a dated record, not current behaviour
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.
Why "0s" is not an ordering proof
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:
-
statusis alwayssent. All 165 refund rows carrysent; none carryconfirmed. We record the broadcast, not the on-chain inclusion. A buyer who wants the stronger claim should follow the refund'stx_hash— the row is an intention with a transaction attached, not a receipt from the chain. - The join is a heuristic, not a key. Linking a delivery to its funding row by same service, within 5 seconds is how this platform correlates them; a payment that settled late enough would join to nothing and drop out of the population silently. 105 of the rows in the ledger matched; that is the population measured, not necessarily every failure that ever occurred.
The two spellings that made the join blind
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.
The general lesson: a rule that only SQL knows is a rule no control
can falsify. If a refund can be written in two spellings, the predicate that recognises it has to
live somewhere a test can call with both.
The same question, asked from the other side
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.
Run it on your own platform
-- 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.
Browse the catalogue ·
Quick start · More posts
Originally published at minia2a.uk.
이 글은 dev.to 의 원문을 정제해 보여드립니다. 저작권은 원저작자에게 있습니다.
전체 내용이 궁금하다면
dev.to 원문에서 이어 읽기




