Why a failed handler gets its payment back and a failed settlement does not
A payment gate marks a payment spent before it knows whether the work succeeded. You don't get to pick that ordering: record the key after the handler returns and the entire run is open to a second presentation of the same signed payload. Record it first and you get the opposite failure, where a handler that fell over has burned a payment for a response nobody received. The fix is a release allowed on exactly one path, and the reasoning about which path carries more weight than the mechanism does.
The code here is paygate402, an x402 payment gate that wraps an http.Handler: an unpaid request comes back as 402 with the terms the server accepts, the client repeats it with an X-PAYMENT header, and the gate gets that payment verified and settled before the handler's response reaches anybody.
The ordering is not a choice#
The replay key is a hash of the payment, not of the request:
// PaymentKey is the replay key for a payment.
func PaymentKey(payment Payment) string {
sum := sha256.New()
sum.Write([]byte(payment.Scheme))
sum.Write([]byte{0})
sum.Write([]byte(payment.Network))
sum.Write([]byte{0})
sum.Write(payment.Payload)
return hex.EncodeToString(sum.Sum(nil))
}That payload is what a client sends over the wire, and the server is not the only party that ever holds a copy of it. If the key went into the store after the handler returned, the gap between the two would be as long as the work takes (a report to generate, a model to call, a file to build), and the same payload could be spent again inside that gap, concurrently, as many times as the server has goroutines to serve it. A replay window as wide as your own p99 latency is not a window you get to reason about. It opens widest exactly when the endpoint is expensive.
So the key goes in first. One refinement is worth saying out loud, because it mirrors the whole problem: the key goes in after verification passes, not at first sight. Recording an unverified payment would burn a payload the facilitator rejected, and a rejection is often something the client can fix (an allowance, an approval) and then legitimately resend the same signed payment for.
store := g.Seen
if store == nil {
store = defaultStore
}
if store.SeenBefore(PaymentKey(payment), window) {
g.challenge(w, accepts, "this payment has already been used")
return
}
// The handler runs into a buffer.
recorder := &recorder{header: http.Header{}, status: http.StatusOK}
next.ServeHTTP(recorder, r.WithContext(withPayer(r.Context(), verification.Payer)))The handler writes into a recorder that buffers status, headers and body. Nothing has reached the client yet, and that is what makes the next decision possible at all.
Four exits, and only one of them is before the money#
Once the key is recorded there are four ways out of the handler:
| Exit | What the client gets | Key after |
|---|---|---|
| Handler answered outside 2xx | the handler's own status and body | released |
Settle returned an error |
502, settlement unavailable |
stays spent |
Settlement.Success is false |
402 with the facilitator's reason |
stays spent |
| Settled | the buffered response plus X-PAYMENT-RESPONSE |
stays spent |
The first exit is the only one you reach before any settlement was attempted:
if recorder.status < 200 || recorder.status >= 300 {
// Nothing was delivered, so nothing is charged — and nothing was
// settled either, so the payment is handed back rather than left
// spent on a response the client never got.
if releaser, ok := store.(Releaser); ok {
releaser.Release(PaymentKey(payment))
}
recorder.flush(w, "")
return
}Notice what the flush does not carry: no X-PAYMENT-RESPONSE, because there is no settlement to report. The client sees its 500 and can retry with the identical header.
The other two failure exits look like they deserve the same treatment. They don't:
settling, done := context.WithTimeout(context.WithoutCancel(r.Context()), g.settleTimeout())
defer done()
settlement, err := g.Facilitator.Settle(settling, payment, terms)
switch {
case err != nil:
g.log("settlement call failed", "error", err)
http.Error(w, "payment settlement is unavailable", http.StatusBadGateway)
return
case !settlement.Success:
g.log("settlement declined", "reason", settlement.ErrorReason)
g.challenge(w, accepts, orDefault(settlement.ErrorReason, "payment could not be settled"))
return
}A transport error is the unknown case, and it is the oldest trap in payments: a call that failed is not a transfer that did not happen. The request may have reached the facilitator, the facilitator may have broadcast, and all you know is that you did not hear back... Release the key there and the next presentation of the same payload gets settled again, on top of a transfer that may already be sitting on the chain.
The declined case is subtler, and it is the one I see people get wrong. Success: false with a reason reads like a definitive no, but it is the facilitator's account of what it observed, and the gate has no second witness. The gate does not read the chain, and it has no business doing so inside a request. So both of these keep the payment spent, and the client is told plainly that the payment was used. For an honest client that is a worse outcome than an automatic retry would be, and it is the only direction that cannot charge twice.
One detail keeps the unknown case from being manufactured on purpose: settlement runs on context.WithoutCancel with its own timeout. A client that hangs up the instant it would have received its response cannot abort the transfer mid-flight and leave the money in the one state nobody can act on.
The release is optional, and that is the safety property#
Release is not part of the store interface. It is a second, narrower one:
// Releaser is a SeenStore that can forget a key.
type Releaser interface {
SeenStore
Release(key string)
}A store that does not implement it keeps the payment spent, and there is a test pinning exactly that, using a wrapper that is a SeenStore and nothing more:
// keepingStore is a SeenStore and nothing more: it cannot take a payment back.
type keepingStore struct{ inner *MemoryStore }
func (s *keepingStore) SeenBefore(key string, ttl time.Duration) bool {
return s.inner.SeenBefore(key, ttl)
}
func TestAStoreWithoutReleaseLeavesThePaymentSpent(t *testing.T) {
g := releaseGate(&keepingStore{inner: NewMemoryStore()})
signed := header(t, payment("1"))
if failed := releaseRequest(t, g, handlerThatFails(), signed); failed.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want the handler's 500", failed.Code)
}
retry := releaseRequest(t, g, paidHandler(), signed)
if retry.Code != http.StatusPaymentRequired {
t.Fatalf("retried request: status = %d, want 402; body %s", retry.Code, retry.Body)
}
}This is for whoever puts a shared store behind the interface, which any multi-replica deployment has to do: the bundled MemoryStore is one process's memory, and FileStore is one machine's disk. A Redis-style store implements Release in a line. A store that genuinely cannot delete, say an append-only audit log you do not control, or a third-party idempotency service with no revoke, does not have to pretend. Leave the method off and the gate takes the conservative branch, with no configuration flag anywhere and no way for a deploy to pick up the dangerous half of the behaviour by accident.
Forgetting in an append-only log#
FileStore exists so a restart does not hand a captured X-PAYMENT header a second chance. Its file is an append-only log of expiry key lines, so forgetting cannot be a deletion. It has to be another line:
// releaseMarker stands where an expiry would be, on a line that takes a payment
// back. An append-only log forgets by appending, and the later line wins when
// the file is read again.
const releaseMarker = "-"A release writes the key with - where its timestamp would be. On the way back in, the record parser turns that marker into the zero time, and the loader deletes any key whose expiry is zero. The tombstone survives the restart, which is the whole point: a release that lived only in memory would resurrect the spent key on the next boot and refuse a retry the client is entitled to. Releasing a key that was never recorded writes nothing, so a stray release does not grow the file, and the file gets rewritten once the dead records outnumber the live ones.
The free tier obeys the same rule from the other side. An allowance is spent before the X-PAYMENT header is even read, and a free request that answered outside 2xx comes back through Return on the allowance store: work that was not delivered is not charged for, whether the unit of charge is a stablecoin transfer or one of five courtesy requests.
What I would change#
Release(key string) returns nothing. For a map behind a mutex, and for a file that keeps its own error for you to ask about later, that is fine. For a network store it is not: a release that fails against Redis is a payment silently left spent, with no log line and nothing for an operator to find. I would give it an error return and log the failure at the gate, while still treating it as non-fatal. The client already has its 500, and a release that did not take is the same conservative outcome as a store that cannot release at all.
The second change is about visibility. The gate asks whether the store is a Releaser per request, inside the failure branch, so the quiet, conservative behaviour is also the invisible one: an operator who swapped in a store without Release probably learns about it from a support ticket. I would resolve the capability once, where the gate is validated, and say so at startup.
What I would not change is the single-path rule. Everything above collapses into one sentence: record the payment before the work runs, and forget it only where nothing was attempted. The mechanism is the easy half of that. The part I had to sit with is that a declined settlement is not evidence of an absent transfer, and that the gate has to be the component which refuses to guess.