BlogCompose

Win-back emails fail because they're written for "users." Write them for the one user instead.

Every dormant-user campaign in history: "We miss you! Here's what's new." Open rate: pity clicks. The email fails because it's about your changelog - and the user left because of something in their experience.

The setup

Every dormant-user campaign in history: "We miss you! Here's what's new." Open rate: pity clicks. The email fails because it's about your changelog, and the user left because of something in their experience - they finished a project, hit a friction point, or forgot what your product was for. A win-back email that works has to reconstruct that context per user. Manually, that's impossible at scale. Generated from each user's own usage history, it's a batch job. Vevee meters my users' AI usage (@vevee/sdk), which means every dormant user has a trail: what they used, how often, and what they were doing right before they went quiet. compose() turns that trail into the email. The compose type prompt: "This user has been inactive for {daysInactive} days. From their past usage, identify what they used the product FOR, and write a short email that resumes that thread - reference their actual work, suggest the single most natural next action. Mention a new feature only if it's directly relevant to how they used the product." Data sources: user events, user usage, user attributes, popular features.

Only send when status === "generated". Users who opted out of AI personalization come back as opted_out - skip them or fall back to a plain template; never route around the opt-out.
import { createClient } from "@vevee/sdk";

const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });

interface WinbackEmail {
  subject: string;
  body: string;
  ctaText: string;
  ctaHref: string;
}

for (const user of dormantUsers) {       // e.g. no events in 30 days
  const email = await vevee.compose<WinbackEmail>(
    "winback-email",
    user.id,
    { daysInactive: user.daysInactive }
  );

  if (email.status === "generated") {
    await sendEmail(user.email, email.output);
  }
  // opted_out → skip or send the plain template; your call
}

What goes out

The same compose type produces completely different emails depending on the trail it finds. Two real shapes - one for a user whose project ended, one for a user your pricing pushed out:

  • To a user who generated 40 product photos in March and vanished - Subject: Your product-shot setup is still here. "You made 40 product images in March - then went quiet right after the spring batch. If there's a new drop coming, your presets and styles are exactly where you left them. One click to pick the thread back up."
  • To a user who churned two days after hitting a quota - Subject: You hit the wall on day 12 - that wall moved. "You stopped right after maxing out the Free tier. Since then we doubled the Free limit. Your projects are intact; the ceiling that stopped you isn't where it was."
That second email is the killer. "Churned immediately after hitting a limit" is a diagnosable cause of churn sitting in your metering data, and the composed email addresses the cause instead of spraying a changelog at it.

Track resurrection like a funnel

The SDK's analytics side closes the loop with reserved events. signed_in → feature_used with source: "winback_email" is your real resurrection rate - not opens, not clicks, returned to doing the thing. If you later add a discount variant, tag variant in properties and compare in the same funnel.

// CTA link lands them here:
await vevee.capture({ distinctId: userId, event: "signed_in",
  properties: { source: "winback_email" } });

// did they actually do something?
await vevee.capture({ distinctId: userId, event: "feature_used",
  properties: { source: "winback_email" } });

Why this beats the discount

The default win-back lever is "20% off if you come back," which trains users to churn for coupons and attracts exactly the users who'll churn again. The composed email's lever is unfinished work and remembered context - "your presets are still here" - which selects for users who had a real use case. Cheaper, and the cohort it recovers is worth more. Operationally: it's a nightly cron, server-side (sk_* key), capped by the monthly compose budget in the dashboard, with per-call cost reported in usage.costMicroUsd. A thousand dormant users costs less to email than one coffee - and unlike the discount, the margin survives.

See your own numbers in two minutes.Add the SDK and your users, costs and revenue fill in on the next request.
Join waitlist

Keep reading

ShareCopy a link to this post.

Set your first limit in about five minutes.

50k operations free every month. No card required.
Join waitlist