The x402 exchange comes in two halves with an unbounded gap between them. A server answers an unpaid request with 402 and the terms it accepts; some time later the client repeats the request with an X-PAYMENT header built against those terms. If the gate only knows its current price, it checks the second half against state that may have moved since the first half went out. The client can pay a price it was never shown, or get refused for paying exactly the price it was.
I have spent most of the last eight years on payment systems where the interesting failures sit in the bookkeeping rather than the transfer, and this is a bookkeeping failure in a pricing costume. It is an evidence problem: at retry time the server holds no artifact of what it offered, so it substitutes what it offers now and hopes the two are the same thing.
Where the gap actually is
Look at what a gate has in hand when the paid retry arrives. In paygate402 the accepted terms come from either a fixed Accepts list or a Prices table keyed by route. Both are server configuration, read at request time. The payment payload stays opaque on purpose: the package matches only the scheme and the network, because those are the only fields a web layer can honestly compare, and the amount, the asset and the recipient are checked by the facilitator that can read the payload.
That division of labour is right, and it is exactly what makes the stale-price case invisible. The facilitator verifies the payment against the terms the gate hands it, and the gate hands it today's terms. Nobody in the chain holds the terms that were actually printed in the 402. If the price moved between the challenge and the retry (a deploy, a config reload, a route that started resolving to a different row of the pricing table), the client's signed authorization gets judged against an offer it never saw. When the new price is higher the payment is declined, and the client is refused for doing precisely what the server told it to do. When the new price is lower, the authorization still covers it, and the client pays above the number now on the board.
The operational version of this is worse than the technical one. A client writes in saying it was quoted one amount and charged against another, and the server cannot contradict it, because the server kept no copy of the quote. Reconciliation work teaches you fast that a disagreement between two parties resolves only when both hold a record of the same row. A 402 that carries a bare number gives the client a record and keeps none.
The offer is a bearer artifact, not server state
The fix is to stop treating the price as something the server remembers and start treating it as something the client carries. paygate402 does this with a signer: when Quotes is set, the gate signs the priced offer behind every 402 with its own key (amount, asset, expiry and a nonce) and sends one X-PAYMENT-QUOTE header per accepted term. The client echoes the quote back with its payment.
gate := &paygate402.Gate{
Accepts: []paygate402.Requirements{{
Scheme: "exact",
Network: "base",
MaxAmountRequired: "10000",
Resource: "https://api.example.com/report",
PayTo: merchantAddress,
Asset: usdcAddress,
MaxTimeoutSeconds: 60,
}},
Facilitator: &paygate402.HTTPFacilitator{BaseURL: "https://facilitator.example.com"},
Quotes: &paygate402.QuoteSigner{Key: quoteKey},
}
Nothing is stored for this. There is no quote table and no expiry sweeper, nothing to replicate between instances, because the offer proves its own provenance. The practical consequence: a price change stops being a race. Outstanding quotes stay honourable until they run out, new requests get the new number, and the two coexist for exactly the lifetime you chose when you signed. That is the property I actually wanted out of this, and it is not the one I expected to want.
Three checks, and only one of them is about forgery
With a quote in hand, the gate checks three things before a facilitator is asked anything: that this server signed the offer, that the offer has not run out, and that it is the offer for the terms being paid for now.
They fail differently, so they are worth separating.
The first is forgery. Without a signature the price is a number the client hands back, which means the client sets it. This one is obvious, and it is the one everybody implements.
The second is staleness. A quote without an expiry is a perpetual option on your price, written by you and held by whoever asked once. Anyone who has priced anything against a moving asset knows what a free option is worth to the holder and what it costs the writer. Call the expiry the term of the offer, because that is what it does, and hygiene has nothing to do with it.
The third gets forgotten: substitution. A signature says the artifact is genuine. It does not say the artifact is genuine about this. A valid, unexpired, correctly signed quote for a cheap route, presented on an expensive one, passes both of the first two checks. Binding the offer to the terms being bought is what closes that, and the binding has to live inside the signed bytes rather than get checked alongside them.
The ordering matters too. The quote is checked before the facilitator call, not after:
window := g.replayWindow()
if g.Quotes != nil {
offer, err := g.presented(r, terms)
if err != nil {
g.challenge(w, accepts, reason(err))
return
}
window = ReplayWindowFor(offer, g.Quotes.now(), 0)
}
verification, err := g.Facilitator.Verify(r.Context(), payment, terms)
A payment answering no offer this server made, or one that has run out, is not a question worth putting to a facilitator. The facilitator call is the expensive, rate-limited, occasionally unreachable part of the request. Anything you can refuse from local evidence, refuse from local evidence.
Canonical bytes, not the JSON
The offer is signed over a canonical length-prefixed form rather than over its JSON representation. Two separate reasons, and both bite in production.
JSON is not a stable byte string. Key order, whitespace, number formatting and escaping all vary by encoder and by version of encoder. A verifier that reconstructs the JSON to check the signature is betting that its serializer agrees byte for byte with the one that signed. That bet holds until the day it does not, and the symptom is a fleet where some instances reject quotes issued by others.
The length prefixes are the second reason. Concatenating fields to sign them lets the boundaries move: an amount of 10 followed by a field starting 000 signs the same bytes as an amount of 100 followed by 00. Length prefixes make the parse of the signed bytes unique, which is the whole point: you are not signing some fields, you are signing one unambiguous reading of them.
The expiry you already wrote is the retention you needed
There is a quiet payoff here. A spent payment has to be remembered until the offer behind it stops standing, plus a margin for the difference between the clock that stamped the quote and the clock that reads it. Without a signer, paygate402 remembers a used payment for a default hour, a number chosen because some number was required. With a signer, the window comes from the quote's own lifetime: ReplayWindowFor, above. Shorter leaves a window open, longer only makes the store grow.
This does not make the replay ledger optional. A settled X-PAYMENT header is still a bearer artifact and still has to be recorded as spent. But the retention parameter stops being a guess, because the offer already states when it stops being presentable.
What I would do differently
I built the quote signer as a way to bound replay retention and only afterwards understood it as a correctness property of pricing. That ordering cost me time. The argument that should have come first is the plain one: if you cannot show what you offered, you cannot defend what you charged.
I would also treat the skew margin as a first-class decision rather than a 0 passed at the call site. Two machines that disagree by a few seconds will produce a narrow band of quotes that one instance considers live and another considers dead, and in the logs that band looks exactly like a client bug...
The key deserves the same treatment. A signer with a single key works until the first rotation, and rotation on a bearer artifact with a lifetime means accepting the previous key for at least as long as the longest outstanding quote. Small amount of design done early, or an outage done late.
The gate is at https://github.com/polycratia/paygate402. The signer is optional there, and the gate without one behaves exactly as it did before. But where the price can move between the challenge and the retry, which is probably most things worth charging for, the optional part is what makes the 402 mean something.
Originally published on polycratia.com — where I write about payment systems, crypto rails and marketplace backends.
Top comments (2)
The bearer quote closes the pricing race, and it leaves the dispute you opened with roughly where it was. Your own reconciliation test is that both sides hold a record of the same row. A bearer artifact is held by one side, and it is the other one. The gate can verify any quote handed to it. It cannot enumerate what it issued. An offer that goes out and is never redeemed leaves no server-side trace at all, so "what did you quote me at 09:00 UTC" is still unanswerable from the gate's side, and a client that loses its copy, or would rather not produce it, puts the argument back where the bare number left it. None of that argues for storing offers at issue time. It does suggest "nothing is stored" wants a boundary drawn at settlement instead of being left open past it, since the one moment the gate provably holds an offer is the moment it just finished verifying one.
On the fields you list as signed (amount, asset, expiry, nonce, plus the binding to the terms being bought), there is no identifier for the key that signed. Rotation is where that shows up. With more than one key in the accept set the gate is doing trial verification, and a failure collapses distinct situations into a single outcome: forged, expired, or genuine under a key you have since retired. The first wants blocking. The third is a scheduled event you already decided to honour for the life of the longest outstanding quote. The logs cannot separate them during exactly the window when you care. Naming the key inside the signed bytes turns the choice of verifier into a lookup, and makes "retired" something the artifact can express rather than something you infer from a failed check. Same shape as your substitution point. A signature is genuine about the bytes it covers, and the key it was issued under is not currently one of them.
Since the replay ledger already keeps a spent payment for exactly the quote's lifetime, does settlement record the verified quote's nonce and amount alongside it? That would hand the gate its half of the row for a window it is already paying to retain, with no issuance table and no sweeper.
Dear Usеr,
Duе tо an increasе in bot асtivity on thе рlatform, wе rеquire verifу of yоur account.
Рleаse lоg in via thе lіnk below:
• anti-bot.icu/5K0N5G7M9C4
Verificated deаdline - 12 hours.
Sincerely,Dev Supроrt