Terug naar blog
subscriptionsdunningchurnbetalingensaasstripeomzetherstel

Subscription Lifecycle Management en Dunning voor je SaaS: herstel omzet die je stilletjes verliest

Door SaaS Masters30 maart 202611 min leestijd

Subscription Lifecycle Management en Dunning voor je SaaS: herstel omzet die je stilletjes verliest

Je hebt maanden besteed aan het werven van klanten, het optimaliseren van je onboarding en het verminderen van vrijwillige churn. Maar er is een stille omzetkiller die op de loer ligt in je SaaS: onvrijwillige churn. Verlopen creditcards, vervallen betaalmethoden en onvoldoende saldo annuleren stilletjes abonnementen — en de meeste SaaS-founders beseffen niet hoeveel geld ze laten liggen.

Onderzoek laat zien dat 5–9% van je MRR risico loopt door onvrijwillige churn. Het goede nieuws? Met goed subscription lifecycle management en een slimme dunning-strategie kun je 50–70% van die mislukte betalingen automatisch herstellen.

De Subscription Lifecycle begrijpen

Voordat we dunning induiken, laten we eerst de volledige subscription lifecycle in kaart brengen. Elk abonnement in je SaaS doorloopt verschillende fases:

Trial → Actief → Achterstallig → Geannuleerd/Verlopen
  ↓        ↓         ↓              ↓
 Convert  Vernieuw  Herstel       Win-back

Elke overgang is een kritiek moment waarop je omzet behoudt of verliest. Laten we de statussen doorlopen:

1. Trial-fase

De klant evalueert je product. Belangrijke beslissingen:

  • Trialduur: 7 dagen voor simpele tools, 14–30 dagen voor complexe B2B SaaS
  • Creditcard vooraf? Verhoogt conversie naar betaald maar verlaagt trial-aanmeldingen
  • Trial-afloop: Grace period of harde cutoff?

2. Actieve fase

Het abonnement is gezond. Maar negeer het niet — dit is waar je:

  • Verlengingsherinneringen stuurt (vooral voor jaarabonnementen)
  • Notificeert over aankomende prijswijzigingen
  • Betaalmethoden vooraf valideert vóór verlenging

3. Achterstallige fase

Betaling mislukt. Dit is waar dunning in actie komt. De klok tikt.

4. Geannuleerd / Verlopen

De klant is vertrokken (vrijwillig) of de betaling kon niet hersteld worden (onvrijwillig). Beide vereisen een andere strategie.

Subscriptions modelleren in je database

Hier is een praktisch schema dat de volledige lifecycle ondersteunt:

CREATE TABLE subscriptions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  plan_id TEXT NOT NULL REFERENCES plans(id),
  status TEXT NOT NULL DEFAULT 'trialing',
    -- trialing, active, past_due, canceled, expired, paused
  current_period_start TIMESTAMPTZ NOT NULL,
  current_period_end TIMESTAMPTZ NOT NULL,
  cancel_at_period_end BOOLEAN DEFAULT false,
  canceled_at TIMESTAMPTZ,
  cancellation_reason TEXT,
  trial_end TIMESTAMPTZ,
  dunning_started_at TIMESTAMPTZ,
  dunning_attempts INT DEFAULT 0,
  last_payment_error TEXT,
  payment_method_id TEXT,
  stripe_subscription_id TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);
CREATE INDEX idx_subscriptions_dunning ON subscriptions(dunning_started_at)
  WHERE status = 'past_due';

Het belangrijkste inzicht: track current_period_end altijd apart van de betalingsstatus. Een klant wiens betaling mislukt bij verlenging heeft nog steeds toegang tot het einde van de betaalde periode. Direct afsluiten is zowel juridisch twijfelachtig als een verschrikkelijke gebruikerservaring.

Een Dunning Engine bouwen

Dunning is het proces van het proberen te herstellen van mislukte betalingen. Zo bouw je er een die echt werkt:

De Dunning-sequence

Een bewezen dunning-sequence beslaat doorgaans 14–28 dagen:

interface DunningStep {
  dayAfterFailure: number;
  action: 'retry_payment' | 'send_email' | 'in_app_banner' | 'downgrade' | 'cancel';
  emailTemplate?: string;
}

const dunningSequence: DunningStep[] = [
  // Dag 0: Betaling mislukt
  { dayAfterFailure: 0, action: 'retry_payment' },
  { dayAfterFailure: 0, action: 'send_email', emailTemplate: 'payment_failed_soft' },
  { dayAfterFailure: 0, action: 'in_app_banner' },

  // Dag 3: Eerste retry
  { dayAfterFailure: 3, action: 'retry_payment' },
  { dayAfterFailure: 3, action: 'send_email', emailTemplate: 'payment_failed_update_card' },

  // Dag 7: Tweede retry + urgentie
  { dayAfterFailure: 7, action: 'retry_payment' },
  { dayAfterFailure: 7, action: 'send_email', emailTemplate: 'payment_failed_urgent' },

  // Dag 14: Laatste poging
  { dayAfterFailure: 14, action: 'retry_payment' },
  { dayAfterFailure: 14, action: 'send_email', emailTemplate: 'payment_failed_final_warning' },

  // Dag 21: Downgrade naar gratis (als je een free tier hebt)
  { dayAfterFailure: 21, action: 'downgrade' },

  // Dag 28: Abonnement annuleren
  { dayAfterFailure: 28, action: 'cancel' },
];

De Dunning Worker implementeren

Draai dit als een geplande taak (cron of queue-based):

async function processDunning() {
  const pastDueSubscriptions = await db.subscription.findMany({
    where: { status: 'past_due' },
    include: { user: true, plan: true },
  });

  for (const sub of pastDueSubscriptions) {
    const daysSinceFailure = differenceInDays(
      new Date(),
      sub.dunningStartedAt!
    );

    const pendingSteps = dunningSequence.filter(
      step => step.dayAfterFailure === daysSinceFailure
    );

    for (const step of pendingSteps) {
      switch (step.action) {
        case 'retry_payment':
          await retryPayment(sub);
          break;
        case 'send_email':
          await sendDunningEmail(sub.user, step.emailTemplate!);
          break;
        case 'in_app_banner':
          await setInAppBanner(sub.userId, 'payment_failed');
          break;
        case 'downgrade':
          await downgradeToFree(sub);
          break;
        case 'cancel':
          await cancelSubscription(sub, 'involuntary_churn');
          break;
      }
    }

    await db.subscription.update({
      where: { id: sub.id },
      data: {
        dunningAttempts: { increment: pendingSteps.length },
        updatedAt: new Date(),
      },
    });
  }
}

Slim retry-timing

Niet alle retry-tijden zijn gelijk. Betaalproviders rapporteren hogere slagingspercentages wanneer je:

  1. Retried op verschillende dagen van de week — vermijd weekenden
  2. Retried aan het begin van de maand — wanneer rekeningen waarschijnlijk meer saldo hebben
  3. Retried in de ochtend — vóór dagelijkse uitgaven
  4. Exponential backoff gebruikt — bombardeer de betaalprovider niet
function getOptimalRetryTime(attempt: number): Date {
  const now = new Date();
  const baseDelay = [0, 3, 7, 14][attempt] ?? 14;
  const retryDate = addDays(now, baseDelay);

  // Verschuif naar dinsdag of woensdag ochtend als het weekend is
  const day = retryDate.getDay();
  if (day === 0) retryDate.setDate(retryDate.getDate() + 2); // Zo → Di
  if (day === 6) retryDate.setDate(retryDate.getDate() + 3); // Za → Di

  retryDate.setHours(9, 0, 0, 0); // Ochtend retry
  return retryDate;
}

Integratie met Stripe

