Ivan Balias

83 posts

Ivan Balias banner
Ivan Balias

Ivan Balias

@IvanBalias

Help Stripe SaaS recover revenue lost to failed payments — smart retries + dunning, pay only on recovery. Building Lirova solo · NestJS/Prisma · pre-launch

Katılım Ocak 2025
21 Takip Edilen8 Takipçiler
Sabitlenmiş Tweet
Ivan Balias
Ivan Balias@IvanBalias·
SaaS founders obsess over cancellations. Meanwhile 20-40% of churn is involuntary - cards that expired, banks that declined. The customer never chose to leave. Their payment just failed and the subscription died. Expired cards alone cause 42% of failed payments. Stripe recovers ~35% of these by default. The other 65%? Gone. That's the leak I'm building Lirova to fix - smart retries + dunning that recover the rest, before they churn. Pre-launch, looking for early users on Stripe. Demo (sample data): lirova.app/en/demo #buildinpublic #SaaS
English
1
0
2
389
Ivan Balias
Ivan Balias@IvanBalias·
Building payment recovery means asking a founder for write access to their Stripe. That's a big ask. So the design question that mattered most: how do you take write and make it provably hard to misuse? What we landed on: - read-only by default. connecting does nothing to money until you explicitly turn on recovery. - the write-gate lives at the worker, not the UI. every retry checks account status before it can touch invoices.pay - a stray request or a UI bug can't move money. - deterministic idempotency key per retry (caseId + attempt). replay the same retry and Stripe returns the original result instead of charging again — a double-charge isn't possible. - fraud / lost / stolen declines: zero retries. retrying a hard fraud decline just flags you with the issuer. The interesting part: the write path ended up being the most boring, most gated code in the whole system. Which is exactly what you want when it's someone else's money. #buildinpublic #stripe
English
0
0
1
20
Ivan Balias
Ivan Balias@IvanBalias·
Got my first waitlist signup this week. I might have to tell them not to buy. The form says <$5k MRR. At that size, even in a bad month the money at stake from failed payments is small - and Stripe's built-in retries already claw back a good chunk of it on their own. So my reply was basically: "how many failed charges do you actually see? if it's a handful, don't pay me or anyone." Feels wrong turning away the first person who ever signed up. But recovery tools start making sense once you're at real subscription volume - below that, the leak is smaller than the noise. Selling into that anyway is how this niche got its reputation. First signup, possibly first "no". Building in public is weird. if you want to check your own number before deciding: lirova.app
English
0
0
1
98
Ivan Balias
Ivan Balias@IvanBalias·
Building Lirova in public. Dunning / failed-payment recovery for Stripe SaaS. Solo, pre-launch. Another surprise while building: past_due isn't a state. It's a moment. Stripe keeps retrying a failed subscription in the background for days. So a customer can go past_due > active on their own - no email, no action from you - while your app still thinks they're delinquent. Cache "past_due" as a boolean and gate access on it, and you get one of two bugs: - you lock out someone Stripe already recovered, or - you dun a customer who's mid-retry and about to pay. Both burn trust over a number your own DB got wrong. The fix isn't a smarter query. It's discipline: - the webhook is the ONLY writer of subscription status. not your UI, not a cron guess. - webhooks drop and arrive out of order - so you reconcile against Stripe on a schedule to catch what you missed. past_due doesn't mean "this customer failed." It means "Stripe is mid-conversation with their bank." Treat it like a live state, not a verdict. #buildinpublic #stripe
English
1
0
4
99
Ivan Balias
Ivan Balias@IvanBalias·
clean writeup. the UPSERT-on-webhook fix is the right call - the sibling gotcha that bites later is ordering: Stripe doesn't guarantee delivery sequence, so subscription.updated can arrive before .created. UPSERT saves the row but you can still write stale state over fresh. sorting by event.created closes it. solid stack btw.
English
0
0
0
2
Azeez Roheem
Azeez Roheem@aroheem·
Layer 5: Clerk + Stripe The integration that bit me: Stripe webhooks fire before Clerk syncs the user to your database. If you INSERT on webhook — silent failure when the user row doesn't exist yet. Fix: always UPSERT on webhooks. Assume the user row might not exist.
English
2
0
0
21
Ivan Balias
Ivan Balias@IvanBalias·
the "webhook silently drops a payment" one is the worst because it's invisible - no error, no complaint, just revenue that quietly stops. reconcile-against-Stripe-on-a-schedule catches those, but most vibe-coded builds skip it because nothing's visibly broken. month-two is exactly when the silent billing debt surfaces, like you said. spent the last year building specifically around this failure class - it's deeper than it looks.
English
0
0
0
14
Konstantin Klyagin
Konstantin Klyagin@thekonst1·
2/8 The pattern I kept seeing at Redwerk: a Cursor or Lovable build, a few hundred users signed up by week four, good energy. Then week six. A login breaks for one specific customer. A Stripe webhook silently drops a payment.
English
2
0
0
22
Konstantin Klyagin
Konstantin Klyagin@thekonst1·
1/8 For most of 2024 I watched founders ship MVPs in a weekend and genuinely believed the hard part was over once the dashboard held together past week three.
English
1
0
0
44
Ivan Balias
Ivan Balias@IvanBalias·
exactly right for subscription invoices - Stripe's retries carry it and you shouldn't hand-roll that. the gap only really opens at higher volume where the decline-mix and stop-logic start costing real money. for a clean solo setup like yours, reacting to the webhook is the whole game. good thread.
English
0
0
1
9
Dusko Licanin
Dusko Licanin@dulelicanin·
Exactly. I kept access on through past_due with a soft "update your card" banner instead of cutting it, since Stripe recovers a good chunk of those on its own within a couple days. Pulling access then handing it back reads worse than the banner ever does. UI just renders the stored status, never its own guess, and the hard cut waits for canceled or unpaid.
English
1
0
0
44
Dusko Licanin
Dusko Licanin@dulelicanin·
A subscription looked like the simplest model in the app. A boolean, basically: are they paying or not. Then a past-due customer kept full access for two weeks, and a trialing one got locked out of the exact thing the trial was meant to sell. Same root cause. Stripe moves a subscription through eight distinct states, and I'd flattened all of them into one column my code checked in a dozen places. The rebuild had nothing to do with webhooks, which I already had. The real work was deciding, per feature, which of those states should grant access, then making the webhook the only writer of that status. No more guessing up in the UI layer. Full breakdown of the lifecycle here: duskolicanin.com/blog/saas-subs…
Dusko Licanin tweet media
English
2
0
0
54
Ivan Balias
Ivan Balias@IvanBalias·
fair on sequencing - dunning is genuinely not day one. the trap is when you do add it: a plain retry loop double-charges on webhook redelivery, and retrying a stolen-card decline flags the account with the bank. so it's less "a feature to add later," more money-safety you can't bolt on casually.
English
0
0
0
23
Nicola Bovolato
Nicola Bovolato@nicolabovolato·
Stripe Checkout + one price is day one. Tiers, coupons, annual billing, usage metering, dunning, proration: not day one. You can always add tiers later. You can't get back the 6 weeks you spent on a pricing page.
English
2
0
0
15
Ivan Balias
Ivan Balias@IvanBalias·
@system_monarch the nastier version in payment retries: a 429 backoff fires a retry, and if your key is request-derived not operation-derived, that backoff-retry slips past dedup. the rate limiter and idempotency layer have to agree on what "same operation" means.
English
0
0
0
9
Puneet Patwari
Puneet Patwari@system_monarch·
The same Stripe system that guarantees you won't double-charge a customer will also, deliberately, refuse to serve you if you ask too fast. That's rate limiting, and the interesting part is that Stripe runs it before almost everything else, including the idempotency check, which tells you something about what rate limiting is actually for. A rate limiter caps how many requests a client can make in a window of time. The point isn't to be stingy, it's survival. One misbehaving client, a script stuck in a retry loop, a bad actor, or just one customer having a genuinely huge traffic spike, can consume so much capacity that everyone else's requests start failing. The rate limiter is the thing that stops one user from taking down the service for all the others. That's why it sits right at the front door, before any work happens: there's no point spending effort on a request you're about to reject, and the whole job is to shed load before it reaches the expensive parts. The common way to build one is the token bucket, and it's worth knowing because it's everywhere: 1. Imagine a bucket that holds tokens, refilling at a steady rate, say 100 tokens a second. Every request takes one token to proceed. 2. If the bucket has tokens, the request goes through. If it's empty, the request is rejected with a 429 "too many requests," and the client is told to back off. 3. Because the bucket has a size, it can absorb a short burst above the steady rate, but a sustained flood drains it and gets throttled. This is what lets you tolerate a spike without allowing a permanent overload. The part that trips people up is what a client should do when it gets a 429, and the wrong answer makes everything worse. If every blocked client retries at the exact same moment, they all slam back in together and re-trigger the limit, a self-inflicted denial of service. The fix is exponential backoff with jitter: wait a bit longer after each rejection, and add a random amount to that wait so the retries spread out instead of synchronising. Stripe's own client libraries do this automatically. So the two systems draw a clean line worth remembering. Rate limiting protects the service from the client. Idempotency protects the client from the network. They run in that order, front door first, which is exactly why a rate-limited request can slip past the idempotency guarantee, as covered earlier.
English
1
0
13
1.9K
Ivan Balias
Ivan Balias@IvanBalias·
the detail most people miss in that screenshot: one Échoué in mar 2023 that recovered on its own. that's involuntary churn in the wild. a card fails, and whether a loyal customer stays comes down to whether the retry catches them before they even notice something broke. the scary part is how often it doesn't recover, and you just see a "cancelled" sub months later with no idea it was never a real decision to leave.
English
0
0
0
25
Arnaud Belinga
Arnaud Belinga@ArnaudBelingaCX·
This guy is my most loyal customer 🤯 He experienced 3+ pivots and stayed.
Arnaud Belinga tweet media
English
3
0
10
2.1K
Ivan Balias
Ivan Balias@IvanBalias·
Building Lirova in public. Dunning / failed-payment recovery for Stripe SaaS. Solo, pre-launch. lirova.app Surprise while building: "just retry the failed card" is a trap. Expired card, no-funds card, stolen card all fail. But retrying them the same way flags you with the bank. So the engine routes by decline reason: - Expired: you need the customer, retry is useless. - Insufficient funds: timing matters, retry near payday. - Lost/stolen/fraud: STOP. Every retry raises a fraud signal. The other trap nobody mentions: retries aren't safe by default. A webhook can redeliver, a job can run twice, and suddenly you double-charge a real customer. Every pay call is idempotency-keyed and gated on account status, so a retry can never move money twice. Involuntary churn isn't one problem. It's three wearing the same error code. And the recovery engine has to be paranoid about money the whole way. #buildinpublic #saas #churn
English
1
0
3
281
Ivan Balias
Ivan Balias@IvanBalias·
that race is real - payment_failed and payment_succeeded can arrive out of order, and the failed handler fires the email before the success lands. the fix that killed it for us: don't act on payment_failed immediately. open the case, but gate the email behind a short reconcile - if a succeeded event for the same invoice arrives within the window, close silently, no email. treat failed as "pending confirmation", not "notify now".
English
0
0
0
19
Sai Krishna ⚡️ Superblog.ai
Thanks for replying man. For more context, there are two bugs that I noticed: 1. [few months ago] I tried to pay the invoice but payment failed in the first try. So I paid in the second attempt. But cf never considered that and started showing banner in the dashboard. I raised a support request and waited for a month. In the meantime, I paid another invoice. Still, the payment reconciliation for the old invoice dint happen. It got resolved automatically sometime after that. 2. [today] I paid an invoice successfully in the first attempt but still got the payment failed email from cf. I believe this is a bug in stripe (i encountered this bug because superblog customers used to say they paid successfully but still got payment failed emails). Because sometimes, stripe sends out invoice.payment_failed webhook right in between payment button clicked and payment successfully done. So our webhook listener processes that event (and sends out email). I believe this is what happened today.
English
2
0
3
911
Ivan Balias
Ivan Balias@IvanBalias·
one thing i locked down early building Lirova: it can never double-charge a customer. sounds trivial. it's the hardest part of the whole system. here's why. when a payment fails, we retry it on a schedule. but "retry" in a distributed system is a trap: - the queue redelivers the same job when a worker restarts mid-run - Stripe fires the same webhook twice, sometimes minutes apart - a network blip re-runs a request you thought already finished any single one of these = charging a real person twice for the same failed invoice. and nothing tells you until they open a dispute. the fix isn't "be careful." careful doesn't survive production. the rule i built everything around: the queue never decides whether money moved. Redis moves the job, but the actual truth - "did this charge already happen" - is a unique row in Postgres. every retry re-runs, hits the constraint, and no-ops. you can redeliver the same job a thousand times and the customer gets charged once. it's boring plumbing. no one asks for it in a demo. but it's the entire difference between a tool that recovers revenue and a tool that quietly creates chargebacks. pre-launch, but this is the part i refused to fake.
English
0
0
1
70
Ivan Balias
Ivan Balias@IvanBalias·
100% underrated. one thing that doubles it: the email is only half — the retry timing behind it matters just as much. an expired card needs the customer (email does the work), but insufficient funds just needs a retry near their payday, no email required. matching the move to the decline reason is where the recovery jumps.
English
0
0
0
11
Ben Fitterman
Ben Fitterman@benfitterman·
One of the most profitable email automations is the most unsexy. The good old billing error reminder - a Dunning automation. These are subscription customers whose credit card expired, or the transaction failed for some reason. They probably had no clue. They love your products and want to buy them. But unless you follow up and REMIND them. They will just churn. If you don't have at least 3 reminder emails set up to fix this you are leaving an insane amount of money on the table. Always be following up for that sale.
English
3
0
2
164
Ivan Balias
Ivan Balias@IvanBalias·
most SaaS founders can't tell you their involuntary churn number. not because they don't track churn — because failed payments never show up as churn. the customer didn't quit. their card did. ~15% of recurring charges fail. expired cards, insufficient funds, declines. a chunk is recoverable, a chunk isn't — but you can't fix a number you've never seen. so i built the thing that shows you the number.
Ivan Balias tweet media
English
0
0
0
73
Ivan Balias
Ivan Balias@IvanBalias·
Kỳ Thanh's close - it's the mandate. when a customer authorizes a subscription, the consent is tied to that one card, not the account. stripe charging a different card the user added for something else = a charge they never authorized for this sub -> dispute + fraud-flag risk. that's why it won't auto-swap even with cards sitting right there. not a missing feature, a consent boundary.
English
0
0
0
14
Miki Palet
Miki Palet@paletmiki·
One thing I don't understand is why @stripe doesn't automatically retry failed payments with other cards the user has added
English
6
0
5
2.7K
Ivan Balias
Ivan Balias@IvanBalias·
@lukerramsden @stripe you're right that "update payment method" is the wrong ask here. a spend-limit decline is temporary — the card is fine, it just needs a retry a bit later, not a new card. stripe treats every decline the same, that's the actual gap. retry timing depends on WHY it failed.
English
0
0
0
9
Luke Ramsden
Luke Ramsden@lukerramsden·
@stripe is legitimately not a good product anymore - forcing me to update payment method makes no sense when my card declines because it's due to Ramp spend limits, I just need a 'retry' button
Luke Ramsden tweet media
English
4
0
1
228
Ivan Balias
Ivan Balias@IvanBalias·
7 is the one that bit me. the trick that held: internal "captured" is never the source of truth on its own - it's a claim, not a fact. the fact is a unique row the processor event confirms. so exactly-once effect on top of at-least-once delivery = deterministic job id + a unique constraint in postgres. retries re-run, hit the constraint, no-op. redis moves work, but it never gets to say whether money moved.
English
0
0
0
15
Abhishek Singh
Abhishek Singh@0xlelouch_·
A company rejected me but I still love this system design question: Design Stripe-style ledger reconciliation with idempotent payments. How do you build a system that can prove the internal ledger matches the payment processor and bank, while payment requests are retried and duplicated? Constraints that make it interesting: 1) Idempotency keys per customer + API endpoint, valid for 24h, must handle client retries, timeouts, and out-of-order responses 2) At-least-once delivery between services (Kafka/SQS), duplicate events common, no global transactions 3) Ledger is append-only double-entry (debit/credit), immutable, needs auditability and backfills 4) External processors send webhooks late, duplicated, or missing; settlement can be T+2; chargebacks can arrive weeks later 5) Need reconciliation at 3 layers: auth/capture, processor balance transactions, and bank statements 6) Support partial capture, refunds, disputes, multi-currency, FX fees, and rounding 7) Define the source of truth when internal state says captured but processor says failed (and vice versa) 8) Exactly-once effects for money movement, but only at-least-once primitives available 9) SLO: payment API p99 < 300ms, reconciliation can be async but must detect mismatches within 15 minutes 10) You must produce an explainable diff for every mismatch and a safe automated repair path without corrupting the ledger
English
5
3
62
5.3K
Ivan Balias
Ivan Balias@IvanBalias·
spent a week thinking "just retry failed payments" was the whole problem. it's the trap. a card that's expired, a card with no funds, and a stolen card all fail — but retrying them the same way is how you get flagged by the bank. expired → you need the customer, retry is useless. insufficient funds → timing matters, retry near payday. lost/stolen/fraud → STOP. every retry raises a fraud signal. involuntary churn isn't one problem. it's three, wearing the same error.
English
0
0
1
47
Ivan Balias
Ivan Balias@IvanBalias·
the measurement split is the right call. one thing i'd add: once involuntary is isolated, it's still not one bucket — expired card vs insufficient funds vs hard decline each need a different retry move. measuring it right is step one, routing it right is where the recovery actually shows up.
English
0
0
0
10
Shantanu Kulkarni
Shantanu Kulkarni@_ShantanuKul·
If you take one thing from this thread: Pull your churn data for the last 12 months. Split it by voluntary, silent, and involuntary. If silent + involuntary is over 40%, the fix isn’t a better product. It’s a better measurement system. And that’s the cheapest fix you’ll make all year.
English
2
0
0
25
Shantanu Kulkarni
Shantanu Kulkarni@_ShantanuKul·
Most founders think retention infrastructure is something you build “when you’re bigger.” But the cost of waiting compounds. Here’s what you need at each stage — and what happens if you skip it. 🧵
English
1
0
0
48