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.

  1. Every login
    Your login code restarts the journey
  2. Day 14
    Email
  3. Day 18
    Inbox note
  4. Day 21
    Last 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.

Email

From: Acme
It's been a while, Dana

Hi Dana,

It's been a couple of weeks since you last logged in. Jump back in whenever you're ready.

Log back in

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

Pick up where you left off

It's been a while since your last visit.

Open the app

Step 3: 3 days after the Inbox note

Last email.

Email

From: Acme
Last note from us, Dana

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.

Log back in

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 Courier
client = 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.

Add a win-back email sequence to this app, sent through Courier: an email 14 days after a user's last login, an Inbox note 4 days later, and a last email 3 days after that, restarted every time they log in. Follow the steps at https://www.courier.com/recipes/win-back-email-sequence.md and use the Courier skill for SDK details. 1. Create the journey and its three templates in my Courier workspace by running setup.ts from that page with COURIER_API_KEY from our environment. Save the journey ID it prints as COURIER_WINBACK_JOURNEY_ID. 2. Find where a user logs in and call restartWinBack from start.ts with that journey ID and the user's ID, at most once a day per user (for example on their first login of the day). It cancels the journey with the token reengage-<user_id> and starts it again. 3. The templates read first_name and email from the user's Courier profile, so make sure those are written at signup. 4. Match this codebase's language, file layout, and error handling. Never hardcode the API key. 5. When you're done, call restartWinBack for a test user and show me the run that starts for them.

Data

Data fields

FieldTypeExampleNote
profile.first_namestringDanaOn the user's Courier profile. The emails say "there" when it's missing
profile.emailemaildana@example.comWhere 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.

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.