Back to blog

Why You Should Manage Stripe Subscription Cancellations With a Rule Engine

Learn how to use Nodora to detect qualifying cancellations and trigger customer win-back workflows

Darko M.·

Stripe sends you a webhook event for almost everything. Most of them you log and forget, but one of them is worth real money if you act on it fast: the cancellation.

When a customer cancels, you have a window to win them back. In most codebases that logic ends up buried in the Stripe webhook handler. Some if checks the event type, pulls a few fields off the subscription object, and calls a marketing API inline. It works until you want to change who qualifies, or stop targeting churned trials, or add a second channel. Now every tweak to a retention policy is a pull request and a deploy. It can get messy fast.

This kind of problem is why rule engines like Nodora exist. We can write a rule that reads the raw Stripe event, decides whether this cancellation is worth a win-back, and emits a signal when it is. Everything downstream hangs off that one signal.

Here’s the whole ruleset written in the Nodora language:

signal WinbackCandidate(customer_id, email, plan, reason)

rule StripeSubscriptionCanceled {
  is_canceled = input.type == "customer.subscription.deleted"

  sub    = input.data.object
  plan   = sub.items.data[0].price.id
  reason = sub.cancellation_details.reason   // "cancellation_requested", "payment_failed", etc.

  // exclude involuntary churn, that belongs in the recovery flow, not win-back
  is_voluntary = reason != "payment_failed"

  out should_winback = is_canceled && is_voluntary

  emit WinbackCandidate(
    sub.customer,
    sub.customer_email,
    plan,
    reason
  ) when should_winback
}

The input here is the raw Stripe event, the same JSON Stripe POSTs to your webhook. The rule reaches straight into it: input.type is the event name, input.data.object is the subscription that was canceled, and the fields it needs hang off that.

The signal we trigger on

Two things decide whether a win-back fires. The first is the event itself. customer.subscription.deleted is the event Stripe sends when a subscription actually ends, not when someone schedules a cancellation for the end of the period, and not when they just downgrade. That’s the moment the relationship is over and the win-back clock starts.

The second is the line:

is_voluntary = reason != "payment_failed"

Stripe tells you why a subscription ended in cancellation_details.reason. There’s a real difference between someone who clicked “cancel” (cancellation_requested) and someone whose card just kept failing (payment_failed). The first is a marketing problem: they chose to leave, so win them back with an offer. The second is a billing problem: they probably still want the product, their card expired. Sending a 20%-off coupon to someone whose payment failed is the wrong move; that person belongs in your dunning and card-recovery flow, not a promo campaign.

So the rule splits them. Only voluntary cancellations pass the filter and emit WinbackCandidate. Involuntary churn falls through and never reaches the win-back channels at all.

That filter is exactly the kind of thing that changes. Maybe next quarter you only want to win back paid plans and skip churned trials. Maybe you want to exclude too_expensive cancellations from the discount track and route them somewhere else. Each of those is one line in the rule and a release, not a deploy of your payments code.

What the signal carries

When should_winback is true, the rule emits:

WinbackCandidate(customer_id, email, plan, reason)

Four fields, and they’re the four every downstream channel actually needs. The customer_id to look the person up, the email to reach them, the plan (the Stripe price ID) so you can tailor the offer to what they were paying for, and the reason so the campaign can branch on why they left.

A signal on its own doesn’t go anywhere. It’s a domain event your rule emits if the condition is met: this customer is worth winning back. What happens next is up to the actions you attach to it. When the signal emits, Nodora runs each attached action and hands it a clean JSON body shaped like the emission:

{
  "name": "WinbackCandidate",
  "args": ["cus_P1a2b3c4", "[email protected]", "price_pro_monthly", "cancellation_requested"]
}

Attaching the side effects

The signal is the stable thing; the delivery is yours to choose. The most common action is a webhook that POSTs that payload to a URL you control, and that URL can be anything:

  • An automation platform like Zapier or n8n, where the win-back becomes a flow a non-engineer builds and rewires: send the email, wait three days, send a follow-up, etc.
  • An email/marketing service like SendGrid or Resend, hit directly or through a thin function, firing a “here’s 25% off” message to the email, with the offer tied to the plan they left.
  • Your own service or queue if you’d rather keep the orchestration in-house.

You can attach several at once because each is just an action subscribed to the same event. The rule never knows or cares which platforms are on the other end. Swap SendGrid for Customer.io, or move the whole flow from Zapier into n8n, and you edit the action. The rule and your application code don’t change.

Evaluate the rule

The only code you write is the evaluate call, and it should live where you already handle Stripe webhook events:

import { NodoraClient } from "@nodora/client";

const nodora = new NodoraClient({
  apiKey: process.env.NODORA_API_KEY!,
});

const billing = nodora.ruleset("Billing");

export async function POST(req: Request) {
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    req.headers.get("stripe-signature")!,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  if (event.type === "customer.subscription.deleted") {
    await billing.evaluate("StripeSubscriptionCanceled", event);
  }

  return new Response(null, { status: 200 });
}

The handler does the one cheap call and leaves the actual decision to the engine. It doesn’t know what a win-back is, doesn’t know about discounts, plans, or which channels are live this month. You’re not paying a network hop per event either. The Nodora client is smart enough to cache the ruleset and evaluate it locally.

Why this split is worth it

There are three layers here, and each one owns exactly one thing:

  • The rule owns the decision: what counts as a win-back candidate. Excluding payment failures, narrowing to paid plans, branching on cancellation reason. All of that is a rule edit and a release.
  • The action owns the transport. Where the signal goes, n8n, Zapier, SendGrid, your own queue, is a setting on the action. Point it somewhere new and nothing else moves.
  • The platform on the other end owns the fan-out. The promo, the audience, the Slack ping, the spreadsheet. Whoever runs your marketing tooling builds and rewires this without ever opening the repo.

The result is that the part of your retention strategy that changes, who gets a win-back and what they’re offered, lives somewhere you can change as you please. Your payments code stays boring, which is exactly what you want payments code to be.

Stripe already knows the moment a customer leaves. A rule just decides what that moment means, and a signal carries it to the rest of your infrastructure whose job is to win them back.