← Blog
November 4, 2026 · 12 min

When an x402 Gate Cannot Run: Fail-Open Hooks Audited

A gate is a check that runs before money moves. If the check throws, what should happen? In the TypeScript x402 resource server on main today, the payment goes through. The exception is logged and the loop moves on.

This is not a hypothetical. The Go and Python servers, the TypeScript client and the TypeScript facilitator all stop in the same situation. One component fails open, and it happens to be the one most sellers run.

We read the code on x402-foundation/x402 main (58e667ff, 3 November 2026), reproduced the behaviour with the real batch-settlement scheme and a failing store, and ran both open fixes against the same test. The fixes agree on the core rule and disagree on two details that matter to a buyer.

The rule that is already written down

Resource-server hooks have six phases that matter here. beforeVerify and beforeSettle can return { abort: true, reason } to stop the payment, or { skip: true, result } to answer locally. afterVerify, afterSettle and the two on*Failure phases observe.

The first two are gates. An extension that checks a signature, a sanctions list, a request commitment or a channel balance sits there. It can fail in two ways: return an abort, or throw.

On main, only the first works. In x402ResourceServer.ts the beforeVerify loop wraps each hook in try/catch, and the catch calls warnResourceServerHookFailure and continues. The beforeSettle loop does the same, except it rethrows a SettleError. Everything else is swallowed. The next line of code is the facilitator call.

Issue #3689 was opened on 4 October 2026 by an author building a Cardano request-commitment extension. Their extension wraps its whole check in try/catch and converts any error to an abort, "but every extension with a gate has to remember to do that, and nothing in the hook types signals it."

Who else fails open

A commenter on the issue listed how the other components behave. We checked each against main.

So the same SDK has a facilitator that treats a throwing gate as a stop and a resource server that treats it as a pass. Both are ours to read, and they disagree.

Reproduction with the real scheme

An extension author could be told to catch their own errors. Scheme authors are in the same position, and they ship in the same repo. The EVM batch-settlement server scheme registers onBeforeVerify and onBeforeSettle hooks. So does the SVM one.

Its handleBeforeVerify already converts storage failures into an abort. Its handleBeforeSettle in settle.ts calls storage.updateChannel(...) with no try/catch around it. The SVM scheme's beforeSettle has the same shape: await this.store.get(...) sits outside the try that wraps the charge commit. The EVM store is a ChannelStorage interface, and the repo ships Redis and file implementations of it, so a network failure there is an ordinary event.

So we built the smallest test that exercises this. A real x402ResourceServer, the real BatchSettlementEvmScheme, an in-memory store that throws on demand, and a stub facilitator that counts calls. Three cases:

We ran it on main and on the heads of the two open fixes, rebuilding @x402/core each time (the EVM package resolves core from its built output, so skipping the rebuild reports main three times).

// facilitator calls = how many times the stub facilitator was reached
main 58e667ff
  A  settle  -> success:true,  facilitator.settle calls: 1
  B  verify  -> isValid:false verification_state_unavailable, calls: 0
  C  verify  -> isValid:true,  calls: 1
  C  settle  -> success:true,  calls: 1

PR #3702 head 55147fc8
  A  settle  -> SettleError extension_hook_error "extension hook failed", calls: 0
  B  same as main (the scheme already aborts)
  C  verify  -> isValid:false extension_hook_error, calls: 0
  C  settle  -> SettleError extension_hook_error, calls: 0

PR #4109 head d342db01
  A  settle  -> SettleError extension_hook_error "ECONNREFUSED redis:6379 (update)", calls: 0
  B  same as main
  C  verify  -> isValid:false extension_hook_error "sanctions-list fetch timed out", calls: 0
  C  settle  -> SettleError extension_hook_error, calls: 0

Case C is the clean demonstration: on main a hook that could not complete its check returns a paid verification and a paid settlement. Case B shows the intended pattern, an abort with a reason. Case A shows the same scheme doing the right thing in one phase and not in the other.

What case A does and does not prove. On main, with the store down, the voucher payload reaches facilitator.settle. Our stub answered success. The real EVM batch facilitator does not: its settle dispatches on deposit, claim, refund and settle payloads and returns invalid_payload_type for anything else, including a bare voucher. So here the fail-open path is caught by an unrelated check downstream, the reservation never commits, and the buyer gets an opaque error instead of the cause. That is luck, not design. A KYC or sanctions extension has no such backstop.

Two fixes, two disagreements

Two PRs are open against #3689, and both say they close it.

PR #3702 was opened on 5 October. PR #4109 was opened on 2 November. Both do what the issue's option 1 describes: a throw in beforeVerify returns isValid: false with reason extension_hook_error, a throw in beforeSettle raises a SettleError(400) with the same reason, the facilitator is not called, and the warning log stays. Advisory phases keep logging and continuing. Both keep explicit aborts as they are.

What the payer is told

#3702 returns a fixed string: "extension hook failed". Its code comment gives the reason: "The thrown message can carry an RPC URL, path, or storage error; the warning log already has the detail."

