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.
- Go server: the
beforeVerifyHooksandbeforeSettleHooksloops ingo/server.godoif err != nil { return nil, err }. The facilitator is not called. - Python server: in
verify_payment, an exception raised outside the facilitator-call phase is re-raised. Hook exceptions propagate. - TypeScript facilitator: a changeset on main,
isolate-facilitator-hook-errors, isolates the advisory hooks and states thatbeforeVerifyandbeforeSettle"still fail closed". The source comment says it directly: a gate is "deliberately not isolated". - TypeScript resource server: logs and continues.
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:
- A. Voucher payment, store throws in
updateChannelduringsettlePayment. - B. Same payload, store throws in
getandupdateChannelduringverifyPayment. - C. Control: a plain extension whose
onBeforeVerifyandonBeforeSettleboth throw "sanctions-list fetch timed out".
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.
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:
- Before money moves: a throw should stop the payment. Today it does not on the resource server.
- After money moves: a throw is logged, unless it is the explicit abort type, which flags a settled payment as failed with its receipt attached.
- Advisory phases: log and continue.
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.
- Timeouts. A gate that never returns is a gate that could not run. The thread flags that a timed-out gate should land on the abort side. We found no timeout handling around hooks in
x402ResourceServer.tson main, so a hung extension holds the request open until the transport times out. - Scheme consistency. Case A shows a built-in scheme that converts store errors to aborts in one phase and throws in the other. With the core fix this is covered; without it, each scheme has to wrap every storage call. Wrapping is cheap and removes the dependency on core.
- Parity tests. The thread suggests a per-SDK test: register a throwing
onBeforeVerifyandonBeforeSettle, assert the facilitator is never called. It would have caught this gap, and it catches drift between Go, Python and TypeScript later.
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
- 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.
- 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.
- Prefer risky checks in
beforeVerify. A failed gate there costs nothing. A failedbeforeSettlegate costs a finished handler. - 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. - Teach our client examples the reason. Treat
extension_hook_erroras retry-with-backoff and invalid-payment reasons as terminal. Combine it with the hold-and-reconcile rule so a retry does not sign twice. - 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.
- 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