Retention
Win-back email sequence: template and code
Bring back users who've gone quiet, and restart the clock whenever they log in.
Passing, tested 2026-10-19 · Email, Inbox
How it works
When to send win-back emails
A win-back email sequence re-engages users who stop logging in. Fourteen days after their last login it sends an email, then an Inbox note 4 days later, and a final email 3 days after that. Every login restarts the wait, so people who come back never get these messages.
- Every loginYour login code restarts the journey
- Day 14Email
- Day 18Inbox note
- Day 21Last email
Stops early: Logging in again cancels whatever is left and starts a new 14-day wait.
The waits are Courier delay nodes. We test this exact journey with each wait shortened to seconds.
Messages
What each step sends
Step 1: 14 days after a user's last login
Email.
Hi Dana,
It's been a couple of weeks since you last logged in. Jump back in whenever you're ready.
Other subject lines: "We haven't seen you in a while", "Still want to use this?"
Step 2: 4 days after the first email
Inbox note.
In-app Inbox
It's been a while since your last visit.
Step 3: 3 days after the Inbox note
Last email.
Hi Dana,
This is the last email in this series. If you'd like to come back, your account is one click away. If something got in the way, reply and tell us.
Other subject lines: "Should we stop emailing you?"
Want a different gap? Change the P14D, P4D, and P3D waits in the journey.
Build
Add it to your app
1. Create the journey
Run setup.ts once with your API key. It creates the templates and the journey, publishes it, and prints the journey ID you pass to step 2. Copy setup.ts, journey.json, and the templates folder from the full definition at the end of this section.
Terminal
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
2. Restart it on every login
Node.js
// Restart the win-back sequence on every login, with the journey ID setup.ts// printed. It cancels the user's current run and starts a new one, so the// journey's first 14-day wait begins again. Call it at most once a day per user,// for example on their first login of the day. Nothing is passed to the journey:// the templates read the user's name and email from their Courier profile.import Courier from "@trycourier/courier";const client = new Courier();export async function restartWinBack(journeyId: string, userId: string) {await client.journeys.cancel({ cancelation_token: `reengage-${userId}` });const { runId } = await client.journeys.invoke(journeyId, {user_id: userId,});return runId;}
Python
# Restart the win-back sequence on every login, with the journey ID setup.ts# printed. It cancels the user's current run and starts a new one, so the# journey's first 14-day wait begins again. Call it at most once a day per user,# for example on their first login of the day. Nothing is passed to the journey:# the templates read the user's name and email from their Courier profile.from courier import Courierclient = Courier()def restart_win_back(journey_id: str, user_id: str) -> str:client.journeys.cancel(cancelation_token=f"reengage-{user_id}")response = client.journeys.invoke(template_id=journey_id, user_id=user_id)return response.run_id
Full definition: journey.json, setup.ts, and the 3 templates
Your login code calls restartWinBack, which cancels the user's current run and starts the journey again. The first wait is 14 days, so the first email only goes out if the user hasn't logged in since.
Each run is tagged reengage-<user ID>, so the restart finds the run to cancel. Call it at most once a day per user, for example on their first login of the day.
journey.json
{"name": "Win-back email sequence","cancelation_token": "reengage-{{recipient}}","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"type": "delay","mode": "duration","duration": "P14D"},{"type": "send","channel": "email","message": {"template": "@winback-first-email"}},{"type": "delay","mode": "duration","duration": "P4D"},{"type": "send","channel": "inbox","message": {"template": "@winback-inbox"}},{"type": "delay","mode": "duration","duration": "P3D"},{"type": "send","channel": "email","message": {"template": "@winback-last-email"}},{"type": "exit"}]}
setup.ts
// Creates this recipe's journey from journey.json and the templates/ folder,// then publishes it. Run it once per workspace://// COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts//// It prints the journey ID and the template IDs it created.import Courier from "@trycourier/courier";import { readFileSync, readdirSync } from "node:fs";const client = new Courier();const here = new URL(".", import.meta.url);const readJson = (path: string) =>JSON.parse(readFileSync(new URL(path, here), "utf8"));const spec = readJson("journey.json");// 1. Create the journey, then the templates its send nodes use.const journey = await client.journeys.create({name: spec.name,nodes: [spec.nodes[0], spec.nodes.at(-1)],});const templateIds: Record<string, string> = {};for (const file of readdirSync(new URL("templates/", here))) {const key = file.replace(/\.json$/, "");const template = await client.journeys.templates.create(journey.id,readJson(`templates/${file}`));await client.journeys.templates.publish(template.id, {templateId: journey.id,});templateIds[key] = template.id;}// 2. Save the nodes. In journey.json, "@<template>" stands for the ID of the// template with that file name. Node IDs must be unique across a workspace,// so any node ID journey.json names (used by "send_status.<id>" conditions)// gets this journey's ID added.const nodeIds = [...JSON.stringify(spec.nodes).matchAll(/"id":"([\w-]+)"/g),].map((m) => m[1]);let nodes = JSON.stringify(spec.nodes).replace(/"@([\w-]+)"/g, (_, key) =>JSON.stringify(templateIds[key]));for (const id of nodeIds)nodes = nodes.replace(new RegExp(`(?<=[".])${id}(?=")`, "g"),`${id}-${journey.id}`);await client.journeys.replace(journey.id, {name: spec.name,cancelation_token: spec.cancelation_token,nodes: JSON.parse(nodes),});// 3. Publish, so new runs use this version.await client.journeys.publish(journey.id);console.log(JSON.stringify({ journeyId: journey.id, templateIds }));
templates/winback-first-email.json
{"channel": "email","notification": {"name": "Win-back, first email","tags": ["recipe", "win-back"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "It's been a while, {{default profile.first_name \"there\"}}"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "It's been a couple of weeks since you last logged in. Jump back in whenever you're ready."},{"type": "action","content": "Log back in","href": "https://app.acme.com/login"}]}]}}}
templates/winback-inbox.json
{"channel": "inbox","notification": {"name": "Win-back, Inbox note","tags": ["recipe", "win-back"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "inbox","elements": [{"type": "meta","title": "Pick up where you left off"},{"type": "text","content": "It's been a while since your last visit."},{"type": "action","content": "Open the app","href": "https://app.acme.com"}]}]}}}
templates/winback-last-email.json
{"channel": "email","notification": {"name": "Win-back, last email","tags": ["recipe", "win-back"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Last note from us, {{default profile.first_name \"there\"}}"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "This is the last email in this series. If you'd like to come back, your account is one click away. If something got in the way, reply and tell us."},{"type": "action","content": "Log back in","href": "https://app.acme.com/login"}]}]}}}
Agents
Hand it to your agent
Give this prompt to Claude Code, Cursor, or Codex with the Courier skill installed. Agents can also read the markdown version and the manifest.
Data
Data fields
| Field | Type | Example | Note |
|---|---|---|---|
| profile.first_name | string | Dana | On the user's Courier profile. The emails say "there" when it's missing |
| profile.email | dana@example.com | Where the emails go |
FAQ
Frequently asked questions
How long should I wait before sending a win-back email?
Match the gap to how often people normally use your product. This recipe uses 14 days. To change it, edit the first wait (P14D) in the Journeys editor.
How many win-back emails should I send?
Three touches over a week is enough: an email, an Inbox note, and one last email. If they haven't come back by then, stop.
How do I stop win-back emails when someone logs back in?
Call restartWinBack from your login code. It cancels the run with the token reengage-<user ID> and starts a new one, so the 14 days count from the latest login. Call it at most once a day per user, for example on their first login of the day. For a deleted account, cancel the same token and don't invoke again.
Why is there no audience or daily job?
The journey's own 14-day wait does the counting, so your app only has to say that a user logged in. Nothing tracks days inactive, and nothing sends to a user who keeps logging in, because each login cancels the old run before it gets past its first wait.
Recipes
Related recipes
Confirm the cancellation, ask why, and make one good offer to come back.
Ask engaged users for an NPS score with one click, and survey nobody more than once every 90 days.
Nudge new users who haven't tried your key feature, and compare two versions of the first email.
Build this in your workspace
Run setup.ts against a free account and the journey is live in a minute. Prefer no code? Rebuild it in the Journeys editor.