2
9 Comments

Building in Public: I just shipped the foundation of a Smart Dunning Decision Engine

UPDATE

Just shipped Phase 4 of Smart Dunning in RetryFix — we can finally see why payments are failing.

After several weeks of focused work, Phase 4 is now live in production.

What we built

We added automatic capture of real Stripe decline codes on every invoice.payment_failed webhook (and during onboarding backfill). These codes are now stored in a new failure_reason column on the FailedPayment table.

This gives our rule-based engine actual signals to work with instead of guessing based only on invoice amount.

Examples:

  • insufficient_funds → gentler cadence
  • expired_card → more aggressive cadence

Why this was important

Even after introducing the rule-based engine in Phase 3, we still had a blind spot. A failed payment was just “failed”. Now we know the reason, which makes smarter retry timing possible.

How we implemented it (low risk by design)

  • Added a nullable failure_reason column (idempotent migration)
  • Pure helper extract_decline_code() that works with both Stripe SDK objects and plain dicts
  • Fail-soft lookup in the webhook: temporarily switch to the merchant’s secret key, retrieve the PaymentIntent, restore the previous key
  • Same pattern applied to the onboarding backfill path
  • New kill switch DUNNING_CAPTURE_FAILURE_REASON (defaults to true)
  • The webhook never breaks if the lookup fails — we just log it and continue with failure_reason = NULL

No changes to existing dunning schedules. Only new failed payments are affected.

Current status

  • Deployed to production
  • New failed payments are now populating failure_reason
  • Rule-based engine (when enabled) can immediately use the new data
  • Existing in-flight dunning steps are untouched

What’s next (Phase 5)

We’re already well into a weighted ScoringDecisionEngine. Instead of hard if/elif precedence, signals (value + decline code + merchant risk) are combined into a continuous score that maps to the same cadence bands.

We’re also adding full per-signal explainability so we can see exactly why a particular schedule was chosen.

Lessons learned so far

  • Start simple ([1,3,7]), then iterate
  • Make every change reversible and observable
  • Capture real signals early, even if the decision logic isn’t using them yet
  • Fail-soft is essential when touching webhooks and external APIs

This entire Smart Dunning journey started because I was tired of losing revenue on failed payments that could have been recovered with better timing and intelligence.

If you’re building a subscription SaaS, I’d love to hear how you currently handle failed payment recovery. What’s working well? What’s painful?

Happy to answer questions about the architecture, the rollout, or the journey so far.

#indiehacker #buildinpublic #SaaS #payments #dunning

————————2

For few months, RetryFix has used a very simple retry schedule: try again after 1, 3, and 7 days. It works okay, but it’s completely static. Every failed payment gets the same treatment, regardless of the amount, the failure reason, the customer’s history, or the merchant’s preferences.

I’ve always known this was a limitation. Good dunning should be contextual. A $9 failed payment probably deserves different handling than a $299 one. A temporary “insufficient funds” error is very different from a hard decline. So I’ve started replacing the hardcoded logic with something much more intelligent.

What I just shipped

I’ve completed the core foundation of a Smart Dunning Decision Engine. This is a clean, testable Python module that now sits at the heart of our retry logic.

Phase 1 created the domain models and a stable DecisionEngine protocol. Phase 2 delivered the first real implementation (DefaultDecisionEngine) that reproduces the existing [1, 3, 7] behaviour exactly. I also built a thin adapter layer so the rest of the application talks to the engine through a clean interface instead of directly to the old hardcoded loop.

The important part is what this unlocks. Because the decision logic now lives in its own isolated module (following a Ports & Adapters approach), I can start swapping in smarter implementations without touching the rest of the codebase.

The plan going forward

I’m going to build this iteratively and openly:

• Next: Rule-based decisions (different schedules based on invoice amount, failure reason, merchant plan, etc.)
• Then: Weighted scoring
• Eventually: Light machine learning (multi-armed bandit style) that learns what actually works for different segments

I’m doing this in public because I think the journey of moving from simple rules to more intelligent systems is interesting for other SaaS founders. I’ll share the architecture decisions, the trade-offs, what works, and what doesn’t.

If you’re also thinking about moving away from static retry logic, or you’ve built something similar, I’d love to hear your thoughts. What’s worked (or failed) for you when trying to make dunning smarter?

You can try the current version of RetryFix here: https://retryfix.com

More updates coming as I build out the next layers.

