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.
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.
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.
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.
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”.
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.
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).