A paywall is a table of what is sold
Any HTTP payment gate has to answer one question: what happens to a request it has no price for. Most middleware answers that by accident rather than by design. Everything behind the gate is paid, so a path nobody priced gets a challenge built from some fallback amount, or a 500, or an empty accepts array that no client can act on. I built paygate402 the other way around. The price table is the entire statement of what is sold, and a route the table does not name is served free, with no challenge at all.
That is one of two defaults in this gate worth arguing about. The other one is an ordering: the free tier is spent before the X-PAYMENT header is even read. Five lines each. Both of them decide what the thing is.
An unpriced route is not an error#
A gate sitting in front of a single resource can carry its terms directly in Accepts. A gate in front of a whole API declares a price per route instead:
gate := &paygate402.Gate{
Prices: &paygate402.Prices{
Terms: paygate402.Requirements{
Scheme: "exact",
Network: "base",
PayTo: merchantAddress,
Asset: usdcAddress,
MaxTimeoutSeconds: 60,
},
Routes: []paygate402.Route{
{Pattern: "/report", Amount: "10000", Description: "One generated report"},
{Pattern: "/reports/{id}", Amount: "2000"},
{Pattern: "POST /jobs", Amount: "50000"},
},
BaseURL: "https://api.example.com",
},
Facilitator: facilitator,
}
http.Handle("/", gate.Handler(api))Terms holds what does not change from route to route (the scheme, the network, the asset, the recipient), and each route supplies the amount. The lookup is a compiled http.ServeMux of the priced patterns, so method matching, wildcards and precedence come from the standard library instead of a second dialect invented inside a payment package. /reports/summary beats /reports/{id} here for the same reason it would anywhere else in a Go server.
What happens on a miss is where the design decision sits:
accepts, err := g.accepts(r)
switch {
case errors.Is(err, ErrNoPrice):
// The table is the whole statement of what is sold here, so a route
// missing from it is free rather than closed.
next.ServeHTTP(w, r)
return
case err != nil:
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}ErrNoPrice is a sentinel the lookup returns for a path no Route declares, and the gate reads it as this costs nothing, not as this failed. A health check needs no line in the table saying it is free. Neither does /favicon.ico, nor the OpenAPI document, nor whatever the load balancer probes.
The opposite default (fail closed, charge for anything not explicitly exempt) sounds like the safe one, and it is the one I would expect a security reviewer to ask for. I think it is wrong for this component, for a reason that has nothing to do with safety: it stops the price table from being the statement. You would need a second table, of exemptions, and the two would be disagreeing inside a month. Every route added to the API would have to land on one list or the other before the service could answer a probe, and the failure mode of forgetting is a 402 on an endpoint whose caller has no wallet and no idea what a facilitator is. Ask a paywall what it sells, and it should answer by printing one list. It can only do that if the list is exhaustive by construction.
The cost of this default is honest and should be stated: a typo in a pattern makes a paid route free rather than broken. That is a real risk, and it is why the two failures that can be caught are caught loudly. A malformed or repeated pattern makes http.ServeMux panic, and registration turns that panic into an error the gate reports as a 500, like the rest of its misconfiguration. Gate.validate refuses a gate with both Accepts and Prices set, because those are two ways of saying the same thing, and the alternative is a precedence rule nobody would remember at three in the morning.
The free tier is spent before the payment is read#
The second default is nothing but the order of two statements in Handler:
// The free tier is spent before the payment is even read: a client that
// does not have to pay yet is not asked to, and a payment it sent anyway
// is left unspent rather than taken.
if client, left, granted := g.Free.take(r); granted {
g.serveFree(w, r, next, client, left)
return
}
payment, err := DecodePayment(r.Header.Get(PaymentHeader))Turn those around and nothing visibly breaks. The gate still serves five free requests, and the tests that count responses still pass. What changes is the client that sends a payment it did not have to send: an agent that attaches one to every request, because that is simpler than tracking an allowance. Decode first, and that payment gets matched, verified, recorded against replay and settled, and the free request is consumed on top of it. The client paid for something advertised as free and has no way to tell, because the response looks exactly like the paid one.
With the check in this order, the payment header is not read at all. The facilitator is not called. The test asserts the absence rather than the outcome, which is the only way to assert it:
func TestAGateWithFixedTermsCanAlsoHaveAFreeTier(t *testing.T) {
facilitator := &StaticFacilitator{}
g := gate(facilitator)
g.Free = &Allowance{Requests: 1}
if first := request(t, g, ""); first.Code != http.StatusOK {
t.Fatalf("status = %d, want the free request served", first.Code)
}
if second := request(t, g, ""); second.Code != http.StatusPaymentRequired {
t.Errorf("status = %d, want 402 once the allowance is gone", second.Code)
}
if facilitator.Trail() != "" {
t.Errorf("calls = %q: a free request was put to the facilitator", facilitator.Trail())
}
}Every free response also carries how many are left:
X-PAYMENT-ALLOWANCE: 4AllowanceHeader is this package's own: x402 does not define it. A client that can watch the allowance running down can have a payment ready, instead of finding the boundary by being refused and having to replay the request.
A free request that produced nothing is given back#
The paid path in this gate does not charge for work it did not deliver: a handler that answers non-2xx gets the payment released rather than left spent. The free path owes the client the same courtesy, for the same reason. serveFree runs the handler on the house, and when the handler answers outside the 2xx range it hands the request back through Allowance.give.
The in-process counter does that with a decrement, and with the floor that matters:
// Return gives one free request back.
func (c *AllowanceCounter) Return(client string) {
c.mu.Lock()
defer c.mu.Unlock()
if entry := c.used[client]; entry != nil && entry.used > 0 {
entry.used--
}
}The entry.used > 0 guard is not defensive clutter. Without it, a duplicated return (a retry, a buggy caller, a store that saw the release twice) drives the count negative and quietly hands out an unbounded free tier.
The tally resets over Every, and the sweep runs on write instead of in a goroutine:
// sweep drops the clients whose period has closed. It runs on write, on the
// requests that matter, so no background goroutine has to exist. A lifetime
// allowance has no period and is never swept — that is what makes it a
// lifetime one.
func (c *AllowanceCounter) sweep(now time.Time) {
for client, entry := range c.used {
if !entry.resets.IsZero() && now.After(entry.resets) {
delete(c.used, client)
}
}
}Every == 0 means the allowance does not reset, and that falls out of the same branch rather than from a special case: an entry with no reset time is never swept, so it is a lifetime allowance by omission.
Who counts as a client#
Clients are told apart by ClientIP, which reads RemoteAddr and nothing else:
func ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}No forwarded headers. Anyone can set one, and a free tier that counts a header the client controls counts nothing. The consequence is that behind a proxy the default is useless, since every request arrives from one address and the whole world shares five requests, so Allowance takes a Client function: you give it the header your own proxy sets, or you key the tier on something a client states on purpose:
g.Free = &Allowance{
Requests: 1,
Client: func(r *http.Request) string { return r.Header.Get("authorization") },
}This is a courtesy and not a rate limit, and it should not be sold as one. Before anyone has paid, the only thing a request carries is the address it came from, and a new address is a new allowance, so the tier is worth exactly what a fresh address costs. It exists so an agent can try an endpoint before paying for it. The tally also lives in one process unless Counts is set, which is why it is an interface: several replicas mean several allowances.
type AllowanceStore interface {
Take(client string, limit int, window time.Duration) (bool, int)
Return(client string)
}What I would change#
The inconsistency I would fix first is about trusting the request. The allowance refuses to read a forwarded header because the client controls it. But when neither BaseURL nor a route Resource is set, the resource named in the 402 gets built from the request's own scheme and host, which are just as much the client's to assert and are wrong behind a proxy that terminates TLS. Two parts of one package answer the same question in opposite directions. The free tier is the side that got it right. The resource fallback should be a configuration error instead of a guess.
The rest I would keep. A paywall whose table is the whole statement of what is sold, and a free tier spent before any money is read: those are choices you get to make once, because every later line of code assumes one answer or the other.