# Subscription cancellation email: template and code

Source: https://www.courier.com/recipes/subscription-cancellation-email (manifest: https://www.courier.com/recipes/subscription-cancellation-email/manifest.json)

Confirm the cancellation, ask why, and make one good offer to come back.

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.

Tested 2026-10-19: passing.

The waits are Courier delay nodes. We test this exact journey with each wait shortened to seconds.

## When to send cancellation emails

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | Right after the customer cancels | Email | Your {{plan_name}} subscription is canceled |
| 2 | 7 days after they cancel | Email | One question about why you left |
| 3 | 23 days after the feedback email, 30 days after they cancel | Email | Come back to Acme with 50% off |

## Copy

### Cancellation confirmed (email)

Subject: "Your {{plan_name}} subscription is canceled", "You've canceled your Acme subscription"

Hi {{default profile.first_name "there"}},

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.

Button: Manage your account (https://app.acme.com/account)

### Why did you cancel? (email)

Subject: "One question about why you left", "Quick question: why did you cancel?"

Hi {{default profile.first_name "there"}},

What was the main reason you canceled? Pick one. It takes a second, and it goes straight to the team.

Button: Too expensive (https://app.acme.com/feedback?reason=price)

Button: Missing a feature (https://app.acme.com/feedback?reason=feature)

Button: Switched to another tool (https://app.acme.com/feedback?reason=switched)

Button: Not using it enough (https://app.acme.com/feedback?reason=usage)

### Come back offer (email)

Subject: "Come back to Acme with 50% off", "Your account is still here, and 50% off if you come back"

Hi {{default profile.first_name "there"}},

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.

Button: Reactivate your account (https://app.acme.com/reactivate)

## The 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"
    }
  ]
}
```

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
```

setup.ts

```typescript
// 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 }));
```

## Start it when a customer cancels

```typescript
// 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 Courier

client = 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
```

## Stop it when they resubscribe

```typescript
// 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 Courier

client = Courier()


def stop_cancellation_follow_up(user_id: str) -> None:
    client.journeys.cancel(cancelation_token=f"winback-{user_id}")
```

## Templates

setup.ts reads these from a templates/ folder next to it.

templates/cancel-confirm-email.json

```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

```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

```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"
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
Add cancellation follow-up emails to this app, sent through Courier: a confirmation right away, a one-question feedback email after 7 days, and one offer to come back after 30 days. Follow the steps at https://www.courier.com/recipes/subscription-cancellation-email.md and use the Courier skill for SDK details.

1. Create the journey and its 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_CANCELLATION_JOURNEY_ID. Replace the placeholder product name (Acme), the app.acme.com links, and the offer (50% off) in the templates with ours.
2. Find where a customer cancels a subscription and keeps access until the period ends (in Stripe, customer.subscription.updated when cancel_at_period_end changes to true, not customer.subscription.deleted, which fires when access actually ends) and start the journey there with the user's ID. Pass the fields in the Data fields table from our own data.
3. Find where an account resubscribes, or undoes the cancellation (cancel_at_period_end back to false in Stripe), and cancel the journey with the token winback-<user ID>.
4. Match this codebase's language, file layout, and error handling. Never hardcode the API key.
5. When you're done, start one run for a test user and show me the run's steps. The confirmation sends right away and the run then waits 7 days, so cancel the test run once you've checked it.
```

## 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

### 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.

## Related docs

- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Send node conditions](https://www.courier.com/docs/journeys/nodes/send)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)

## Related recipes

- [Win-back sequence](https://www.courier.com/recipes/win-back-email-sequence): Bring back users who've gone quiet, and restart the clock whenever they log in.
- [NPS survey email](https://www.courier.com/recipes/nps-survey-email): Ask engaged users for an NPS score with one click, and survey nobody more than once every 90 days.
- [Failed payment recovery](https://www.courier.com/recipes/dunning-email-sequence): Recover failed payments with three reminders that stop the moment the card goes through.