on July 2, 2026
  1. 1

    Full disclosure: I'm Avery Lin (avrlin). I've been packaging a small Stripe Dunning Email Pack (failure-reason matrix → day-1 copy) with AI assistance, so take this as adjacent interest, not neutral advice.

    Generic "payment failed" emails burn the easy recoveries (expired card) and feel cold on the hard ones (do_not_honor / fraud). Branching by decline family on day 0/3/7 with a Customer Portal CTA is the boring fix that actually recovers revenue.

    Curious — are you still on one generic failed-payment email, or do you branch by decline family yet?

  2. 1

    Many teams either do nothing or apply the same retry logic to every failed payment.

    The reality is that different failure reasons need different approaches. Treating them all the same is one of the biggest missed opportunities in revenue retention.

    I wrote more about this here: https://retryfix.com/blog/failed-payment-revenue-impact

    #saas #churn #stripe

  3. 1

    Quick update: Phase 3 of our Decision Engine just went live.

    We actually built it a while ago but only turned it on via config this week after more testing.

    Changes:

    • Proper rule-based system instead of basic retries
    • Better handling of different Stripe decline codes
    • Smarter retry timing

    Early results are encouraging. I’ll post more detailed metrics once we have a solid week or two of data.

    Anyone else working on dunning or payment recovery systems? Curious what’s working for you these days.

    Building in public as always.

    #SaaS #Stripe #Dunning #BuildingInPublic

  4. 1

    Not all failed payments are the same.

    Hard declines (lost/stolen cards, closed accounts) are usually unrecoverable. But a big portion are soft declines — insufficient funds or temporary issuer issues — which are often recoverable with the right timing and strategy.

    I broke this down in more detail here: https://retryfix.com/blog/failed-payment-revenue-impact

    #saas #churn #stripe

  5. 1

    A surprising number of SaaS companies treat failed payments as “just part of doing business.”

    They might retry once or twice manually, then move on. The problem is that this approach leaves a lot of recoverable revenue on the table.

    More on the numbers and the missed opportunity here: https://retryfix.com/blog/failed-payment-revenue-impact

    #saas #churn #stripe

  6. 1

    Most SaaS founders dramatically underestimate how much revenue they lose to failed payments every month.

    It’s not just the obvious hard declines — it’s the accumulation of soft declines, expired cards, and temporary issues that quietly compound over time.

    I wrote a short piece breaking down the real revenue impact here: https://retryfix.com/blog/failed-payment-revenue-impact

    Would love to hear how big an issue this has been for your business.

    #saas #churn #stripe #failedpayments

  7. 1

    Quick update on what I’ve been focused on the last couple of weeks.

    I’ve added a lightweight blog section to RetryFix. It’s deliberately simple — just markdown files in the repo. No CMS, no database, no admin panel. Adding a new post is as easy as creating a .md file and committing it. The goal is to start sharing more in-depth writing about failed payments, churn, retention strategies, and the lessons I’m learning while building.

    I’ve also shipped the foundation of an Automated Onboarding & Retention Email Sequence. This automatically detects key user states and sends helpful, low-pressure nudges. The aim is to reduce drop-off and help more merchants actually get value from the tool.
    I temporarily paused the Decision Engine work to focus on this because I felt it was more important right now for user activation and retention.

    The blog is live here: https://retryfix.com/blog
    I’ll be adding more posts over the coming weeks. Would love any feedback on the format or topics people would find useful.

    Thanks to everyone who’s been following along.
    #buildinpublic #saas #churn #stripe

  8. 1

    Treating it as a proper engine instead of tweaking 1-3-7 is smart, and the ports & adapters setup will make iterating so much easier.

    When you get to weighted scoring / ML, how are you planning to define “success” for a strategy – purely recovered revenue, or also churn/chargeback risk and LTV?

    1. 2

      Hey, thanks for the comment! - Yeah the ports & adapters setup is already making things way easier to work with.

      Totally agree though — if you just optimise for recovered revenue you can end up annoying people into churning or triggering chargebacks, which kills the long-term value. Right now I’m thinking success needs to be a mix: recovered money as the main thing, but also whether the customer sticks around for the next billing cycle, chargeback risk on the retries, and treating higher-LTV customers a bit more gently.

      For the rule-based and weighted stuff I want to keep the logic readable so I can actually explain why a payment got a certain schedule. The clean DecisionEngine interface should let me test different scoring approaches against historical data first before touching anything live, which feels a lot safer.

      When we get to the bandit phase I’ll probably try a contextual setup where the reward mixes immediate recovery with those longer-term signals (even if attribution gets a bit fuzzy). Still really early though — the foundation only just landed.

      Curious what’s worked for you on this stuff? Any metrics or guardrails you’ve found actually matter when you try to make dunning smarter? Appreciate any comments you may have.

      Hey would also appreciate you checkout out a few screen on the UI if you have the time, I had a recent refresh on that front too ;-)