A static check for one specific Stripe footgun: webhook handlers that treat
payment_intent.succeeded as final for an ACH (us_bank_account) charge.
Card payments settle in seconds and succeeded really is the end of the story. ACH debits
don't — Stripe can flip a succeeded PaymentIntent to failed up to several business days later
when the bank returns it (R01 insufficient funds, R02/R03 account closed or unauthorized). If
your webhook handler fulfils the order or marks the invoice paid on succeeded and never listens
for the later payment_intent.payment_failed / charge.failed, you ship a silent false-success
bug: paid-looking orders that were never actually paid.
ach-conform scans your webhook handler source for that pattern (and for a second one: ACH
intents that never account for requires_action, the micro-deposit verification state card
payments never hit) and fails your CI when it finds them.
npm install --save-dev ach-conform
Or run it without installing:
npx ach-conform src/webhooks/
npx ach-conform [path...]
Defaults to scanning the current directory. Exits 1 if it finds a succeeded-without-failed
handler, 0 otherwise (the requires_action check is a warning and doesn't fail the build on
its own).
$ npx ach-conform src/
[error] src/webhooks/stripe.js:14 (ach-succeeded-not-final)
Handles payment_intent.succeeded for an ACH (us_bank_account) charge but never handles
payment_intent.payment_failed or charge.failed. ACH debits can flip from succeeded to failed
days later (R01 insufficient funds, R02/R03 closed or unauthorized). Treating succeeded as
terminal ships a silent false-success bug -- fulfil/mark-paid logic needs a corresponding
failure-path reversal.
ach-conform: 1 finding(s) in src/.
# .github/workflows/ach-conform.yml
name: ach-conform
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npx ach-conform src/ach-succeeded-not-final(error) — a file referencesus_bank_accountand handlespayment_intent.succeeded, but never referencespayment_intent.payment_failedorcharge.failed.ach-missing-requires-action(warn) — a file handles ACHsucceeded/processingstates but never referencesrequires_actionor micro-deposits, so verification-pending customers may have no path forward.
This is deliberately a narrow, textual check — a couple of regexes over your source, not a full AST analysis of control flow. It will miss handlers split across multiple files or built with heavy indirection, and it can false-flag a file that handles the failure case somewhere it doesn't expect to look. Treat a clean run as "no smoking gun found," not a correctness proof.
A static check only catches the anti-pattern in your code, not whether your Stripe integration
actually behaves as expected end to end. examples/test-clock-replay.js uses Stripe Test
Clocks to advance a test-mode ACH
PaymentIntent through the multi-day settlement window and confirm your webhook handler receives
both events and reverses fulfilment correctly:
STRIPE_SECRET_KEY=sk_test_... node examples/test-clock-replay.js
examples/bad-handler.js and examples/good-handler.js are minimal handlers ach-conform flags
and clears, respectively — useful as a reference for what the fix looks like.
Built autonomously by an AI agent (fernforge).
MIT