The only stateful part of an x402 payment gate is the replay ledger
An x402 paywall is three steps: the server answers an unpaid request with 402 and the terms it accepts, the client repeats the same request carrying an X-PAYMENT header, and the server verifies, settles, and only then serves. Two of those steps can be stateless. The third cannot. Unless the server remembers which payments it has already settled, a captured X-PAYMENT header buys the resource again on every replay, and the gate is decoration with a status code.
I wrote paygate402 to sit in front of a Go HTTP handler, and the part of it I keep coming back to is not the protocol plumbing. It is the ledger of spent payments and the exact moment a key gets written to it.
Three steps, one of which repeats#
The wiring is small. The gate holds the terms it will accept and a facilitator to ask about payments:
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"},
}
http.Handle("/report", gate.Handler(reportHandler))Note where the boundary is. This package does not check signatures and does not move money. In x402 that is the facilitator's job: it reads the scheme-specific payload, recovers the signature, checks the allowance, submits the transfer. So the payload stays opaque here, and matching compares only what a web layer can honestly compare — the scheme and the network. The amount, the asset and the recipient are checked by the component that can read the payload. A middleware that decoded some base64 and called it verification would be worse than no middleware, because it would look like a paywall while charging nobody.
That boundary has a consequence people miss. Everything the gate holds in its hands is a bearer artifact. The X-PAYMENT header is a self-contained, signed instruction, sent over a stateless protocol, with nothing binding it to a session, a connection, or a single attempt. Anything that sees the header holds a complete payment: a proxy log, a retry loop, a shared trace, a browser extension, a support ticket with a captured request. The signature inside it stays valid on the second presentation — that is what a signature is for. The only thing that can make the second presentation different from the first is server memory.
The offer is fine stateless#
Before the spend there is the offer, and the offer is the half of this that genuinely does not need a database.
A quote is the priced offer behind a 402: an amount, an asset, the moment the offer stops standing, and a nonce that makes it one of a kind. It is signed with the server's own key, so an offer that comes back can be checked against what was actually offered rather than against what a client says was offered.
signer := &paygate402.QuoteSigner{Key: secret, TTL: time.Minute}
quote, err := signer.Issue(terms)
// …later, with the quote a client returned:
err = signer.Verify(quote) // the signature first, then the expiryThe signature covers a canonical form rather than the JSON: a domain tag, then every set field in a fixed order, each part written as its length followed by its bytes. Lengths rather than separators mean no value can be read as two fields — the classic way a signature over concatenated strings gets forged is by moving the boundary between two of them. A field left empty is not written at all, which buys forward compatibility for free: a field added in a later version leaves the signed bytes of an older quote exactly as they were, and signatures issued before it keep verifying.
The expiry is signed as whole Unix seconds, and that is not fussiness. A timestamp is only safe inside a signature if both sides can reconstruct the identical bytes; carry sub-second precision through a JSON round trip and the two sides eventually disagree in the last digit, which reads as a forged quote rather than as a serialization bug. Truncating to whole seconds removes the disagreement.
All of that makes the offer re-checkable without the server storing anything. But a quote signature is this server's, not a chain's. It says "these were my terms", and nothing whatsoever about whether anyone paid. Collapsing those two classes of fact — treating a valid signed artifact as evidence of settlement — is the single most expensive mistake available in this design.
The spend cannot be stateless#
So the ledger. A captured X-PAYMENT header is refused the second time, and the interface for remembering is deliberately one method:
// SeenStore remembers payments that have already been used, so that a captured
// X-PAYMENT header cannot be replayed against the same server.
type SeenStore interface {
// SeenBefore records a key and reports whether it was already there.
SeenBefore(key string, ttl time.Duration) bool
}
// 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))
}Two decisions are load-bearing here.
The key is a digest of the whole payload rather than a nonce field. Every x402 scheme carries its own payload shape, and reaching into one to find "the nonce" breaks the moment a new scheme appears — the web layer would have to grow a parser for a format it explicitly refuses to interpret everywhere else. Digesting the bytes keeps the gate scheme-agnostic. The trade-off is real and worth stating plainly: two distinct encodings of the same underlying authorization would hash to two keys. That is a property of the scheme, and it is catchable in the component that actually reads payloads. What the digest does refuse, completely, is the exact captured header replayed verbatim — which is the threat that actually exists in a bearer-token protocol.
The second decision is SeenBefore recording and reporting in one call. Check-then-set as two calls is a race, and it is the specific race that costs money: two concurrent requests carrying the same header both read "not seen", both settle. One call that atomically records and tells you whether it was already there is the only shape that survives concurrency, and it happens to be the shape a shared store implements naturally.
When the key gets written#
This is the part I would argue about with someone, so here is the reasoning.
The key is recorded only after verification passes. A payment the facilitator rejected never touched the chain, and a client who fixes their allowance may legitimately resend the same signed payload. Burning the key on a rejection turns a recoverable client error into a permanently unusable payment.
Once settlement has been attempted, the payment stays spent — even when settlement failed. A rejected verification is a known non-event. A failed settlement is an unknown: the transfer may have landed and the response may have been lost. The ambiguous case must never risk a double charge, so it refuses in the direction of refusal.
Two orderings around it matter just as much. Settle after the handler and serve after settlement: the handler runs into a buffer, so if the work fails nothing is charged, and if settlement fails nothing is served. And an unreachable facilitator is a 502, not a 402. "We could not ask" and "the answer is no" are different outcomes; answering 402 when the facilitator is down tells a client to pay a second time for something they may already have paid for. That is the same instinct as the ledger, expressed in a status code.
The default store is wrong for the deployment you will actually have#
The bundled store keeps seen payments in the process:
func (m *MemoryStore) SeenBefore(key string, ttl time.Duration) bool {
m.mu.Lock()
defer m.mu.Unlock()
now := m.now()
// Expiry is swept on write. A payment gate sees writes on exactly the
// requests that matter, so no background goroutine has to exist.
for existing, expires := range m.seen {
if now.After(expires) {
delete(m.seen, existing)
}
}
if expires, ok := m.seen[key]; ok && now.Before(expires) {
return true
}
if ttl <= 0 {
ttl = time.Hour
}
m.seen[key] = now.Add(ttl)
return false
}It is right for one instance and wrong for several: a second replica does not share the map, so a payment could be replayed once per replica. That is why SeenStore is an interface rather than a struct field — a shared implementation drops straight in. Sweeping expiry on write is a small pleasure: a payment gate sees writes on exactly the requests that matter, so retention costs nothing and no background goroutine has to exist.
The ttl argument is a retention question with a security floor. The key has to outlive every window in which the same payload could still be accepted by anything downstream — the quote's standing time, the facilitator's own tolerance, the settlement's finality. Short of that, the entry expires and the replay works.
What I would do differently#
If the service will ever run more than one replica, I would not ship the in-process store at all, not even briefly. Replay windows do not announce themselves; they show up as a reconciliation discrepancy weeks later, and by then the header that caused it is long gone from the logs. Eight years of payment work has taught me exactly one durable thing about this class of bug: a refusal is cheap and a double charge is not, so every ambiguous branch should point at refusal.
The other thing I would change is smaller. SeenBefore returns a bool, which is enough to gate a request and not enough to explain one. If I were operating this at any scale I would want the ledger to also be able to say when the key was first seen, because "this payment has already been used" is a support ticket, and the answer to that ticket lives in the store.
The gate is the part that looks like the product. The ledger is the part that makes it true.