← ALL POSTS
21 SEPTEMBER 2026

We Were Computing Churn Risk Every Morning And Throwing It In The Bin

The Aiva platform has been calculating churn risk properly for a while. RFM scoring, payment-failure weighting, pause patterns, days since last order, expected next purchase date, days overdue. It runs nightly. The maths is fine.

Then I went looking for where it sends the message.

There isn't one. There never was.

Dead end number one

RFMBackgroundJobService.TriggerWinBackCampaignsAsync picks up high and critical risk customers for clients that have a HighLevel key configured. Then:

// Create win-back campaign workflow in HighLevel
// TODO: Integrate with HighLevel workflows API when available

risk.WinBackTriggered = true;
risk.MerchantNotified = true;
risk.NotifiedAt = DateTime.UtcNow;

It marks them notified. Nobody is notified. The flag is set, the timestamp is written, the dashboard would happily tell a merchant their at-risk customers had been contacted.

That is worse than doing nothing, because doing nothing is at least honest.

Dead end number two

The subscription churn path is more sophisticated. A proper 0 to 100 score, a Claude call for recommendations, an incentive ladder based on status and lifetime value, and then it writes a WinBackCampaign row with Status = "Scheduled".

Nothing anywhere reads that table. TouchpointsSent is never incremented because nothing ever sends a touchpoint.

And the reminder table that is a log, not a queue

There was already a SubscriptionReminders table, and it looked exactly right. It has a ScheduledSendAt. It has a Channel that supports sms and whatsapp. It has a RecipientPhone.

Except ScheduledSendAt is only ever set to the moment of insert, nothing drains rows where it is due, and the one code path that writes to it sets Status = "sent" before attempting the send. Every row said email, because nothing ever wrote another channel.

So: a table designed as a queue, used as a log, recording sends that may not have happened.

What it does now

All of it goes out as a text with a one-word reorder. "It's been a while. Want me to send beans again? Reply YES and it's on its way."

That is the entire point of having built Buy via SMS first. The churn engine already knew who to contact and roughly what they wanted. What it never had was a channel where the customer could act in one word without opening anything.

The replenishment side uses the prediction that was already sitting there unused: average days between purchases, expected next purchase date, days overdue. If someone is due, they get asked before they run out rather than chased after they have already bought elsewhere.

The rules, because this is where it goes wrong

An unwanted text at the wrong hour is how a merchant loses a customer and how we lose a SIM. So:

  • Quiet hours in the merchant's own time zone, not ours. An unknown time zone suppresses the send rather than defaulting to any hour.
  • A monthly cap per contact, and never twice about the same thing inside a fortnight.
  • The opt-out list checked before every single message, not per batch.
  • The risk is only marked contacted once a message has actually left the building. Which was the original sin.

The lesson

The interesting thing here is not that the sends were missing. It is that every one of these looked finished. Real scoring, real tables, real nightly jobs, flags being set, dashboards with numbers on them. If you only checked whether the job ran and whether rows appeared, everything was green.

Go and find the last mile of your own automations. Follow it all the way to the thing that actually reaches a human, and if you cannot find that line of code, it is not there.

Want this working on your store? Aiva does the sales, marketing and service work - in your voice, around the clock.
Get Aiva