#4109 forwards error.message. In our case A run, the payer receives ECONNREFUSED redis:6379 (update), which is the seller's internal store address, inside a response that goes to an anonymous buyer. In case C it receives the sanctions-list error text.

We would take the fixed string. Operators already have the log line. Buyers should get a code they can act on and nothing about the seller's infrastructure.

Which version of the code base it targets

The branch behind #3702 predates parts of current main. A diff against main shows unrelated noise, such as AfterSettleAbortError appearing as removed, because the branch has not seen it. The relevant hunk is small and clean, but it needs a rebase before merge. #4109 is closer to main and keeps that machinery.

The third outcome

The thread settled on a useful frame. A gate has three outcomes, not two: passed, failed (abort with the gate's own reason), and could not run. Collapsing the third into either of the others produces this bug, or its mirror image, which one commenter reported from their own production: a firewall service that answered 200 with verdict: REJECT for a transaction it never simulated, because its RPC was rate-limited. A gate that could not complete had reported a verdict on the transaction.

The fix names the third outcome with its own reason, extension_hook_error, kept apart from invalid-signature and invalid-payment codes. That lets a buyer tell "your authorization is bad" (stop) from "the seller's gate failed" (retry later).

The transport still blurs it. In x402HTTPResourceServer.ts, a verify with isValid: false is turned into a payment-required response through createPaymentRequiredResponse(..., verifyResult.invalidReason, ...). A SettleError goes through buildSettlementFailureResponse, whose status is a hard-coded 402. So a gate that could not run reaches the buyer as HTTP 402 in both phases. The distinguishing signal is the reason string only.

That matters because of what buyers do with a 402. The x402 fetch client, as we covered in the double-sign audit, throws "Payment already attempted" when a request that already carries a payment header gets a 402 back. A retryable seller outage and a rejected authorization arrive in the same shape, and the client has no branch for the reason. A 503 for infrastructure-class gate errors would be a more honest transport. Neither PR does that, and the thread keeps it out of scope.

Where this leaves the settle phase

The settle gate has a harder problem than the verify gate. In the after-handler flow the handler has already run when beforeSettle executes. Failing closed there means the seller did the work and does not get paid. That is the correct trade for a check that could not confirm it was allowed to take the money, but it is a cost, and it argues for doing risky checks in beforeVerify, before the handler.

On main there is already a second fail-closed mechanism for the opposite moment. AfterSettleAbortError lets an afterSettle hook turn an on-chain success into success: false while keeping the transaction hash, payer and amount. Its doc says the use case is a voucher store failure after funds moved. Any other throw in afterSettle is logged and ignored. So the SDK now has three failure treatments across the lifecycle:

The asymmetry is deliberate, and sensible once written down. It was not written down in the hook types, which is what #3689 asked for.

What is still open

Three items are outside both PRs.

What it means for LLM4Agents

We are a seller. Our gateway quotes a price, verifies a payment and settles it, and any extension we add sits in these two phases. If we run the TypeScript resource server with a gate that can fail on network I/O (a price lookup, an allow-list, a spend ledger), a transient error today would let an unchecked payment through.

The threat is bounded. Verification and settlement still run, so no money is created or lost on the chain. What fails open is policy: the check we added to refuse some payments does not refuse them while its dependency is down. For a gateway whose pitch includes spend controls and agent identity, that is the worst moment for a policy to lapse silently.

The buyer side is the other half. Our agents see a 402 in both outcomes. If we want them to retry a gate failure and not retry a bad signature, the reason string is the only signal, and the SDK does not branch on it.

Staying on the frontier

  1. Wrap our own gates today. Every gate we register should catch its own errors and return an explicit abort with a retryable reason. This works on current main and keeps working after either PR merges.
  2. Add the parity test to our harness. A throwing gate on verify and on settle must leave the facilitator call count at zero. Run it on every SDK bump. Case C above is the template.
  3. Prefer risky checks in beforeVerify. A failed gate there costs nothing. A failed beforeSettle gate costs a finished handler.
  4. Never echo exception text to payers. If a fix we adopt forwards error.message, override it with a fixed string at our edge. Comment on #4109 in that direction.
  5. Teach our client examples the reason. Treat extension_hook_error as retry-with-backoff and invalid-payment reasons as terminal. Combine it with the hold-and-reconcile rule so a retry does not sign twice.
  6. Add a deadline to every gate we own. A timer that resolves to an abort with the same retryable reason, so a hung dependency cannot hold a request open or let it through.
  7. Watch #3689, #3702 and #4109. Two PRs for one issue means a maintainer decision is pending. The outcome on error text and on a status code other than 402 will shape what buyers can safely automate. Our earlier look at the recovery extension and at seller middleware shows where else this hook surface is used.

Build agents on gates that fail closed

Register an agent on LLM4Agents and pay per request in stablecoins over an OpenAI-compatible gateway.

Register agent