Loading
Please wait while your experience is prepared...
Please wait while your experience is prepared...
backend / Jun 18, 2026 / 12 min
ach debits take days to clear, so a subscription can't wait for settlement. provision on payment_intent.processing, with a grace period for later failures.
I inherited an ACH payments feature that had been built and then left disabled. The business need was bank debits for a premium subscription tier billed at over $5,000 per month, where a 2.9% card fee is real money and customers prefer ACH anyway. The previous developer had wired most of the Stripe integration and commented the ACH path out, and nobody could confirm whether it was production-ready. The first day was an audit: which code paths were live, which were scaffolding, and which assumptions about the payment lifecycle were wrong.
The wrong assumption was the important one. The dormant code treated ACH like a card: confirm the payment, wait for success, grant access. That works for cards because confirmation and settlement happen in the same second. For ACH it produces a subscription that a customer has paid for but cannot use for the better part of a week. Re-enabling the feature meant rebuilding the provisioning logic around the part of the payment lifecycle that ACH actually has and cards do not: a multi-day gap between submitting a debit and knowing whether it cleared.
Stripe's us_bank_account payment method is ACH Direct Debit. When you confirm a PaymentIntent against a card, it moves to succeeded almost immediately, and a webhook handler can safely treat payment_intent.succeeded as the moment to grant access. ACH does not work that way. Confirming an ACH debit moves the PaymentIntent into the processing state, which means the debit has been submitted to the customer's bank but has not cleared. Settlement takes two to five business days. Only then does the PaymentIntent transition to succeeded, or to a failed state if the debit is returned.
This single difference cascades through the whole subscription lifecycle. The first invoice's payment does not settle at checkout time, so any logic that assumed money had arrived by the time the customer left the checkout page is wrong by days. Renewal payments fail days after the renewal date rather than on it. A failed payment is not a card decline you can surface in the checkout UI; it is an asynchronous event that arrives long after the session ended. Every piece of logic that assumed payment confirmation was synchronous had to be re-examined against a timeline measured in days rather than milliseconds.
The core decision was to provision access optimistically when the PaymentIntent enters processing, rather than waiting for succeeded. ACH has a high eventual success rate, and for a high-value managed tier, making a paying customer wait five days for access is a worse outcome than the rare case of provisioning someone whose debit later fails. The failure becomes a recovery problem handled after the fact, not a gate on access.
Two things happen when the processing event arrives. First, the account's online flags are flipped directly in the database, because access here is gated by the application's own subscription state, not by Stripe's view of whether the invoice is paid. Second, and this is the part that matters, the handler forwards a synthesized invoice.payment_succeeded event into the existing subscription handler so the account gets fully provisioned (plan, token allotment, editing credits) at the earliest possible signal:
// stripe-webhook gateway: payment_intent.processing branch
if (event.type === "payment_intent.processing") {
const invoiceId =
invoice?.id ??
(typeof subscription?.latest_invoice === "string"
? subscription.latest_invoice
: subscription?.latest_invoice?.id);
if (invoiceId) {
const fullInvoice = await stripe.invoices.retrieve(invoiceId);
// reuse the real provisioning path by handing it a real invoice object
await routeToHandler(
{ type: "invoice.payment_succeeded", data: { object: fullInvoice } },
organizationId ? "organization" : "individual"
);
}
}Synthesizing the event rather than writing a parallel provisioning path is deliberate. The subscription handler already knows how to read product metadata, grant the annual allotment (594,000 tokens and 400 editing credits, in this case), and write the balance transactions. Forwarding a real retrieved invoice object lets ACH reuse that exact code rather than duplicating it. This forwarding happens only on processing, the ACH-specific early signal. On succeeded, and for cards which never emit a processing event, the real invoice.payment_succeeded already does the provisioning, so forwarding there would be a redundant second path racing the real event.
ACH is enabled only on the premium checkout flow. Everything else (the standard tier, all one-time add-ons, and premium customers who paid by card) must never touch the ACH handler. A production issue earlier had been ACH leaking into checkout flows it did not belong in, traced to Stripe payment method configuration inheritance at the account level, so the invariant matters and is enforced as defense in depth on the webhook side too.
The pre-filter is a cheap check that runs before the expensive subscription lookup:
// ACH (us_bank_account) is enabled only for premium checkouts, so a card-only
// PaymentIntent never needs this handler. This guard can only drop events that
// don't need handling: payment_intent.processing fires only for async methods,
// and a real ACH charge always lists us_bank_account in payment_method_types.
const isAchPayment =
event.type === "payment_intent.processing" ||
(Array.isArray(pi.payment_method_types) &&
pi.payment_method_types.includes("us_bank_account"));
if (!isAchPayment) {
return { processed: "payment_intent_skipped_not_ach", type: event.type };
}Identifying which payments are premium has its own wrinkle. At payment_intent time the database is_concierge flag is not reliable, because for an ACH signup that flag only gets set later, at settlement, by the very provisioning the processing event is trying to trigger. The fix was to stamp the tier into the subscription metadata at checkout (plan_type of concierge or teams_concierge) so the payment is recognized from the event payload itself, with a database lookup only as a fallback. The handler resolves the tier from metadata first and never depends on a flag that may not exist yet.
The failure case is where the design earns its complexity. An ACH debit can fail days after submission, most commonly for insufficient funds. The business rule, confirmed with stakeholders, was that a failed payment must never immediately suspend a premium customer. Instead the account enters a two-month grace window during which it keeps full access while the operations team handles recovery manually. On failure the handler emails ops and writes a grace marker, and suspends nothing.
The subtle bug is in how a retry interacts with grace. When a customer retries a failed invoice, the retry fires payment_intent.processing first, which is the keep-online path. That path flips the subscription status back to active and clears the grace_expiry column. So if the retry then also fails, the obvious signals (status and grace_expiry) no longer tell you the account was already in grace, and a naive handler would start a fresh two-month clock on every retry. The grace period would never actually expire.
The fix is to persist the deadline somewhere the keep-online path does not touch, and key idempotency on the specific failed invoice:
const failedId = graceInvoiceId ?? null;
const meta = sub.renewal_metadata ?? {};
// Same invoice already graced? Reuse its stored deadline. Never extend it.
const alreadyGracedForInvoice =
failedId !== null && meta.grace_invoice_id === failedId;
const graceExpiryIso =
alreadyGracedForInvoice && typeof meta.grace_until === "string"
? meta.grace_until
: addDays(now(), 60).toISOString();
await supabase
.from("user_subscriptions")
.update({
subscription_status: "past_due",
grace_expiry: graceExpiryIso,
subscription_expiry: graceExpiryIso, // keep the date-based access query returning this row
renewal_metadata: {
...meta,
grace_invoice_id: failedId,
grace_until: graceExpiryIso,
},
})
.eq("id", sub.id);Two details make this correct. The deadline lives in renewal_metadata.grace_until, which the keep-online reactivation path leaves alone, so it survives the status flip that clears the grace_expiry column. And grace is keyed on grace_invoice_id: a second failure of the same invoice reuses the stored deadline and never extends it, while a genuinely new invoice in the next billing cycle has no matching marker and correctly starts a fresh grace window. Setting subscription_expiry equal to the grace deadline is what keeps the account online during grace, because access is a date-based query against that column.
No webhook event ever suspends a premium account. The failure handler only ever opens a grace window. Closing it is the job of a single scheduled function that runs daily, and it acts only on accounts whose grace deadline has actually passed. Splitting the decision this way means suspension is never tangled up in the timing of asynchronous Stripe events, which made the whole thing far easier to reason about during testing.
There are two separate crons because the two populations have opposite rules. The standard expiry sweep explicitly excludes premium accounts, loading their ids and skipping them, because they must never be auto-suspended on a payment issue. A second function owns premium grace teardown: it queries subscriptions that are past_due with a grace_expiry in the past, zeroes the resource pool, revokes any per-member allocations for team accounts, and marks the account torn down.
The teardown deliberately stops at the database boundary:
// grace-teardown cron: act only on genuinely expired grace
const { data: expired } = await supabase
.from("organization_subscriptions")
.select("id, organization_id, grace_expiry, stripe_subscription_id")
.eq("subscription_status", "past_due")
.not("grace_expiry", "is", null)
.lt("grace_expiry", now.toISOString());
for (const sub of expired ?? []) {
await zeroResourcePool(sub.organization_id); // tokens + credits to 0, revoke allocations
await markToreDown(sub.id);
await emailOpsToCancelInStripe(sub); // the Stripe subscription is NOT cancelled here
}The cron tears down only the application side. The Stripe subscription is left intact, because dunning is configured to leave it past_due rather than cancel, and the email to ops is the signal that a human still needs to cancel it manually in Stripe. The irreversible billing action stays with a person, never the cron. This is the same principle as the failure path: the system automates everything reversible and stops short of the one step that is not.
Optimistic provisioning creates a duplication hazard. The synthesized invoice.payment_succeeded fires when ACH enters processing, and Stripe delivers the real invoice.payment_succeeded days later at settlement. Without protection, the customer would receive the full annual allotment twice. Both events reference the same invoice, so the provisioning handler is made idempotent on the invoice id: the first event grants the allotment and records the invoice, and the settlement-time event finds it already provisioned and no-ops.
That same idempotency absorbs Stripe's delivery guarantees. Stripe delivers webhooks at least once, not exactly once, and will redeliver on any non-2xx response or timeout. A handler that mutates entitlement therefore cannot key on the arrival of an event; it has to key on something stable in the payload. Keying on the invoice id means the synthesized event, the real settlement event, and any redelivery of either all collapse to a single grant. The annual allotment is granted once per billing term regardless of how many times an equivalent event is delivered.
None of the grace behavior can be validated on a normal development timeline, because the events that matter are days apart. Stripe Test Clocks solve this. You bind a test customer and subscription to a simulated clock and advance time programmatically, fast-forwarding through the ACH settlement window, the grace period, and renewal cycles in seconds rather than weeks.
The scenarios I ran through Test Clocks covered the full lifecycle for both individual and team accounts: a debit entering processing and provisioning at day zero, the clock advanced to settlement with a forced success to confirm no double allotment, then a separate run with a forced failure to verify grace entry kept the account online and emailed ops. From there I advanced the clock past the 60-day deadline to confirm the teardown cron fired exactly once and left the Stripe subscription intact, and tested a retry mid-grace to confirm the clock did not reset. The bugs in delayed-settlement billing hide in the gaps between events, and Test Clocks are the only practical way to compress those gaps enough to exercise them deliberately.
Make the grace window configurable per tier rather than a hardcoded 60 days. The two-month window was the right call for the highest-value accounts, but a team tier and an individual tier do not necessarily warrant the same recovery runway. Moving the deadline derivation onto a per-plan setting would let the business tune recovery aggressiveness without a code change, and would make the cron's behavior depend on data rather than a constant living inside a handler.
Add a reconciliation job for stuck provisioning. Optimistic provisioning assumes the settlement-time invoice.payment_succeeded eventually arrives to confirm the money landed. If that event is ever dropped, an account stays provisioned while never having truly settled, and nothing notices. A daily reconciliation that asks Stripe for the actual PaymentIntent status of any account provisioned past the normal settlement window would catch a missed settlement before it becomes a silent revenue discrepancy, the same way a watchdog catches a background job that stopped reporting.
Track the grace outcome, not just the grace state. The system knows an account is in grace and knows when it exits, but it does not record why: whether ops recovered the payment, whether the customer fixed their bank account, or whether it expired into teardown. Capturing that outcome would turn the grace period from an operational stopgap into a dataset about why high-value ACH payments fail, which is exactly the kind of signal worth having before the next pricing change.
why provision access on payment_intent.processing instead of payment_intent.succeeded for ACH?
ACH debits settle in two to five business days, not instantly. If you wait for payment_intent.succeeded, a customer who just paid waits days before getting access to what they bought, which is unacceptable for a high-value subscription. ACH has a high eventual success rate, so the practical pattern is optimistic provisioning: grant access the moment the PaymentIntent enters the processing state, then treat the rare payment_intent.payment_failed that arrives days later as a recovery case rather than a denial. The risk you accept is a short window where a customer who will ultimately fail their debit has access, which for high-touch accounts is preferable to making every paying customer wait for settlement.
what is the difference between payment_intent.processing and payment_intent.succeeded?
For instant payment methods like cards, a confirmed PaymentIntent moves straight to succeeded. For delayed-notification methods like ACH Direct Debit (us_bank_account), confirmation moves the PaymentIntent to processing, meaning the debit has been submitted to the bank but has not cleared. The PaymentIntent then transitions to succeeded when the funds settle, typically two to five business days later, or to a failed state if the debit is returned for insufficient funds or a closed account. Your webhook handler has to treat processing as the earliest provisioning signal and succeeded as confirmation, rather than treating succeeded as the moment access begins.
how do you stop Stripe from automatically canceling a subscription when an ACH payment fails?
Configure Stripe's dunning so an exhausted retry sequence leaves the subscription in past_due rather than canceling or marking it unpaid. The subscription stays past_due and Stripe stops retrying, but it never tears anything down on its own. Access during the grace window is driven by your own database state, and a separate scheduled job is the only thing that revokes access once the grace deadline passes. Critically, that teardown job only zeroes the account in your own database and emails the operations team; it does not cancel the Stripe subscription. A human cancels the Stripe side manually, so the irreversible billing action is never automated.
how do you avoid provisioning a subscription twice when both a synthesized and a real invoice event fire?
Optimistic provisioning forwards a synthesized invoice.payment_succeeded event the moment ACH enters processing, and Stripe also delivers the real invoice.payment_succeeded days later at settlement. Both carry the same invoice id. The provisioning handler is made idempotent on that invoice id: the first event grants the allotment, and the settlement-time event plus any webhook redelivery become no-ops because that invoice has already been provisioned. Stripe delivers webhooks at least once rather than exactly once, so any handler that grants tokens or credits has to be idempotent on something stable like the invoice id, never on the fact that an event arrived.
how do you test ACH settlement timelines without waiting days for real payments?
Stripe Test Clocks let you bind a test customer and subscription to a simulated clock, then advance time programmatically to fast-forward through the ACH settlement window, the grace period, and renewal cycles. You can simulate a debit entering processing, advance the clock several days, and trigger either a success or a failure to verify that provisioning, grace entry, and the teardown cron all fire correctly. Test Clocks are essential here because the bugs in delayed-settlement billing live in the gaps between events that are days apart in production, and there is no other practical way to exercise a two-month grace period end to end during development.
related