Email Marketing15 min read

Subscription Lifecycle Emails: Trial, Upgrade, Dunning, and Cancellation Flows in Next.js

Every SaaS subscription generates predictable email moments. Build production-ready lifecycle email flows — trial activation, trial ending, payment receipts, plan changes, failed payment recovery, and cancellation confirmation — with Next.js and React Email.

R

React Emails Pro

March 12, 2026

Every SaaS subscription generates a predictable sequence of moments where email matters: the trial starts, the trial is ending, the card gets charged, the plan changes, and eventually someone cancels. Most apps handle one or two of these well and ignore the rest. The gaps show up as churn.

This guide covers every email in the subscription lifecycle — from trial activation to win-back — with production patterns for Next.js and React Email. Each flow includes the trigger event, timing, template structure, and the code to wire it together.

62%

Trial-to-paid conversions need email

SaaS trials with email sequences convert 2.5x more than those without.

$1.8B

Lost to involuntary churn annually

Failed payments that a well-timed email could have recovered.

15%

Win-back rate from cancel emails

Cancellation confirmation emails with the right offer recover 1 in 7 users.


1. Trial activation email

The moment a user signs up for a trial, they should get an email that does three things: confirms their trial is active, sets expectations on what happens next, and gives them one clear first step. Not three steps. Not a feature tour. One action.

emails/trial-activated.tsx
import {
  Html, Head, Body, Container, Section,
  Text, Button, Hr,
} from "@react-email/components";

interface TrialActivatedProps {
  userName: string;
  trialDaysLeft: number;
  firstStepUrl: string;
  planName: string;
}

export function TrialActivatedEmail({
  userName,
  trialDaysLeft,
  firstStepUrl,
  planName,
}: TrialActivatedProps) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: "sans-serif", background: "#f9fafb" }}>
        <Container style={{ maxWidth: 560, margin: "0 auto", padding: 40 }}>
          <Text style={{ fontSize: 20, fontWeight: 600 }}>
            Your {planName} trial is active
          </Text>
          <Text style={{ color: "#6b7280", lineHeight: 1.6 }}>
            Hi {userName}, you have {trialDaysLeft} days to explore everything
            in {planName}. No credit card was charged.
          </Text>
          <Section style={{ textAlign: "center", margin: "32px 0" }}>
            <Button
              href={firstStepUrl}
              style={{
                background: "#000",
                color: "#fff",
                padding: "12px 24px",
                borderRadius: 6,
                fontSize: 14,
                fontWeight: 500,
              }}
            >
              Take your first step →
            </Button>
          </Section>
          <Hr style={{ borderColor: "#e5e7eb", margin: "24px 0" }} />
          <Text style={{ fontSize: 13, color: "#9ca3af" }}>
            Your trial ends in {trialDaysLeft} days. We'll remind you
            before it expires — no surprises.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}
Personalize the "first step" based on the user's role or use case. A developer should see "Set up your first integration." A marketing lead should see "Import your contacts."

2. Trial ending reminder (3 days before)

This email has one job: create urgency without creating anxiety. Show what they've accomplished during the trial (usage stats), remind them what they'll lose, and make upgrading frictionless.

lib/email/triggers/trial-ending.ts
import { createEmailProvider } from "@/lib/email/provider";
import { TrialEndingEmail } from "@/emails/trial-ending";
import { getTrialUsageStats } from "@/lib/usage";

const provider = createEmailProvider();

