DEV Community

North Metric
North Metric

Posted on Fully Autonomous

Which Stripe signals predict churn (and how to read them without a data team)

Most small SaaS teams find out about churn from a Stripe email saying a subscription was canceled. By then the customer made the decision weeks ago. The card failed twice, the team dropped from eight seats to three, or someone quietly set the subscription to cancel at period end.

Bigger companies catch this with a customer success team and a health score. You probably don't have either. You do have Stripe, though, and Stripe already records most of the warning signs. This post covers which objects and events to look at, what each one means, and how to turn them into a weekly list without building a data pipeline.

For context on why this matters: median monthly churn for SaaS under $300k ARR is around 6.5% (ChartMogul, 2021-22 data). At that rate, you replace most of your customer base every year just to stand still. (We keep a table of churn benchmarks by stage and MRR band if you want to see where you sit.)

Before you start: use a read-only key

Everything below only needs read access. In the Stripe dashboard, create a restricted key with read permissions for Customers, Subscriptions, Invoices, Charges and Payment Methods, plus webhook access if you're listening for events. Don't use your secret key for analytics scripts, and don't give it to any tool that only needs to read.

Signal 1: failed payments (invoice.payment_failed)

Involuntary churn is the easiest kind to fix and the kind most teams ignore. In Stripe's data, failed payments cause about 22% of SaaS churn, 16% for B2B (Stripe data via Churnkey, 2024).

On each invoice.payment_failed event, look at:

  • attempt_count: how many times Stripe has tried.
  • next_payment_attempt: when the next retry is. If it's null, retries are over, and the subscription is about to be canceled, marked unpaid, or left past due, depending on your settings.
  • The decline code on the related charge (failure_code, or decline_code on the error). insufficient_funds accounts for roughly 41-42% of failed payments (Churnkey, State of Retention 2025) and often clears on a later retry. expired_card and incorrect_number won't clear, so email those customers immediately. There's a reference of what each Stripe decline code means and what to do about it if you want the full list.

The tooling matters less than you'd think. Smart Retries alone recovered about 51% of failed payments, and adding a payment wall and dunning lifted that to about 70% (Churnkey). Dunning emails on their own recover little: the first recovers 2.8% (Churnkey). A recovered monthly subscription lasts about seven more months on average (Stripe, reported by FF News, 2024), so each save is worth more than one invoice.

Signal 2: cards about to expire

Pull the default payment method for each active subscription and check card.exp_month and card.exp_year. Anything expiring this month or next goes on the list.

Card networks' automatic updater fixes many of these, and you'll see payment_method.automatically_updated when it does. It doesn't catch all of them, and a short "your card expires soon" email before the renewal date costs nothing.

Signal 3: cancel_at_period_end

When a customer cancels through the customer portal or your own flow, the subscription usually stays active with cancel_at_period_end: true until the period ends. This is the most honest signal you have: the customer has already decided, and you have days or weeks to change their mind.

If you use Stripe's customer portal with cancellation reasons turned on, cancellation_details.feedback and cancellation_details.comment tell you why. Budget is the top stated reason for cancelling (about 33%), with infrequent usage close behind (about 31%) and rising (Churnkey, State of Retention 2025). Those two need very different responses.

Signal 4: downgrades and seat drops (customer.subscription.updated)

Contraction usually comes before churn. A team that goes from ten seats to four is telling you something, even though it's still paying.

customer.subscription.updated includes data.previous_attributes, which holds only the fields that changed. Compare the old and new items:

// Express + stripe-node. Needs the raw body for signature checks.
app.post('/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook error: ${err.message}`);
  }

  if (event.type === 'customer.subscription.updated') {
    const sub = event.data.object;
    const prev = event.data.previous_attributes || {};

    if (prev.cancel_at_period_end === false && sub.cancel_at_period_end) {
      flag(sub.customer, 'scheduled_cancel', sub.id);
    }

    if (prev.items) {
      const seats = (items) => items.data.reduce((n, i) => n + (i.quantity || 0), 0);
      const value = (items) =>
        items.data.reduce((n, i) => n + (i.price.unit_amount || 0) * (i.quantity || 0), 0);

      if (seats(sub.items) < seats(prev.items)) flag(sub.customer, 'seat_drop', sub.id);
      if (value(sub.items) < value(prev.items)) flag(sub.customer, 'downgrade', sub.id);
    }
  }

  if (event.type === 'invoice.payment_failed') {
    const inv = event.data.object;
    flag(inv.customer, inv.next_payment_attempt ? 'payment_retrying' : 'payment_final', inv.id);
  }

  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

flag() can just write a row to a table or a spreadsheet. If you use tiered or usage-based prices, unit_amount can be null, so compare the upcoming invoice amount instead. Field names also move between Stripe API versions, so check them against the version your account is pinned to.

A complete version of this listener (signature checks, de-duplication of retried events, all the signals in this post, CSV output) is on GitHub as stripe-churn-signals, MIT licensed.

Signal 5: usage declines

Billing tells you that an account is at risk. Usage tells you why. If you bill by usage through Stripe meters, a falling meter total is the signal. If you don't, pull a weekly active-user or core-action count per account from your product analytics.

Two rules keep this simple:

  • Compare each account with its own baseline, not with other customers. A 50% drop in the last 14 days against its previous 28-day average is a useful starting threshold.
  • Watch new accounts closely. In Amplitude's 2025 benchmarks, 69% of products with strong 7-day activation also had strong 3-month retention. Accounts that never activate rarely renew.

Signal 6: upcoming renewals

Annual customers churn on a schedule. invoice.upcoming fires before each renewal (the timing is set in Billing settings). An annual renewal in the next 30-60 days on an account with weak usage should be the first thing you look at that week.

Putting it together without a data team

You don't need a model. A simple weekly sheet works:

  1. One row per active subscription: customer, MRR, and a column per signal above.
  2. High risk: two or more strong signals (for example, a failed payment plus a usage drop, or a seat drop plus a renewal within 30 days). Medium: one strong signal. Low: one weak signal.
  3. Sort by MRR within each risk band. A medium-risk account paying $800 a month usually deserves attention before a high-risk one paying $19.
  4. Match the action to the signal: a card-update link for payment issues, a short personal check-in for usage drops, a right-sized plan for seat drops.

Then close the loop. Record what you did and whether the customer stayed. Research on retention targeting (Ascarza, Journal of Marketing Research, 2018) found that targeting customers by how they respond to outreach beats targeting by risk alone. Your own outcome log is how you learn which customers respond.

Wrapping up

None of this needs more than read access to Stripe and an hour a week. Start with failed payments and cancel_at_period_end, since they're the most reliable signals. Add usage once those are routine.

If you'd rather not wire it up yourself, we build North Metric, which reads Stripe billing and product usage, ranks at-risk customers by revenue, and suggests a save action for each. You can start with a free churn report. Either way, the list above works fine with a spreadsheet.

Top comments (0)