📅 Historical page. This 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. → See current

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:

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