export async function sendTrialEndingReminder(userId: string) {
  const user = await getUser(userId);
  const stats = await getTrialUsageStats(userId);
  const trialEnd = new Date(user.trialEndsAt);
  const daysLeft = Math.ceil(
    (trialEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
  );

  await provider.send({
    from: "App <hello@yourdomain.com>",
    to: user.email,
    subject: `${daysLeft} days left on your trial — here's what you've built`,
    react: (
      <TrialEndingEmail
        userName={user.name}
        daysLeft={daysLeft}
        stats={{
          emailsSent: stats.emailsSent,
          templatesCreated: stats.templatesCreated,
          teamMembersInvited: stats.teamMembersInvited,
        }}
        upgradeUrl={`${process.env.NEXT_PUBLIC_URL}/settings/billing`}
      />
    ),
  });
}

The usage stats are the secret weapon here. Telling someone "you've sent 847 emails and created 5 templates" is far more persuasive than "upgrade now to keep your features." It reframes the decision from "should I pay?" to "should I throw away the work I've already done?"


3. Payment success / receipt

Every charge needs a receipt. This isn't just good UX — it's legally required in many jurisdictions. The email should include the amount, plan name, billing period, and a link to the invoice.

app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { createEmailProvider } from "@/lib/email/provider";
import { PaymentReceiptEmail } from "@/emails/payment-receipt";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const provider = createEmailProvider();

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature")!;

  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "invoice.payment_succeeded") {
    const invoice = event.data.object as Stripe.Invoice;
    const customer = await stripe.customers.retrieve(
      invoice.customer as string
    ) as Stripe.Customer;

    await provider.send({
      from: "Billing <billing@yourdomain.com>",
      to: customer.email!,
      subject: `Receipt for ${formatCurrency(invoice.amount_paid)}`,
      react: (
        <PaymentReceiptEmail
          customerName={customer.name || "there"}
          amount={formatCurrency(invoice.amount_paid)}
          planName={invoice.lines.data[0]?.description || "Subscription"}
          billingPeriod={`${formatDate(invoice.period_start)} – ${formatDate(invoice.period_end)}`}
          invoiceUrl={invoice.hosted_invoice_url || "#"}
        />
      ),
    });
  }

  return NextResponse.json({ received: true });
}

4. Plan upgrade / downgrade confirmation

When a user changes their plan, the confirmation email should clearly state: what changed, when it takes effect, and what the new price is. Upgrades should feel celebratory. Downgrades should be straightforward and respectful — no guilt trips.

emails/plan-changed.tsx
interface PlanChangedProps {
  userName: string;
  previousPlan: string;
  newPlan: string;
  effectiveDate: string;
  newPrice: string;
  isUpgrade: boolean;
  changedFeatures: Array<{
    name: string;
    previousValue: string;
    newValue: string;
  }>;
}

export function PlanChangedEmail({
  userName,
  previousPlan,
  newPlan,
  effectiveDate,
  newPrice,
  isUpgrade,
  changedFeatures,
}: PlanChangedProps) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: "sans-serif", background: "#f9fafb" }}>
        <Container style={{ maxWidth: 560, margin: "0 auto", padding: 40 }}>
          <Text style={{ fontSize: 20, fontWeight: 600 }}>
            {isUpgrade
              ? `You're now on ${newPlan} 🎉`
              : `Plan changed to ${newPlan}`}
          </Text>
          <Text style={{ color: "#6b7280", lineHeight: 1.6 }}>
            Hi {userName}, your plan has been{" "}
            {isUpgrade ? "upgraded" : "changed"} from {previousPlan} to{" "}
            {newPlan}. This takes effect on {effectiveDate}.
          </Text>

          {/* Feature comparison table */}
          <Section style={{ margin: "24px 0" }}>
            {changedFeatures.map((f) => (
              <Row key={f.name}>
                <Text style={{ fontWeight: 500 }}>{f.name}</Text>
                <Text style={{ color: "#9ca3af" }}>
                  {f.previousValue} → {f.newValue}
                </Text>
              </Row>
            ))}
          </Section>

          <Hr style={{ borderColor: "#e5e7eb" }} />
          <Text style={{ fontSize: 14, color: "#6b7280" }}>
            Your new billing amount is {newPrice}/month.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}
Upgrade emails
  • Celebratory tone — acknowledge the investment
  • Highlight new features they've unlocked
  • Suggest first action with their new plan
  • Thank them for choosing a higher tier
Downgrade emails
  • Neutral, respectful tone — no guilt
  • Clarify what changes and what stays
  • State effective date clearly (usually end of billing cycle)
  • Include a way to provide feedback on why they downgraded

5. Failed payment recovery

