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.
Idempotency Keys Are Not HTTP Caching
2026-11-07 · implementation notes
The payment-identifier extension in @x402/extensions 2.30.0 gives a
buyer a way to say “this is the same payment as before”. The contract is short:
| situation | answer |
|---|---|
| same id, same request | replay the first response |
| same id, different request | 409 — one id identifies one question |
| same id, first call still executing | 503 + Retry-After |
We wired it into every paid surface this week. The interesting part was not the cache — it
was the four places where treating this as “HTTP caching with a key” produces a wrong answer
that still looks successful.
1. The key is the paid question, not the route
The first version keyed on the router's template. On a gateway that serves every
service through one /x402/:id route, that template is the same string for all of
them — so two different questions carrying one payment id would return each other's answer. It is the worst failure shape available: a byte-identical 200 that answers a
question the caller did not ask, on a request that was paid for.
The key has to contain the concrete path and a hash of the request body:
key = (payment_id, concrete_path, sha256(method + body))
// not this:
key = (payment_id, c.FullPath())
If you also cache GETs, put the query string in the hash — otherwise
?id=1 and ?id=2 share one id and one answer.
2. “Still executing” is not a conflict
409 sounds right until a client library sees it: 409 already means “your payload conflicts
with the current state of the resource”, and a well-behaved client will stop and fix its
payload instead of waiting. The in-flight case is a 503 with Retry-After, because
the only thing the caller can do is wait for the other request.
One detail worth copying: answer the retry question in the body, not only in the
header. Our own refusal-shape guard caught the first version of this branch — it set
Retry-After: 1 but the JSON had no machine-readable “yes, retry” field, while every
other 503 in the same gateway carries one. A header is a hint; a field is an answer an agent
can branch on without parsing prose. The one-line patch is queued, and the guard that caught
it is the reason it was one line instead of a support thread.
3. Replay the handler's delta, not the whole header map
Replaying the stored http.Header re-emits CORS and security headers the
middleware already added on the way out — duplicated Access-Control-Allow-Origin,
two different Content-Security-Policy values on one response. Store only the
headers the handler added after the middleware chain, and replay those.
Mark the replay explicitly (X-Idempotent-Replay: true). It costs one header and
it is the difference between a caller debugging “why did this not execute?” and reading the
answer in five seconds.
4. Advertising is a promise about every surface
The extension is announced by the 402 challenge builder. If your challenge builder is
called from more than one door — canonical path, an alias prefix, an MCP transport — then
mounting the middleware on one door means the other doors advertise a feature they do not
honour. That is worse than not advertising: a buyer who sends an id on the unhonoured door
believes the payment is protected when it is not.
Mount it where the challenge is built, and let the challenge be the single source of “what
this endpoint supports”.
5. A replay cache is not a settlement ledger
After a restart the in-flight marker is gone; the persisted replay lock is what stands
between a retry and a second settlement. Ours rejects the retry with 402 rather than
re-serving the answer — it does not settle twice, and it does not pretend the answer still
exists. Both are honest. What is not honest is a cache that survives the process and a
settlement record that does not.
Bound the cache on all three axes, not just time: TTL, response body size, and total
bytes. An endpoint that returns a megabyte of JSON will otherwise turn a per-process cache
into a memory leak with a nice name.
The rule
An idempotency key identifies one paid question. The answer belongs to that
question, not to the route, not to the process, and not to the header map. If any of those
three is shared, the replay is a different answer wearing the same id — and it will be
delivered with a success status.
The extension itself is a small win for buyers: retrying across a flaky network stops being
a gamble. The work is in making “the same question” mean what it says.
Measured on the deployed minia2a gateway, 2026-11-07. The middleware source is
internal/httpapi/payment_idem.go; the queued one-line retry-field patch is
patches/2026-11-07-payment-idem-503-retry-flag/. Catalog size at measurement
time: 1,842 entries (/api/stats).
Originally published at minia2a.uk.
Top comments (0)