Lifecycle Emails
Send automated emails for subscription events like welcome messages, trial reminders, and cancellation confirmations. Learn how to set up and customize subscription lifecycle emails.
Lifecycle emails keep users engaged throughout their subscription journey. Vibestacks supports sending emails at key moments like subscription start, trial ending, and cancellation.
Implementation Status
Lifecycle emails are marked as TODO in the codebase. This guide shows you how to implement them.
Email Types
| Trigger | Purpose | |
|---|---|---|
| Subscription Welcome | User subscribes | Confirm subscription, highlight features |
| Trial Start | User starts trial | Welcome, set expectations |
| Trial Ending | 3 days before trial ends | Remind to convert |
| Trial Expired | Trial ends without conversion | Win-back attempt |
| Subscription Canceled | User cancels | Confirm, offer to restore |
| Payment Failed | Payment fails | Prompt to update payment method |
Setting Up Lifecycle Emails
Create Email Templates
Create React Email templates in components/emails/:
import {
Body,
Container,
Head,
Heading,
Html,
Link,
Preview,
Text,
} from "@react-email/components";
import { siteConfig } from "@/config/app";
interface SubscriptionWelcomeEmailProps {
email: string;
planName: string;
}
export function SubscriptionWelcomeEmail({
email,
planName,
}: SubscriptionWelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to {siteConfig.name} {planName}!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Welcome to {planName}! 🎉</Heading>
<Text style={text}>
Thanks for subscribing. You now have access to all
premium features.
</Text>
<Link href={`${siteConfig.url}/dashboard`} style={button}>
Go to Dashboard
</Link>
<Text style={footer}>
- The {siteConfig.name} Team
</Text>
</Container>
</Body>
</Html>
);
}
// Styles...
const main = { backgroundColor: "#f6f9fc", fontFamily: "sans-serif" };
const container = { margin: "0 auto", padding: "40px 20px" };
const h1 = { color: "#1f2937", fontSize: "24px" };
const text = { color: "#4b5563", fontSize: "16px" };
const button = {
backgroundColor: "#7c3aed",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
};
const footer = { color: "#9ca3af", fontSize: "14px" };Add Email Functions
Add send functions to lib/email.ts:
import { SubscriptionWelcomeEmail } from "@/components/emails/subscription-welcome";
import { TrialStartEmail } from "@/components/emails/trial-start";
import { TrialEndingEmail } from "@/components/emails/trial-ending";
import { SubscriptionCanceledEmail } from "@/components/emails/subscription-canceled";
export async function sendSubscriptionWelcomeEmail({
email,
planName,
}: {
email: string;
planName: string;
}) {
const { error } = await resend.emails.send({
from: siteConfig.emails.from,
to: [email],
subject: `Welcome to ${siteConfig.name} ${planName}! 🎉`,
react: SubscriptionWelcomeEmail({ email, planName }),
});
if (error) throw new Error(error.message);
}
export async function sendTrialStartEmail({
email,
planName,
trialDays,
}: {
email: string;
planName: string;
trialDays: number;
}) {
const { error } = await resend.emails.send({
from: siteConfig.emails.from,
to: [email],
subject: `Your ${trialDays}-day ${planName} trial has started!`,
react: TrialStartEmail({ email, planName, trialDays }),
});
if (error) throw new Error(error.message);
}
export async function sendTrialEndingEmail({
email,
planName,
}: {
email: string;
planName: string;
}) {
const { error } = await resend.emails.send({
from: siteConfig.emails.from,
to: [email],
subject: `Your ${planName} trial is ending soon`,
react: TrialEndingEmail({ email, planName }),
});
if (error) throw new Error(error.message);
}
export async function sendSubscriptionCanceledEmail({
email,
planName,
cancelAt,
}: {
email: string;
planName: string;
cancelAt: Date | null;
}) {
const { error } = await resend.emails.send({
from: siteConfig.emails.from,
to: [email],
subject: `Your ${siteConfig.name} subscription has been canceled`,
react: SubscriptionCanceledEmail({ email, planName, cancelAt }),
});
if (error) throw new Error(error.message);
}Wire Up Lifecycle Hooks
Update the Stripe plugin in lib/auth.ts:
import {
sendSubscriptionWelcomeEmail,
sendTrialStartEmail,
sendTrialEndingEmail,
sendSubscriptionCanceledEmail,
} from "./email";
import { waitUntil } from "@vercel/functions";
// Inside the stripe() plugin configuration:
stripe({
stripeClient,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
subscription: {
enabled: true,
plans: stripePlans.map((plan) => ({
...plan,
// Trial lifecycle (if trial enabled)
...(plan.trial?.enabled && {
freeTrial: {
days: plan.trial.days,
onTrialStart: async (subscription) => {
const user = await getUserById(subscription.referenceId);
if (user?.email) {
waitUntil(
sendTrialStartEmail({
email: user.email,
planName: plan.name,
trialDays: plan.trial.days,
})
);
}
},
onTrialEnd: async ({ subscription }) => {
const user = await getUserById(subscription.referenceId);
if (user?.email) {
waitUntil(
sendTrialEndingEmail({
email: user.email,
planName: plan.name,
})
);
}
},
},
}),
})),
// Subscription lifecycle
onSubscriptionComplete: async ({ subscription, plan }) => {
const user = await getUserById(subscription.referenceId);
if (user?.email) {
waitUntil(
sendSubscriptionWelcomeEmail({
email: user.email,
planName: plan.name,
})
);
}
},
onSubscriptionCancel: async ({ subscription }) => {
const user = await getUserById(subscription.referenceId);
if (user?.email) {
waitUntil(
sendSubscriptionCanceledEmail({
email: user.email,
planName: subscription.plan,
cancelAt: subscription.cancelAt,
})
);
}
},
},
})Email Templates
Trial Start Email
export function TrialStartEmail({
email,
planName,
trialDays
}: TrialStartEmailProps) {
const trialEndDate = new Date();
trialEndDate.setDate(trialEndDate.getDate() + trialDays);
return (
<Html>
<Preview>Your {trialDays}-day trial has started!</Preview>
<Body>
<Heading>Your trial has started! 🚀</Heading>
<Text>
You have <strong>{trialDays} days</strong> of free access
to all {planName} features.
</Text>
<Text>
Your trial ends on {trialEndDate.toLocaleDateString()}.
</Text>
<Link href="/dashboard">Start Exploring</Link>
</Body>
</Html>
);
}Trial Ending Email
export function TrialEndingEmail({
email,
planName
}: TrialEndingEmailProps) {
return (
<Html>
<Preview>Your {planName} trial is ending soon</Preview>
<Body>
<Heading>Your trial is ending soon ⏰</Heading>
<Text>
Your {planName} trial ends in a few days. After that,
you'll be automatically subscribed.
</Text>
<Text>
Want to cancel? You can do so before your trial ends.
</Text>
<Link href="/dashboard/billing">Manage Subscription</Link>
</Body>
</Html>
);
}Subscription Canceled Email
export function SubscriptionCanceledEmail({
email,
planName,
cancelAt,
}: SubscriptionCanceledEmailProps) {
return (
<Html>
<Preview>Your subscription has been canceled</Preview>
<Body>
<Heading>Subscription Canceled</Heading>
<Text>
Your {planName} subscription has been canceled.
</Text>
{cancelAt && (
<Text>
You'll continue to have access until{" "}
{new Date(cancelAt).toLocaleDateString()}.
</Text>
)}
<Text>
Changed your mind? You can restore your subscription
before it ends.
</Text>
<Link href="/dashboard/billing">Restore Subscription</Link>
</Body>
</Html>
);
}Non-Blocking Emails
Always use waitUntil to send emails without blocking the webhook response:
import { waitUntil } from "@vercel/functions";
// Good - non-blocking
waitUntil(sendSubscriptionWelcomeEmail({ email, planName }));
// Bad - blocks webhook response
await sendSubscriptionWelcomeEmail({ email, planName });This is important because:
- Webhooks have a 30-second timeout
- Email sending can be slow
- Stripe retries on timeout, causing duplicate emails
Preview Emails Locally
Use the React Email dev server to preview templates:
pnpm emailOpen http://localhost:3001 to see your email templates.
Testing Emails
Using Stripe CLI
Trigger events to test email flows:
# Test subscription welcome
stripe trigger checkout.session.completed
# Test trial ending (if configured)
stripe trigger customer.subscription.trial_will_end
# Test cancellation
stripe trigger customer.subscription.deletedUsing Resend Test Mode
Resend has a test mode that doesn't send real emails:
# Use the test API key
RESEND_API_KEY="re_test_xxxxx"Best Practices
Timing
| When to Send | |
|---|---|
| Welcome | Immediately after subscription |
| Trial Start | Immediately after trial begins |
| Trial Ending | 3 days before trial ends |
| Canceled | Immediately after cancellation |
| Payment Failed | Immediately after failure |
Content Tips
- Keep it short - Users skim emails
- One CTA - Single clear action per email
- Be helpful - Link to relevant resources
- Brand consistently - Match your app's tone
What NOT to Do
- Don't send too many emails (1-2 per event max)
- Don't include sensitive data in emails
- Don't block webhook responses with email sending
- Don't forget unsubscribe links for marketing emails
Payment Failed Email
Handle failed payments to reduce churn:
onEvent: async (event) => {
if (event.type === "invoice.payment_failed") {
const invoice = event.data.object;
const user = await getUserByStripeCustomerId(invoice.customer as string);
if (user?.email) {
waitUntil(
sendPaymentFailedEmail({
email: user.email,
amount: invoice.amount_due,
updateUrl: `${siteConfig.url}/dashboard/billing`,
})
);
}
}
}Next Steps
- Billing Portal - Let users manage their own billing
- Webhooks - Deep dive into webhook handling