Failed payments are the #1 cause of involuntary churn. The difference between losing and recovering the customer often comes down to email timing and tone. Send the first email within minutes of the failure — not hours, not the next day.

Immediate: soft notification

"Your payment didn't go through. This usually resolves itself — we'll retry automatically. No action needed yet."

Day 3: action required

"We've tried twice and your payment is still failing. Update your card to keep your account active." Include a direct link to the billing page.

Day 7: final warning

"Your account will be downgraded in 3 days. Update your payment method to keep [specific feature they use most]." Personalize the loss — generic warnings don't convert.

lib/email/triggers/failed-payment.ts
import { createEmailProvider } from "@/lib/email/provider";
import {
  FailedPaymentSoftEmail,
  FailedPaymentActionEmail,
  FailedPaymentFinalEmail,
} from "@/emails/failed-payment";

const provider = createEmailProvider();

type DunningStage = "soft" | "action" | "final";

const DUNNING_CONFIG: Record<DunningStage, {
  component: React.FC<any>;
  subject: (name: string) => string;
  delayDays: number;
}> = {
  soft: {
    component: FailedPaymentSoftEmail,
    subject: (name) => `${name}, your payment didn't go through`,
    delayDays: 0,
  },
  action: {
    component: FailedPaymentActionEmail,
    subject: () => "Action required: update your payment method",
    delayDays: 3,
  },
  final: {
    component: FailedPaymentFinalEmail,
    subject: () => "Your account will be downgraded in 3 days",
    delayDays: 7,
  },
};

export async function sendDunningEmail(
  userId: string,
  stage: DunningStage,
) {
  const user = await getUser(userId);
  const config = DUNNING_CONFIG[stage];
  const EmailComponent = config.component;

  await provider.send({
    from: "Billing <billing@yourdomain.com>",
    to: user.email,
    subject: config.subject(user.name),
    react: (
      <EmailComponent
        userName={user.name}
        updatePaymentUrl={`${process.env.NEXT_PUBLIC_URL}/settings/billing`}
      />
    ),
  });
}
Never threaten immediate data deletion in dunning emails. Give clear timelines, be transparent about what happens to their data, and always provide a path to reactivation. Aggressive tactics destroy trust and generate support tickets.

6. Cancellation confirmation

The cancellation email is your last conversation with a departing user. Do it right and 15% of them come back. Do it wrong — long guilt-trip, hidden retention tricks, broken unsubscribe links — and you get a negative review instead.

emails/cancellation-confirmed.tsx
interface CancellationConfirmedProps {
  userName: string;
  planName: string;
  accessEndsAt: string;
  dataRetentionDays: number;
  feedbackUrl: string;
  reactivateUrl: string;
}

export function CancellationConfirmedEmail({
  userName,
  planName,
  accessEndsAt,
  dataRetentionDays,
  feedbackUrl,
  reactivateUrl,
}: CancellationConfirmedProps) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: "sans-serif", background: "#f9fafb" }}>
        <Container style={{ maxWidth: 560, margin: "0 auto", padding: 40 }}>
          <Text style={{ fontSize: 20, fontWeight: 600 }}>
            Your {planName} subscription has been cancelled
          </Text>

          <Text style={{ color: "#6b7280", lineHeight: 1.6 }}>
            Hi {userName}, we've cancelled your subscription as requested.
            Here's what happens next:
          </Text>

          <Section style={{
            background: "#f3f4f6",
            borderRadius: 8,
            padding: "16px 20px",
            margin: "24px 0",
          }}>
            <Text style={{ margin: 0, fontSize: 14 }}>
              <strong>Access continues until:</strong> {accessEndsAt}
            </Text>
            <Text style={{ margin: "8px 0 0", fontSize: 14 }}>
              <strong>Your data is kept for:</strong> {dataRetentionDays} days
            </Text>
            <Text style={{ margin: "8px 0 0", fontSize: 14 }}>
              <strong>Reactivate anytime:</strong> Your settings and data
              will be waiting.
            </Text>
          </Section>

          <Text style={{ color: "#6b7280", lineHeight: 1.6 }}>
            If you have a moment, we'd love to know what we could improve.
          </Text>

          <Section style={{ textAlign: "center", margin: "24px 0" }}>
            <Button
              href={feedbackUrl}
              style={{
                background: "#f3f4f6",
                color: "#374151",
                padding: "10px 20px",
                borderRadius: 6,
                fontSize: 14,
              }}
            >
              Share quick feedback
            </Button>
          </Section>
        </Container>
      </Body>
    </Html>
  );
}