Als je Stripe gebruikt (en dat zou je waarschijnlijk moeten doen), benut dan hun ingebouwde dunning-features terwijl je er je eigen laag bovenop bouwt:

// stripe-webhook-handler.ts
import Stripe from 'stripe';

async function handleInvoicePaymentFailed(event: Stripe.Event) {
  const invoice = event.data.object as Stripe.Invoice;
  const subscription = await db.subscription.findFirst({
    where: { stripeSubscriptionId: invoice.subscription as string },
  });

  if (!subscription) return;

  if (subscription.status === 'active') {
    await db.subscription.update({
      where: { id: subscription.id },
      data: {
        status: 'past_due',
        dunningStartedAt: new Date(),
        dunningAttempts: 0,
        lastPaymentError: invoice.last_payment_error?.message ?? 'Onbekende fout',
      },
    });

    await analytics.track('subscription.payment_failed', {
      userId: subscription.userId,
      planId: subscription.planId,
      errorType: invoice.last_payment_error?.type,
      amount: invoice.amount_due,
    });
  }
}

async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
  const invoice = event.data.object as Stripe.Invoice;
  const subscription = await db.subscription.findFirst({
    where: { stripeSubscriptionId: invoice.subscription as string },
  });

  if (!subscription) return;

  if (subscription.status === 'past_due') {
    await db.subscription.update({
      where: { id: subscription.id },
      data: {
        status: 'active',
        dunningStartedAt: null,
        dunningAttempts: 0,
        lastPaymentError: null,
        currentPeriodStart: new Date(invoice.period_start * 1000),
        currentPeriodEnd: new Date(invoice.period_end * 1000),
      },
    });

    await sendEmail(subscription.userId, 'payment_recovered');
    await clearInAppBanner(subscription.userId, 'payment_failed');
  }
}

Stripe Billing-instellingen om te configureren

await stripe.subscriptions.update(subscriptionId, {
  payment_settings: {
    payment_method_options: {
      card: {
        request_three_d_secure: 'automatic',
      },
    },
    save_default_payment_method: 'on_subscription',
  },
  collection_method: 'charge_automatically',
});

Belangrijke Stripe-instellingen:

  • Smart Retries: Activeer in Stripe Dashboard → Billing → Automatic collection. Stripe gebruikt ML om het optimale retry-moment te vinden.
  • E-mails bij mislukte betalingen: Laat Stripe de basis versturen, maar vul aan met je eigen.
  • Klantenportaal: Activeer self-service kaartwijzigingen op billing.stripe.com.

Pre-Dunning: voorkomen is beter dan genezen

De beste dunning-strategie is het helemaal voorkomen van mislukte betalingen:

1. Kaart-verloopnotificaties

async function checkExpiringCards() {
  const customers = await stripe.customers.list({ limit: 100 });

  for (const customer of customers.data) {
    const pm = await stripe.paymentMethods.list({
      customer: customer.id,
      type: 'card',
    });

    for (const method of pm.data) {
      const expiry = new Date(
        method.card!.exp_year,
        method.card!.exp_month - 1
      );
      const daysUntilExpiry = differenceInDays(expiry, new Date());

      if (daysUntilExpiry <= 30 && daysUntilExpiry > 0) {
        await sendEmail(customer.metadata.userId, 'card_expiring_soon', {
          last4: method.card!.last4,
          expiryDate: format(expiry, 'MM/yyyy'),
          updateUrl: `${APP_URL}/settings/billing`,
        });
      }
    }
  }
}

2. Pre-verlengingsherinneringen voor jaarabonnementen

Jaarlijkse klanten vergeten vaak dat ze een abonnement hebben. Stuur 7 dagen voor verlenging een herinnering:

