1
24 Comments

Most founders are optimizing the wrong churn

For years I stared at one number: churn rate. I treated it like a single dial I had to turn down.

The problem is that one number quietly hides two completely different problems that need opposite responses.

Voluntary churn is a product problem. Someone consciously decided to leave. They stopped seeing the value, found something better, or never got going in the first place. The fix lives in onboarding, activation, pricing, the roadmap. It's slow, human work.

Involuntary churn is a payments problem. Nobody decided anything. A card expired. A bank flagged a charge. A one-time verification the customer never even saw. These are people who never meant to leave, dropping off on the last inch of a relationship they'd already agreed to.

Here's what got me: the responses are almost opposites. For voluntary churn, chasing someone who chose to leave can be annoying and pushy. For involuntary churn, NOT reaching out is the mistake, because a well-timed retry or a "confirm your payment" nudge just quietly saves revenue you already earned.

When you blend them into one "churn rate," you optimize the average and win at neither. You end up running win-back campaigns at people whose card just expired, and sending payment nudges to people who genuinely wanted out.

The industry ranges make this hard to ignore: involuntary churn is often 20-40% of total churn, and failed payments can cost somewhere around 5-10% of revenue. Those are general figures, not my results, but they suggest a big chunk of "churn" isn't a product problem at all.

I'm building Revova around the payments half specifically, so I'm biased. But splitting the number in two changed how I think even before any tool.

So I'm curious how you separate them: do you actually break out voluntary vs involuntary churn in your own dashboards, and if so, how?

