Retention
Subscription cancellation email: template and code
Confirm the cancellation, ask why, and make one good offer to come back.
Passing, tested 2026-10-19 · Email
How it works
When to send cancellation emails
A subscription cancellation email sequence follows up after a customer cancels. It confirms the cancellation right away with the date their access ends, asks one feedback question a week later, and sends a single win-back offer after 30 days, all by email. Resubscribing stops anything that hasn't gone out.
- Customer cancelsYour webhook starts the journey
- Right awayConfirmation email
- Day 7One-question feedback email
- Day 30Offer to come back
Stops early: Resubscribing, or undoing the cancellation, cancels every message still to come.
The waits are Courier delay nodes. We test this exact journey with each wait shortened to seconds.
Messages
What each step sends
Step 1: Right after the customer cancels
Confirmation email.
Hi Dana,
Your subscription is canceled. You keep access until November 14, and your data stays put if you come back.
Other subject lines: "You've canceled your Acme subscription"
Step 2: 7 days after they cancel
One-question feedback email.
Hi Dana,
What was the main reason you canceled? Pick one. It takes a second, and it goes straight to the team.
Other subject lines: "Quick question: why did you cancel?"
Step 3: 23 days after the feedback email, 30 days after they cancel
Offer to come back.
Hi Dana,
It's been a month since you canceled. If you want to pick up where you left off, your first month back comes with 50% off.
Other subject lines: "Your account is still here, and 50% off if you come back"
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. Start it when a customer cancels
Node.js
// Start the follow-up when a customer cancels a subscription they keep until// the end of the period, with the journey ID setup.ts printed. In Stripe that's// customer.subscription.updated with cancel_at_period_end newly true (check// event.data.previous_attributes, since the event fires for every update).// Don't start it from customer.subscription.deleted: Stripe sends that when// access actually ends, and the confirmation says they keep access until then.// The confirmation sends right away, and the journey's waits (7 days, then 23)// put the feedback email at day 7 and the offer at day 30. The name comes from// the user's Courier profile, and the templates format accessEndsAt as a date.// Runs are tagged with the user's ID, so stopCancellationFollowUp takes the// same ID.import Courier from "@trycourier/courier";const client = new Courier();export async function startCancellationFollowUp(journeyId: string,userId: string,account: { plan: string; accessEndsAt: Date }) {const { runId } = await client.journeys.invoke(journeyId, {user_id: userId,data: {plan_name: account.plan,access_ends_at: account.accessEndsAt.toISOString(),},});return runId;}
Python
# Start the follow-up when a customer cancels a subscription they keep until# the end of the period, with the journey ID setup.ts printed. In Stripe that's# customer.subscription.updated with cancel_at_period_end newly true (check# event.data.previous_attributes, since the event fires for every update).# Don't start it from customer.subscription.deleted: Stripe sends that when# access actually ends, and the confirmation says they keep access until then.# The confirmation sends right away, and the journey's waits (7 days, then 23)# put the feedback email at day 7 and the offer at day 30. The name comes from# the user's Courier profile, and the templates format access_ends_at as a date.# Runs are tagged with the user's ID, so stop_cancellation_follow_up takes the# same ID.from courier import Courierclient = Courier()def start_cancellation_follow_up(journey_id: str, user_id: str, account: dict) -> str:access_ends = account["access_ends_at"]response = client.journeys.invoke(template_id=journey_id,user_id=user_id,data={"plan_name": account["plan"],"access_ends_at": access_ends.isoformat(),},)return response.run_id
3. Stop it when they resubscribe
Node.js
// Stop the follow-up when the customer resubscribes, or undoes the cancellation// (cancel_at_period_end back to false in Stripe).import Courier from "@trycourier/courier";const client = new Courier();export async function stopCancellationFollowUp(userId: string) {await client.journeys.cancel({ cancelation_token: `winback-${userId}` });}
Python
# Stop the follow-up when the customer resubscribes, or undoes the cancellation# (cancel_at_period_end back to false in Stripe).from courier import Courierclient = Courier()def stop_cancellation_follow_up(user_id: str) -> None:client.journeys.cancel(cancelation_token=f"winback-{user_id}")
Full definition: journey.json, setup.ts, and the 3 templates
Two waits and three emails. Your webhook starts the journey when the customer cancels: the confirmation goes right away and the offer lands 30 days later. Each feedback button links back to your app with the reason in the URL.
Every run is tagged with the user's ID, so one call stops it. Dates show in each recipient's timezone from their profile, and New York when it isn't set.
journey.json
{"name": "Subscription cancellation follow-up","cancelation_token": "winback-{{recipient}}","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"type": "send","channel": "email","message": {"template": "@cancel-confirm-email"}},{"type": "delay","mode": "duration","duration": "P7D"},{"type": "send","channel": "email","message": {"template": "@cancel-feedback-email"}},{"type": "delay","mode": "duration","duration": "P23D"},{"type": "send","channel": "email","message": {"template": "@winback-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/cancel-confirm-email.json
{"channel": "email","notification": {"name": "Cancellation confirmed (email)","tags": ["recipe", "subscription-cancellation-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Your {{plan_name}} subscription is canceled"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "Your subscription is canceled. You keep access until {{datetime-format access_ends_at \"MMMM d\" (default profile.timezone \"America/New_York\")}}, and your data stays put if you come back."},{"type": "action","content": "Manage your account","href": "https://app.acme.com/account"}]}]}}}
templates/cancel-feedback-email.json
{"channel": "email","notification": {"name": "Why did you cancel? (email)","tags": ["recipe", "subscription-cancellation-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "One question about why you left"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "What was the main reason you canceled? Pick one. It takes a second, and it goes straight to the team."},{"type": "action","content": "Too expensive","href": "https://app.acme.com/feedback?reason=price"},{"type": "action","content": "Missing a feature","href": "https://app.acme.com/feedback?reason=feature"},{"type": "action","content": "Switched to another tool","href": "https://app.acme.com/feedback?reason=switched"},{"type": "action","content": "Not using it enough","href": "https://app.acme.com/feedback?reason=usage"}]}]}}}
templates/winback-email.json
{"channel": "email","notification": {"name": "Come back offer (email)","tags": ["recipe", "subscription-cancellation-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "Come back to Acme with 50% off"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "It's been a month since you canceled. If you want to pick up where you left off, your first month back comes with 50% off."},{"type": "action","content": "Reactivate your account","href": "https://app.acme.com/reactivate"}]}]}}}
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 | From the profile. "there" is the fallback when it's missing |
| plan_name | string | Pro | |
| access_ends_at | string | 2026-11-14T17:00:00Z | An ISO timestamp. The confirmation formats it as a date in the recipient's timezone |
FAQ
Frequently asked questions
What should a subscription cancellation email say?
Confirm it's done, say exactly when access ends, and make coming back easy. Skip the guilt trip: a clean confirmation is what people remember.
When should I ask why someone canceled?
About a week later, in a separate email with one question and one-click answers. Asking in the confirmation itself gets fewer answers.
When should I send a win-back offer?
Around a month after they leave, once. If they resubscribe before then, cancel the run so they never see it. To change when it goes, edit the wait durations in the Journeys editor.
Recipes
Related recipes
Bring back users who've gone quiet, and restart the clock whenever they log in.
Ask engaged users for an NPS score with one click, and survey nobody more than once every 90 days.
Recover failed payments with three reminders that stop the moment the card goes through.
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.