A 402 is a bill: what an x402 gate owes a client when things fail
An HTTP 402 gate sits between a client's money and a service that can fail on either side of it. The handler can fail. The payment network can fail. Those two failures need opposite answers: a failed handler must charge nothing, while a failed facilitator must tell the client the gate is broken, not that it owes more. Nearly every bug I have hit in paid-request plumbing is one of those two answers given in the other's place.
In ordinary HTTP a status code is a statement about the request. On a paid request it is also a statement about money, and that changes what you are allowed to say. A 402 means: you have not paid for this, here are the terms, come back with a payment. Return it because your own facilitator stopped answering and you have just told a client to sign a second authorisation to cover your outage. Settle before the handler has finished and you have billed someone for a 503.
I wrote paygate402 (https://github.com/polycratia/paygate402) around that single asymmetry. The protocol part of x402 is short: unpaid request, 402 with terms, repeat with an X-PAYMENT header, verify, settle, serve. The happy path is not where the work is. It is the four or five places where something fails and the gate has to decide whose problem it is.
Two failures, two answers
The handler side is the easy half, as long as you commit to it early. The client's money must only move for work that was delivered. So settlement cannot happen before the handler has run, and the handler's output cannot reach the client before settlement has succeeded. Both constraints point at the same implementation: the response goes into a buffer.
The payment side is the half that gets written wrong, because the wrong version looks like idiomatic Go.
The facilitator answers on two channels
A facilitator is the seam where the chain lives: signature recovery, allowance checks, the actual transfer. From the server's point of view it is an HTTP call that can fail, and that gives it two distinct ways of saying no:
// Facilitator verifies payments and settles them. This is the seam where the
// chain lives: signature recovery, allowance checks and the actual transfer all
// happen behind it.
type Facilitator interface {
// Verify reports whether a payment is good for the given terms. A returned
// error means the question could not be asked; a Verification with
// Valid=false means it was asked and answered no.
Verify(ctx context.Context, payment Payment, terms Requirements) (Verification, error)
// Settle moves the money and reports the transaction.
Settle(ctx context.Context, payment Payment, terms Requirements) (Settlement, error)
}
type Verification struct {
Valid bool `json:"isValid"`
Reason string `json:"invalidReason,omitempty"`
Payer string `json:"payer,omitempty"`
}The error is "the question could not be asked". The Valid field is "it was asked and answered". Those are not degrees of the same thing, and the line that treats them as one is the bug:
// Wrong, and it reads fine.
if v, err := g.Facilitator.Verify(ctx, payment, terms); err != nil || !v.Valid {
g.challenge(w, accepts, "payment was not accepted")
return
}That || converts a network timeout into a demand for money. The client, especially an automated one, does exactly what the status code tells it to: signs another payment and retries into the same outage. The correct shape keeps the channels apart, and the comment is in the repository because it is the reason the branch exists at all:
verification, err := g.Facilitator.Verify(r.Context(), payment, terms)
if err != nil {
// The facilitator is unreachable. This is the server's problem, not
// the client's: answering 402 would tell them to pay again.
http.Error(w, "payment verification is unavailable", http.StatusBadGateway)
return
}
if !verification.Valid {
g.challenge(w, accepts, orDefault(verification.Reason, "payment was not accepted"))
return
}One more detail about Verification.Payer: inside the handler, Payer(r.Context()) returns the address the facilitator said paid. It is the facilitator's claim, not the web layer's finding. A web layer cannot honestly check a payer, an amount, an asset or a recipient, so it should not pretend to. It should record whose claim it is relaying.
Settlement comes after the handler
The handler runs into a recorder, and the buffered body is only written out once money has actually moved:
recorder := &recorder{header: http.Header{}, status: http.StatusOK}
next.ServeHTTP(recorder, r.WithContext(withPayer(r.Context(), verification.Payer)))
if recorder.status < 200 || recorder.status >= 300 {
// Nothing was delivered, so nothing is charged.
recorder.flush(w, "")
return
}
settlement, err := g.Facilitator.Settle(r.Context(), 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
}Read the two cases of that switch next to each other. A settlement call that errored is a 502 and a log line. A settlement the facilitator declined is a 402 carrying the facilitator's own reason. Same function, same return site, opposite audiences: the first is for an operator, the second is for a client who can act on it.
The non-2xx branch is the part that earns the buffer. A 503 from the upstream the handler depends on, a 404 for a report that does not exist, a panic turned into a 500: none of those produce a charge, because none of them produced a response. The cost of that guarantee is real and worth saying plainly. A buffered response is a response held in memory, and you cannot stream a paid body while also promising it was never delivered unsettled. Buffering is choosing the promise over the streaming. For priced API responses that is the right trade; for large downloads it is not, and that boundary is a design decision rather than an implementation detail.
The log line matters too. Settlement failures are the events an operator most needs to see, because they are the only ones where the work was done and the money did not arrive. Everything else you can read off the status code distribution.
Where the rule bends, and why
There is one place where "charge nothing for failed work" does not fully hold, and pretending otherwise would be worse than admitting it. The replay key for a payment is recorded after verification passes, and once recorded the payment stays spent even if settlement then fails:
Recording at first sight would burn a payload the facilitator rejected, because a client that fixes its allowance will legitimately resend the same signed payment. But once settlement has been attempted, that attempt may already have reached the chain. The server does not know. The ambiguous case is resolved against the possibility of a double charge, which means the client gets a 502 and the old payload is refused if it comes back.
So a client that hits a settlement outage has to produce a fresh payment instead of retrying the stale one. That is friction, and it is deliberate: the alternative is a path where the same authorisation can be settled twice because a timeout looked like a failure.
The same "whose failure is it" question decides a smaller case. When receipts are enabled and a receipt fails to sign, the failure is logged and the paid response still goes out. The money moved, the service was rendered, and withholding the body would punish the client for a problem with the server's signing key.
Free requests follow the same logic
The free tier is spent before the X-PAYMENT header is even read:
if client, left, granted := g.Free.take(r); granted {
g.serveFree(w, r, next, client, left)
return
}A client with free requests remaining is not charged, even if it sent a payment anyway: the payment is left unspent rather than taken. And serveFree calls Allowance.give when the handler returns a non-2xx, giving the free request back. Work that was not delivered is not billed, whether the unit of billing is a stablecoin transfer or a slot in a courtesy allowance. A route that the price table does not mention is served unpaid for the same kind of reason: the table is the whole statement of what is sold, so a health check needs no line saying it costs nothing.
Testing the refusals, not just the success
The distinction is only real if there is a test that fails when someone collapses it. Two fakes exist for that: StaticFacilitator, which answers from a fixed script and records its calls, and MockFacilitator, which keeps balances in memory and exposes VerifyErr and SettleErr to fail the way an unreachable facilitator does.
func TestFacilitatorOutageIsNotTheClientsProblem(t *testing.T) {
facilitator := &StaticFacilitator{VerifyErr: errors.New("dial tcp: connection refused")}
g := &Gate{Accepts: accepts, Facilitator: facilitator}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "https://api.example.com/report", nil)
r.Header.Set(PaymentHeader, encoded)
g.Handler(paid).ServeHTTP(w, r)
if w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502 when the facilitator cannot be reached", w.Code)
}
if facilitator.Trail() != "verify" {
t.Errorf("calls = %q: settlement ran after verification could not be asked", facilitator.Trail())
}
}The Trail assertion is the one I would not drop. A status code test tells you the client was answered correctly. The call trail tells you the gate did not quietly attempt to move money on a question it never got an answer to.
What I would do differently
The two unavailability responses are prose: "payment verification is unavailable" and "payment settlement is unavailable". For a human operator reading logs that is fine. For the agent clients this protocol is aimed at, it means string matching to tell a transient server fault from a permanent one, and I would rather hand them a small machine-readable body with a stable code. Related: x402 defines a shape for "you must pay" and no shape at all for "come back shortly", so a 502 from this gate carries no retry guidance beyond what HTTP already gives you. A Retry-After on the facilitator-outage path costs nothing and is the only hint a client can act on without guessing.
I would also make the buffer's ceiling explicit instead of leaving it implicit in the host's memory, and I would surface the settlement ambiguity in the response rather than only in the log. A client that is told "this payment is spent, sign a new one" can act correctly, while a client that is told only "unavailable" will reasonably retry the payload that will now be refused.
None of this changes the rule the gate is built around. A 402 is a bill, and a bill is only honest when the service was delivered and the payment was actually judged and declined. Everything else the server has to own in its own status code.