on July 16, 2026
  1. 1

    One thing that decides whether any of this measures what you think: the decline code to class mapping is doing more work than it looks, and the biggest bucket is usually the ambiguous one. Stripe says outright that issuers categorise most declines as generic and that the exact reason is unclear by design, so generic_decline and do_not_honor are not cleanly retryable or cleanly hard, and wherever you put them is where most of your volume goes. Worth checking what share of your failures sit in codes you had to make a judgement call about, because that share is the error bar on everything downstream.

    Related, and aimed at the ranking idea rather than the split: recovery rate by class is hard to observe once your policy is already different per class. If you retry every retryable and never retry a hard decline, then a hard decline that recovers is a customer who went and got a new card on their own, and a retryable one that recovers is your retry working. Those are two different things wearing the same number, so ranking on it is directionally fine but carrying more precision than it has.

    One small thing worth checking against the docs: card_velocity_exceeded gets classed as retryable a lot, and the published next step is that the customer contacts their issuer, which is not a retry.

    To answer what you actually asked, I cannot. I am on the payments half too and I am live with nobody using it, so I have no dashboard of my own to split. Happy to compare notes rather than tell you what the number should be.

    1. 1

      Thanks for such a masterclass breakdown, @ivansflow. Your point on decline code mapping (generic_decline, do_not_honor) and the critical difference between Dead Instruments (Expired) and Timing (Insufficient Funds) is the exact reason why static rules and one-size-fits-all email templates fail.

      Sending an "update your card" nudge to someone who just needs a retry after payday is a huge missed opportunity and puts unnecessary blame on the customer.

      This exact dilemma is why I built Revova to use AI for failure classification instead of hardcoded rules. It reads the raw decline code dynamically and tailors the copy — an empathetic prompt for expired cards vs. a gentle, non-card nudge for insufficient funds, paired with daily smart-retries across a 30-day window to cross pay boundaries naturally.

      Really appreciate you dropping so much technical clarity in this thread!

      1. 1

        Thanks Leo. The classification-drives-copy point I agree with, and it is the half most setups skip entirely.

        One thing in the description is worth checking, and it is a number rather than an opinion. Daily retries across a thirty day window is about thirty attempts on a single card. Stripe's own guidance is a maximum of eight retries for charges that permit retrying at all, and it says plainly that card issuers might see additional retries as potential fraud, which can result in increased declines for legitimate charges. Visa's reattempt limit is documented by several processors as fifteen within thirty days, with a fee on the sixteenth onward and none permitted at all on their Category 1 decline group. Visa's own rulebook is not free to read, so I am relying on processor documentation there rather than the primary text, and I would want to confirm it against your acquirer before designing around it either way.

        The consequence is what makes it worth raising. A fixed daily cadence is not a neutral default. On a card that would have recovered on its own, the cadence can be the thing that stops it recovering, and the damage lands on the accounts you were trying to save rather than on the ones already lost.

        Related, and it only shows up at volume. Idempotency keys on Stripe expire after twenty four hours, so nothing past day one is protected by the key from the first attempt. A sequence running for thirty days needs its own deduplication, or a retry storm after an outage bills someone twice.

        Neither of those appears in a demo. They appear at scale, which is why it is worth saying now rather than being clever about it afterwards.

        The question I would actually want answered before shipping either design: what does the daily cadence do to approval rates from day three onward, compared with a schedule that waits for a pay boundary? If daily is winning, that is genuinely interesting and it would change my mind. If it is losing, it is losing on the recoverable group, which is the expensive way to find out.

        1. 1

          Okay, this deserves a real answer, not a "thanks for the feedback" — went and checked every one of these against our actual code instead of the marketing copy. Here's what I found, point by point:

          card_velocity_exceeded — you were right, flat out. It was sitting in our auto-retry set, which means we were silently re-charging a card that the issuer's own velocity/fraud system had just blocked. That's backwards — per Stripe's own docs the right move there is "customer contacts their issuer," and repeatedly hammering that decline code is exactly the pattern that gets a card flagged. Fixed: it's now routed the same way as do_not_honor/fraudulent — fast "update your card" email track, zero auto-retry.

          The ambiguous bucket — mixed bag here. do_not_honor was already excluded from auto-retry on our end, so that one wasn't actually a problem. generic_decline was the real gap you were pointing at though — it's treated as a plain soft/retryable decline with no further judgment applied, and yeah, it's almost certainly our largest bucket. Didn't touch the classification logic itself yet (that's a harder problem — need real signal to split it, not just a rename), but it's now bounded by the attempt cap below so at least the blast radius is limited.

          The retry cadence / network limits — this was the big one, and you nailed it. Checked the actual cron: default behavior (toggle off, which is what most accounts were on) really was firing every day for up to 30 days on any retryable decline. That's ~30 attempts on one card, well past Stripe's own ~8-attempt guidance and uncomfortably close to Visa's 15-in-30-days ceiling. Shipped two fixes: (1) hard cap at 8 total attempts, full stop, independent of whatever window the merchant picks; (2) attempts are now always concentrated on payday windows instead of daily — once you're capped at 8, there's no reason to ever spend one on a random Tuesday when the balance is unlikely to have refilled yet. That second part matters because of your last question below.

          Idempotency — partially fair. We were never creating a fresh charge each attempt, always re-paying the same invoice ID, so Stripe's own object-level state already prevented a literal double-charge in the common case. But there was no idempotency key on the call and no lock, so a genuinely overlapping cron run could race into two pay() calls before our DB caught up. Added a per-payment-per-day idempotency key to close that — narrower gap than "no dedup at all," but a real one.

          Day-3-onward approval curve, daily vs. payday — honestly, don't have that number. We don't currently instrument recovery-by-day-of-attempt, so I can't give you a real answer on whether daily was actually losing on the recoverable segment specifically — just the logical argument above for why payday-only should win once attempts are capped. That's now on my list to actually measure, not assume.

          Appreciate you going this deep on it — this is the kind of comment that's more useful than most code review I get. All of the above is live now, not "on the roadmap."

          1. 1

            That is more than I expected and it makes me want to be careful, because you have now changed a production retry path partly on the strength of a comment from someone with no users. If the cadence change turns out to hurt, that is partly mine.

            The do_not_honor correction is the part I am glad you published. I assumed it from the description and I was wrong.

            On the last one, the number you do not have. You have already run the experiment, you just have not looked at it yet. The cadence changed on a date.

            Everything before it was daily for up to thirty days, everything after is capped at eight and pushed towards payday. Same product, same customers, one variable moved on purpose. That is a before and an after nobody has to design.

            Two things I would be careful about before believing whatever it says.

            Compare by attempt number, not by day.

            Attempt three under daily and attempt three under payday land in completely different parts of somebody's month, and that difference is the entire hypothesis, so comparing day three to day three quietly tests nothing.

            And split by decline reason first. Insufficient funds is the only group where a pay boundary should do anything at all. If the effect shows up in the expired card group too, then something else moved and the number is telling you about that instead.

            The obvious problem with all of it is that the two windows are different months, so seasonality and whatever your volume did in between are sitting underneath the result. It is still worth doing. It is just an indication rather than a proof, and saying which one it is is what stops it from becoming a claim later.

            If daily turns out to be winning I would genuinely rather know. I have been reasoning from documentation and one export of my own, which is not evidence of anything.

            Unrelated to your question: I put the decline code check into a script so it is a command rather than an afternoon, github.com/IvanSFlowGit/advice-code-check.

            It counts the retries that went against the issuer instruction, and separately the try_again_later charges nobody ever went back to.

            Runs on your own export or a read-only key, makes no write call, sends nothing anywhere. It does not answer the cadence question, which is why it is down here.

            When you do instrument it, are you going to be able to see the attempt number, or only date?

            That decides whether any of the above is possible on your existing data or only going forward.

            1. 1

              Hey ivansflow — you were right, and thanks for pushing on this instead of letting it slide.

              I went back and checked: our own decline-codes guide tells merchants to retry try_again_later, but the actual auto-retry logic excluded it. And we were never reading Stripe's advice_code at all — just inferring retry-eligibility from decline_code, which conflates "why it failed" with "should you retry." Both are fixed now: advice_code is captured on every incoming webhook, and it overrides the decline_code fallback when Stripe gives us a direct signal (do_not_try_again blocks a retry, try_again_later allows one, confirm_card_data falls through). Already shipped and live in production, not just a ticket.

              On the methodology side — you're right that our before/after comparison was an indication, not proof, and that the real test is stratifying by decline reason: insufficient_funds should show the payday effect, everything else is the placebo. We didn't have the data to actually run that check, so we started collecting it — every retry attempt now logs attempt number, decline_code, and day-of-month, and there's an internal report that breaks recovery rate down exactly the way you described. One honest caveat: since payday-windowed timing is now mandatory for every retry instead of opt-in, there's no non-window attempt left to compare against — so it can only show variation within the window, not payday-vs-not. Didn't want to overclaim what it proves.

              Appreciate the repo too — advice-code-check made it easy to double-check we had the semantics right. Nice catch.

  2. 1

    This distinction is basically why CancelKit and Revova sit on opposite ends of the same churn problem — you're catching people who never meant to leave, I'm catching people mid-decision on the cancel button. Small update since we last talked: CancelKit's live now and fully free (no card, no paid tier).

    1. 1

      Exactly, @efecaliskan! CancelKit and Revova sit on opposite sides of the exact same coin.

      You guys catch the intentional exit at the cancel button (Voluntary), while Revova catches the silent drop-offs in the billing pipeline (Involuntary).

      Congrats on taking CancelKit live! Combining tools that protect both ends of the funnel is how subscription apps build a truly leak-proof recurring revenue stream.

      1. 1

        Thanks, Leo! Really like that framing — voluntary vs involuntary catches at opposite ends of the same funnel. Feels like the kind of stack a founder could genuinely run both pieces of without them stepping on each other.

  3. 1

    The voluntary versus involuntary split matches a broader pattern worth naming. Some revenue leaks happen because a human made an active decision, and some happen because nobody was watching a specific moment at all. A card expiring is the payments version of a lead going cold because a reply sat in an inbox for two weeks, both are silent, both look identical once they show up as one lagging number, and both need something to notice the moment itself rather than the aggregate. Do you find founders resist splitting the metric because the fix for involuntary churn is mostly automatable while the fix for voluntary churn forces them to face a harder product truth?

    1. 1

      You hit on a really fascinating psychological point, @FounderFlow_57.

      Fixing voluntary churn forces a founder to look in the mirror and face uncomfortable product truths (onboarding, activation, value fit). That’s slow, hard human work. Involuntary churn, however, is purely a technical leaky bucket. The customer already loves the product and already agreed to pay — their bank just tripped over the finish line.

      I do think some founders resist splitting it because fixing product churn feels like "real work," whereas fixing payment churn feels like admin. But I usually advise doing involuntary churn first: it’s the lowest-hanging fruit. Recovering that lost revenue immediately extends your runway so you have the cash flow to actually fix the harder product truths.

      That’s why we built Revova as a 3-minute, no-code setup — so founders can plug the revenue leak on autopilot and get straight back to building their core product.

  4. 1

    Yes, took us way too long to figure out, we had one "churned" bucket, until I noticed we were sending win-back emails to people whose card just expired. Once we split it out, our real churn dropped by about a third, it wasn't smaller, just hidden.

    I think giving failed payments their own grace period instead of lumping them in and the message matters: "update your card" beats "we miss you".

    1. 2

      You are one layer further along than most of that thread, so this is the next split rather than a disagreement.

      Inside the failed payment bucket there is more than one population, and "update your card" is only right for one of them. Someone whose card expired needs exactly that message. Someone with insufficient funds does not need a new card at all, and telling them to get one reads as badly as "we miss you" did to the expired group. Same mistake, one level down.

      The grace period splits the same way. An expired card gains nothing from waiting, because no amount of time fixes it, so that one wants a message immediately. Insufficient funds is the opposite: what matters is that the retry crosses a pay boundary, so a different day of the month beats a fixed number of days later.

      And the awkward one, which is usually the biggest group: a large share of declines come back as generic or unexplained by the issuer. Neither of your two messages is right for those, and most setups quietly file them under whichever one they already built.

      Did you ever get as far as the breakdown by decline reason, or was the voluntary and involuntary split enough on its own? Genuinely curious what the shape looked like, because everyone talks about the split and nobody posts what is actually in it.

      I am building on the payments half too, and I am live with nobody using it, so I am asking rather than telling.

      1. 1

        Good catch. We never went further than voluntary vs involuntary, so insufficient funds has been getting the same "update your card" message as expired cards this whole time. Kind of obvious in hindsight, but easy to miss when you're just happy the first split worked.

        Appreciate you laying it out this clearly, going to go dig into our decline reasons now. Might come back with numbers, might come back quiet if it's uglier than I think.

        1. 1

          Come back with the ugly version. That is the one worth posting.

          Mine was ugly. I built four categories, ran a real export against them, and
          eighteen of Stripe's fifty-odd codes did not fit any of them. The categories were
          wrong rather than the data, and I only found that because I looked at the raw
          list instead of the summary.

          Two things you will probably hit, so they are less annoying when they arrive.

          The biggest single bucket is likely to be the one you cannot act on. Issuers
          categorise a lot of declines as generic and Stripe says outright that the
          specific reason is unclear by design, so generic_decline and do_not_honor tend to
          dominate. Neither of your two messages is right for those, and most setups
          quietly file them under whichever one they already built.

          And expired_card can come in lower than you expect, because Account Updater
          refreshes some of those before a charge is ever attempted. So the group you have
          been messaging correctly may turn out to be the smaller one, and the group
          getting the wrong message the larger.

          One practical thing so you do not have to do it twice: make sure the export
          carries the decline code column and not just the failure message. Several
          distinct codes collapse into the same customer-facing sentence, so the message
          field hides exactly the thing you are trying to see.

          No numbers needed from your side if you would rather not post them. Even the
          ranking with the amounts stripped out is more than anyone in this thread has
          shown.

          1. 1

            Went and checked the decline codes like you suggested, and you were right — generic_decline and do_not_honor make up a lot more of that "unexplained" pile than I expected. Haven't fixed the messaging yet, just confirmed insufficient-funds has been getting the wrong message this whole time, same as you called out. Going to sort that out before I say anything worked. Really appreciate you taking the time to walk through this so clearly, it helped a lot.

            1. 1

              That is the useful half, and it is further than most of that thread got.

              An offer, and the ask attached to it is the smaller half.

              If it is useful, I will do the classification for you. Codes and counts only, no amounts, no customer data, nothing identifying. You would get back which codes are dead card, which are timing, which are verification, and which are the group nobody can act on, plus what message each one actually wants. That is a couple of hours and it is the work you said you have not got to yet.

              Why I am offering: I have been reasoning from Stripe's docs and one export of my own, which is not evidence of anything. Nobody publishes what a real decline distribution actually looks like. If you would let me write up the shape of yours, anonymised, no company name and no amounts, just the ranking, it would be the first real one anybody has posted. Credited to you or not, entirely your call.

              Say the word here and I will send a note through the contact form on trimy.io, so there is somewhere private to put a file.

              If neither half suits, no problem at all, and the thread was worth it either way.

              1. 1

                That's a really kind offer, thanks, genuinely appreciate you putting the time in. Going to hold off for now though, still early enough on our end that I don't think a real comparison would be useful yet. If that changes I'll reach out through your site. Really glad this thread pushed me to actually look at this properly either way.

                1. 1

                  Understood, and that is the right call rather than a polite one. A
                  distribution from a small base describes that base, and knowing that
                  is most of the skill.

                      Here is the mapping anyway, since it is yours regardless and it is the
                      part you said you have not got to.
                  
                      Dead instrument. Expired, lost, cancelled. No retry recovers it, so
                      the message goes immediately and a grace period buys nothing. This is
                      the only group where "update your card" is the right sentence.
                  
                      Timing. Insufficient funds, and it is the opposite case in every
                      respect. The card is fine and nothing needs replacing, so the message
                      should not mention the card at all. What matters is that the next
                      attempt lands at a different point in their month rather than a fixed
                      number of days later.
                  
                      Verification. The customer finishes a step rather than replacing
                      anything. That one is a link and a sentence, not a recovery email.
                  
                      And the fourth, which is the reason the first three are not enough on
                      their own. The unexplained pile is not a category that wants a
                      message, it is the group where no message is the right answer. Stripe
                      says outright that the specific reason is unclear by design for
                      several of these, and for at least one code the published next step is
                      the customer contacting their issuer rather than you retrying. So it
                      wants a policy rather than a message, and it wants measuring on its
                      own. If it sits inside one of the other three, whichever message it
                      got filed under takes credit for outcomes it had nothing to do with.
                  
                      That last part is the bit I would not have known from the summary
                      docs, and it is why running the export was worth it even though you
                      are not ready to compare anything to anyone.
                  
                      Door stays open, no expiry on it.
    2. 1

      @hamdan25 Dropping real churn by a third just by splitting voluntary vs involuntary is a massive win — congrats on that!

      Don't feel bad about missing the "insufficient funds" messaging distinction initially. Most founders stop at the first split and celebrate the quick win. Realizing that insufficient funds need a completely different message (and retry timing) than expired cards is the next level of optimization.

      That pain of having to manually analyze 50+ Stripe decline codes and write custom logic for each was one of the big drivers behind building Revova. We built it so AI handles that exact taxonomy automatically: expired cards get an instant update link, while insufficient funds get gentle reminders without card prompts, backed by daily smart-retries to catch their payday.

      Would love to hear what your decline code distribution looks like once you dig into the raw export!

  5. 1

    The 20-40% figure is the scary one.

  6. 1

    Splitting voluntary and involuntary churn is necessary, but I'd add a third state: delinquent and still active during retries. If dashboards mark that cohort as churned too early, recovery looks like resurrection and retention gets distorted. The clean metric is logo and revenue churn at access-loss time, with recovery rate tracked separately by failure reason.

    1. 1

      You've basically described the model we already landed on, so I'll just show my work instead of agreeing in the abstract.

      A failed charge sits in one of three states: recovered, in_progress (still pending or mid-email-sequence), or lost — and lost only gets assigned once the recovery window closes (sequence exhausted / max retries hit / explicit cancel), not the moment the card first declines. So a customer three days into a retry sequence isn't counted as churned yet, and isn't counted as "resurrected" if they pay on day 4 either — they were never moved out of the active bucket in the first place. That was a deliberate call, for exactly the distortion you're pointing at: if you flip someone to "churned" on first decline and then flip them back on recovery, your retention curve has a sawtooth that has nothing to do with actual behavior.

      On the failure-reason split — also already there, one layer down. Every failed charge carries its decline code, and we bucket those into three classes: retryable (insufficient funds, processing error, generic decline, velocity — a bank-side timing problem, not the customer's), hard (expired/stolen/lost — needs a literal new card), and auth (3-D Secure not completed — needs verifying, not a new card). Recovery rate is tracked per class, not just in aggregate, because "recovered" means something different for each — a retryable decline recovering itself is bank timing, a hard decline recovering means the customer went and got a new card.

      Where we don't have it nailed yet: the value is still computed off all payments in state X, not surfaced as a ranked "biggest bucket by $ at risk" view — that's closer to what came up in your other comment about the historical scan, and it's a fair miss. Metric correctness and metric presentation turned out to be two separate problems

      1. 1

        Rank by expected recoverable dollars, not raw dollars at risk: bucket value multiplied by its observed recovery rate. A large hard-decline bucket may be less actionable than a smaller retryable one.