The trial-ending email everyone sends is the same email. Here's the one that converts
"Your trial ends in 3 days! Upgrade now to keep access." You've received a hundred of these. You've deleted a hundred of these. The email fails because it's about your product, not about the user's trial.
Why the standard trial-end email fails
The trial-end email fails for one boring reason: it's about your product, not about the user's trial. The user doesn't remember what they did in your app two weeks ago. The email's only job is to remind them - specifically - and nobody does it because pulling per-user trial activity into an email template is annoying. It stopped being annoying. Here's a trial-end email generated per user from their actual trial usage, in one API call.
The code
I meter usage with Vevee, so the trial activity already exists as data. The compose type (configured once in the dashboard) prompts: "Write a trial-ending email that recaps what this user actually built/used during the trial, names the feature they used most, and states plainly what stops working in {daysLeft} days. No fake urgency." Data sources: user usage, user events. Output schema: subject, preheader, bodyHtml, ctaText - returned as typed JSON. The cron that finds expiring trials calls compose() per user:
import { createClient } from "@vevee/sdk";
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
interface TrialEndEmail {
subject: string;
preheader: string;
bodyHtml: string;
ctaText: string;
}
for (const user of trialsEndingSoon) {
const email = await vevee.compose<TrialEndEmail>(
"trial-end-email",
user.id,
{ daysLeft: 3, planPrice: "$15/mo" }
);
if (email.status === "generated") {
await sendEmail(user.email, email.output);
await vevee.capture({ distinctId: user.id, event: "trial_ended",
properties: { email_variant: "composed" } });
} else {
await sendEmail(user.email, STATIC_TRIAL_EMAIL); // opted-out fallback
await vevee.capture({ distinctId: user.id, event: "trial_ended",
properties: { email_variant: "static" } });
}
}What lands in the inbox
Two trials, two completely different emails from the same compose type. Note what the second one does: it doesn't pretend a non-activated user is about to upgrade. It makes the one ask that might still activate them. A static template physically cannot make that choice; the composed one makes it per user, automatically.
- Power-user trial, subject: "You made 64 images this trial - here's what happens Friday"
- Power-user trial, body: "You used background removal 31 times (your top tool) and exported 12 in HD. On Friday, HD export and the API turn off. Pro is $15/mo and keeps all of it." CTA: "Keep my workflow"
- Tire-kicker trial, subject: "Your trial ends Friday - your 2 projects stay safe"
- Tire-kicker trial, body: "You tried one generation and made two projects. They won't be deleted. If you didn't get to the good part, background removal is the thing people stay for - one click to try it before Friday."
Measuring the thing
Reserved trial and checkout events make the funnel free. trial_started → trial_ended → checkout_started → checkout_completed, split by email_variant: composed vs. static trial-to-paid, one funnel.
// already captured at signup:
await vevee.capture({ distinctId: userId, event: "trial_started" });
// from the email's CTA link landing page:
await vevee.capture({ distinctId: userId, event: "checkout_started",
properties: { source: "trial_end_email", email_variant: "composed" } });
await vevee.capture({ distinctId: userId, event: "checkout_completed" });The details that matter
The generic trial email asks "do you want our product?" The composed one asks "do you want to keep doing the thing you did 64 times last week?" Only one of those is a real question. Getting there per user, automatically, comes down to a handful of details:
- Batch-friendly economics. Compose runs under a monthly dollar budget you set; each result reports usage.costMicroUsd. A nightly batch of trial emails costs cents - one recovered trial pays for years of it.
- It's server-side (sk_* key) - a perfect fit for the cron or queue you're already sending emails from.
- The opt-out branch is enforced by types. Users who opted out of AI personalization get the static template; ComposeResult is a discriminated union so you can't forget the branch.
- No fake urgency, on purpose. The prompt forbids it. "Here is exactly what you did and exactly what turns off" outperforms "LAST CHANCE" with any audience smart enough to be worth keeping.
Keep reading
I rewrote my cancel flow with one LLM call. It argues better than I do.
The cancel flow is where SaaS revenue goes to die politely. "Are you sure? You'll lose access to Premium features" has never changed a single mind - but reminding a user of their own 312 generations this month is an argument.
ComposeStop making users do math on your pricing page: recommend their plan from their own usage
Every pricing page asks the user to solve an estimation problem: "How many credits will I need per month?" Nobody knows. But for any user who has actually used your product, their usage history IS the answer - here is how to put it on the pricing page.