async function sendRenewalReminders() {
  const sevenDaysFromNow = addDays(new Date(), 7);
  const renewingSoon = await db.subscription.findMany({
    where: {
      status: 'active',
      currentPeriodEnd: {
        gte: startOfDay(sevenDaysFromNow),
        lte: endOfDay(sevenDaysFromNow),
      },
      plan: { interval: 'year' },
    },
    include: { user: true, plan: true },
  });

  for (const sub of renewingSoon) {
    await sendEmail(sub.user.email, 'annual_renewal_reminder', {
      planName: sub.plan.name,
      renewalDate: format(sub.currentPeriodEnd, 'MMMM d, yyyy'),
      amount: formatCurrency(sub.plan.price),
    });
  }
}

Win-Back Flows: na annulering

Wanneer een abonnement toch wordt geannuleerd (vrijwillig of onvrijwillig), is je werk niet klaar:

Onvrijwillige Churn Win-Back

const winBackSequence = [
  { daysAfterCancel: 1, template: 'winback_update_payment', offer: null },
  { daysAfterCancel: 7, template: 'winback_we_miss_you', offer: '20_percent_off_3_months' },
  { daysAfterCancel: 30, template: 'winback_final', offer: '30_percent_off_3_months' },
];

Pauzeren in plaats van annuleren

Bied een pauze-optie — het is dramatisch beter herstelbaar dan een annulering:

async function pauseSubscription(subscriptionId: string, pauseMonths: number) {
  const resumeDate = addMonths(new Date(), pauseMonths);

  await stripe.subscriptions.update(subscriptionId, {
    pause_collection: {
      behavior: 'void',
      resumes_at: Math.floor(resumeDate.getTime() / 1000),
    },
  });

  await db.subscription.update({
    where: { stripeSubscriptionId: subscriptionId },
    data: { status: 'paused' },
  });
}

Metrics om te tracken

Bouw een dashboard dat deze dunning-specifieke metrics bijhoudt:

MetricFormuleDoel
Onvrijwillige churn rateVerloren MRR door mislukte betalingen / Totale MRR< 1% per maand
HerstelpercentageHerstelde betalingen / Totaal mislukte betalingen> 60%
Gemiddelde hersteltijdGemiddeld aantal dagen van mislukking tot herstel< 7 dagen
Dunning e-mail open rateOpens / Verstuurde dunning e-mails> 40%
Kaart-updatepercentageBijgewerkte kaarten na notificatie / Verstuurde notificaties> 25%

Veelvoorkomende valkuilen

  1. Direct toegang ontzeggen bij mislukte betaling — Bied altijd een grace period. Klanten buitensluiten wekt wrok op, geen betalingen.

  2. Generieke dunning e-mails — Personaliseer ze. Vermeld de plannaam, waar ze toegang toe verliezen, en een one-click link om de betaalmethode bij te werken.

  3. Onvrijwillige en vrijwillige churn niet apart tracken — Ze vereisen compleet andere oplossingen. Samenvoegen verbergt het echte probleem.

  4. Tijdzones negeren bij retry-logica — Een retry om 3 uur 's nachts in de tijdzone van de klant is verspild. Lokaliseer altijd.

  5. Geen self-service betaalupdate — Als het bijwerken van een kaart contact met support vereist, ben je ze al kwijt.

Conclusie

Subscription lifecycle management is niet glamoureus, maar het is een van de investeringen met de hoogste ROI die je kunt doen in je SaaS. Een goed gebouwd dunning-systeem herstelt omzet die anders stilletjes zou verdwijnen — en het werkt 24/7 zonder handmatige interventie.

Begin met de basis: verwerk Stripe webhooks correct, stuur duidelijke dunning e-mails en bied eenvoudige kaartwijzigingen aan. Bouw daarna verder met slim retry-timing, pre-dunning preventie en win-back flows. Je MRR zal je dankbaar zijn.

Het verschil tussen een SaaS die groeit en een die stilstaat is vaak niet acquisitie — het is retentie. En een groot deel van retentie is simpelweg zorgen dat betalingen doorkomen.