RetryFi, a dunning engine owned end to end
Role: Solo founder — full stack + infra + GTM · 2026 — present
RetryFi is a failed-payment recovery service for Stripe businesses. I built it alone: architecture, dunning engine, dashboard, OAuth production verification, and launch. This case study covers the design decisions, the numbers behind them, and what I would change.
Index terms: Stripe Connect, dunning, Inngest, Supabase, test-driven development
Context
RetryFi recovers failed Stripe payments for bootstrapped SaaS businesses. A card fails, the engine classifies the decline, retries the invoice on a schedule when a retry can plausibly work, and runs a four-email dunning sequence when it cannot. I own all of it: product, architecture, backend, frontend, infrastructure, and the go-to-market.
The product is deliberately small. Free covers 10 recoveries a month, Pro is $29 for 50, Scale is $79 for 200. An earlier, richer plan (SMS, A/B tests, team seats) still sits commented out in the constants file. Shipping meant cutting it.
Problem & constraints
Involuntary churn is recoverable revenue, but recovering it means operating on other people's businesses. Every retry is a charge attempt on a stranger's customer, made through the merchant's own Stripe account via OAuth. Stripe gates its read-only OAuth scope, so the grant is read_write; I compensate with a restricted platform key, envelope-encrypted tokens with versioned rotation, and a standing rule in the code that marketing copy must never say "read-only" while that is true.
Three more constraints shaped the design. Retry timers span days and must survive deploys on serverless infrastructure that keeps no process alive. Dunning emails go out under the merchant's name, so deliverability and unsubscribe compliance are the merchant's reputation, not just mine. And the same failed invoice can arrive twice, once from the live webhook and once from the reconciliation scan, so anything not idempotent is a money bug.
Architecture & decisions
Durable functions instead of a scheduler. The whole retry and dunning state machine runs on Inngest: multi-day waits are step.sleep, each invoice is serialized with a per-invoice concurrency limit of one, and a payment/recovered event cancels the sequence wherever it is. The trade-off is a vendor in the middle of the core loop. In exchange there are no scheduler tables, no cron sweeps moving state, and replays come free.
Assume every event fires twice. Idempotency is layered: a unique index on the Stripe event id, a second dedupe on the invoice id, an idempotency key on every retry (retry-{invoice}-{attempt}) and on every email send, and a re-entrancy guard that stops the webhook path and the four-hourly reconcile scan from claiming the same invoice. The layers cost code and their own test suites. The failure mode they prevent is charging or emailing someone twice, so they stay.
Hardcoded schedules, classified declines. Soft declines earn four retries at +4h, +72h, +96h, and +168h; hard declines (stolen card, expired card, fraud) skip retries and go straight to email; unknown decline codes get the benefit of the doubt and count as soft. The schedule lives in one constants file, not in per-merchant config. A settings surface would demo well, but no merchant has yet needed one, and constants are testable at a glance.
A circuit breaker before anyone asked for one. Retries halt per merchant at 10 attempts in 60 minutes with an 80% failure rate, and globally at 50 attempts in 15 minutes at 50%, with a 30-minute cooldown and a five-probe half-open recovery whose accounting runs in an atomic Postgres function. This is complexity no incident had yet demanded. I built it anyway, because a retry storm spends the merchant's customer goodwill and Stripe standing, not mine.
| Guardrail | Value |
|---|---|
| Per-merchant breaker | 10 attempts / 60 min at 80% failure |
| Global breaker | 50 attempts / 15 min at 50% failure |
| Email frequency cap | one per recipient per 48 h |
| Marked lost | 72 h after the final email |
| Historical scan | 90 days back, 200 invoices per scan |
What shipped
RetryFi is live at retryfi.com; it launched on Product Hunt in June 2026. Merchants get a dashboard with a six-month recovery chart and a full activity log, live previews of the exact four emails their customers receive (with a rate-limited test send), one-click unsubscribe with a 30-day undo, and a free audit that connects a Stripe account, scans it in memory, stores only aggregates, and deauthorizes itself immediately.
The repo carries 71 test files, including suites that assert on raw migration SQL and a battery of concurrency tests for the double-fire cases. CI gates lint, build, and the full suite before anything reaches the production branch.
A DUNNING_TEST_MODE flag compresses every multi-day delay to seconds so the end-to-end scripts can watch a whole recovery in under a minute. The production boot refuses to start if the flag is set.
What I'd do differently
I ran localhost and production against a single Supabase project to ship faster. It saved a day of setup and it is the decision that ages worst: every seeded row lives next to real data, and every experiment is a production experiment. The same shortcut shows elsewhere, a CSP still in report-only mode and a key-rotation runbook whose re-encrypt script does not exist yet. The test discipline held; the environment discipline lagged, and separating those projects is the first item on the next milestone.
Stack: Next.js, Supabase, Inngest, Stripe Connect, Resend, Vercelretryfi.com