Notice the structure: confirmation first, clear timeline second, soft feedback ask third. The reactivation link is mentioned but not pushed. The tone is matter-of-fact, not desperate.


Scheduling and trigger patterns

Most lifecycle emails are triggered by Stripe webhook events or scheduled jobs. Here's a clean pattern for managing both in Next.js.

lib/email/lifecycle-scheduler.ts
import { db } from "@/lib/db";

interface ScheduledEmail {
  userId: string;
  templateId: string;
  sendAt: Date;
  metadata: Record<string, unknown>;
}

export async function scheduleLifecycleEmail(email: ScheduledEmail) {
  // Idempotency: don't schedule duplicates
  const existing = await db.scheduledEmail.findFirst({
    where: {
      userId: email.userId,
      templateId: email.templateId,
      status: "pending",
    },
  });

  if (existing) return existing;

  return db.scheduledEmail.create({
    data: {
      userId: email.userId,
      templateId: email.templateId,
      sendAt: email.sendAt,
      metadata: email.metadata,
      status: "pending",
    },
  });
}

// Cancel scheduled emails when state changes
// e.g., user upgrades → cancel trial-ending reminders
export async function cancelScheduledEmails(
  userId: string,
  templateId: string,
) {
  await db.scheduledEmail.updateMany({
    where: {
      userId,
      templateId,
      status: "pending",
    },
    data: { status: "cancelled" },
  });
}
Always cancel scheduled emails when the user's state changes. If someone upgrades mid-trial, cancel their trial-ending reminders. If they update their payment method, cancel the dunning sequence. Stale emails destroy trust.

The complete lifecycle email map

Here's every email in the subscription lifecycle, the event that triggers it, and the recommended timing:

  1. Trial activated — Trigger: user signs up. Send: immediately. Goal: first-step activation.
  2. Trial midpoint check-in — Trigger: cron job. Send: day 7 of 14-day trial. Goal: re-engage inactive users.
  3. Trial ending — Trigger: cron job. Send: 3 days before expiry. Goal: convert to paid.
  4. Trial expired — Trigger: cron job. Send: day of expiry. Goal: final conversion push.
  5. Payment success / receipt — Trigger: invoice.payment_succeeded. Send: immediately. Goal: confirmation + trust.
  6. Plan upgraded — Trigger: customer.subscription.updated. Send: immediately. Goal: celebrate + activate new features.
  7. Plan downgraded — Trigger: customer.subscription.updated. Send: immediately. Goal: clarify changes + gather feedback.
  8. Payment failed (soft) — Trigger: invoice.payment_failed. Send: immediately. Goal: inform without alarming.
  9. Payment failed (action) — Trigger: cron job. Send: 3 days after failure. Goal: prompt card update.
  10. Payment failed (final) — Trigger: cron job. Send: 7 days after failure. Goal: prevent involuntary churn.
  11. Cancellation confirmed — Trigger: customer.subscription.deleted. Send: immediately. Goal: confirm + offer feedback path.
  12. Win-back — Trigger: cron job. Send: 30 days after cancellation. Goal: re-engage with new value.

Key takeaway

Production checklist for subscription lifecycle emails:

  • Map every subscription state change to an email trigger — gaps in the lifecycle show up as churn.
  • Include usage stats in trial-ending emails. "You've built X" converts better than "upgrade now."
  • Build a 3-stage dunning sequence: soft → action → final. Send the first email within minutes, not hours.
  • Always cancel scheduled emails when user state changes — stale emails destroy trust faster than missing emails.
  • Respect cancellations. Confirm clearly, state timelines, and make reactivation easy without being pushy.
  • Use idempotency checks in your scheduling layer — webhook events can fire more than once.
R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates