# Subscription renewal reminder email: template and code

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

Remind annual customers before they renew, so the charge never comes as a surprise.

A subscription renewal reminder tells annual customers their plan is about to renew, so the charge never surprises them. It emails the billing contact 30 days ahead with the date and amount, then emails the account admin and leaves an Inbox note a week ahead. Canceling or switching plans stops the reminders.

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 renewal reminders

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | When the journey starts, 30 days before the plan renews | Email | Your {{plan_name}} plan renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}} |
| 2 | 23 days after the first email, 7 days before the plan renews | Email, Inbox | Reminder: your {{plan_name}} plan renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}} |

## Copy

### Plan renews in 30 days (email)

Subject: "Your {{plan_name}} plan renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}}", "Heads up: {{plan_name}} renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}}", "Your annual renewal is coming up"

Hi {{billing_name}},

The annual {{plan_name}} plan renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}} for {{format "$%.2f" (divide amount 100)}}. You don't need to do anything to keep it. To switch plans or update the card first, use the link below.

Button: Manage subscription (https://app.acme.com/billing)

### Plan renews in 7 days (email)

Subject: "Reminder: your {{plan_name}} plan renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}}", "Your {{plan_name}} plan renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}}"

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

Your annual {{plan_name}} plan renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}} for {{format "$%.2f" (divide amount 100)}}. If anything needs to change, now is the time.

Questions about your bill? Reply to this email.

Button: Review subscription (https://app.acme.com/billing)

### Plan renews in 7 days (Inbox)

Subject: "Your plan renews on {{datetime-format renews_at "MMMM d" (default profile.timezone "America/New_York")}}"

{{format "$%.2f" (divide amount 100)}} will be charged for the annual {{plan_name}} plan.

Button: Review subscription (https://app.acme.com/billing)

## The journey

```json
{
  "name": "Subscription renewal reminders",
  "cancelation_token": "renewal-{{recipient}}",
  "nodes": [
    {
      "type": "trigger",
      "trigger_type": "api-invoke"
    },
    {
      "type": "send",
      "channel": "email",
      "message": {
        "template": "@renewal-30d-email",
        "to": {
          "email_override": "{{data.billing_email}}"
        }
      }
    },
    {
      "type": "delay",
      "mode": "duration",
      "duration": "P23D"
    },
    {
      "type": "send",
      "channel": "email",
      "message": {
        "template": "@renewal-7d-email"
      }
    },
    {
      "type": "send",
      "channel": "inbox",
      "message": {
        "template": "@renewal-7d-inbox"
      }
    },
    {
      "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 30 days before a plan renews

```typescript
// Start the reminders 30 days before an annual plan renews, with the journey ID
// setup.ts printed. Call it from a daily billing job that finds subscriptions
// renewing in 30 days, or from your billing provider's upcoming-renewal webhook
// if it fires 30 days out. The first email sends right away, so don't start the
// journey when the plan begins. The journey's 23-day wait puts the second
// reminder 7 days before the renewal.
// The journey runs as the account admin, and their name comes from their
// Courier profile. The templates format renewsAt as a date and amount (in
// cents, as Stripe sends it) as dollars. Runs are tagged with the admin's ID,
// so stopRenewalReminders takes the same ID.
import Courier from "@trycourier/courier";

const client = new Courier();

export async function startRenewalReminders(
  journeyId: string,
  adminId: string,
  subscription: {
    plan: string;
    amount: number;
    renewsAt: Date;
    billingName: string;
    billingEmail: string;
  }
) {
  const { runId } = await client.journeys.invoke(journeyId, {
    user_id: adminId,
    data: {
      billing_name: subscription.billingName,
      billing_email: subscription.billingEmail,
      plan_name: subscription.plan,
      amount: subscription.amount,
      renews_at: subscription.renewsAt.toISOString(),
    },
    // A job that runs twice for the same renewal starts one run, not two.
    "Idempotency-Key": `renewal-${adminId}-${subscription.renewsAt
      .toISOString()
      .slice(0, 10)}`,
  });
  return runId;
}
```

```python
# Start the reminders 30 days before an annual plan renews, with the journey ID
# setup.ts printed. Call it from a daily billing job that finds subscriptions
# renewing in 30 days, or from your billing provider's upcoming-renewal webhook
# if it fires 30 days out. The first email sends right away, so don't start the
# journey when the plan begins. The journey's 23-day wait puts the second
# reminder 7 days before the renewal.
# The journey runs as the account admin, and their name comes from their
# Courier profile. The templates format renews_at as a date and amount (an int
# in cents, as Stripe sends it) as dollars. Runs are tagged with the admin's ID,
# so stop_renewal_reminders takes the same ID.
from datetime import datetime, timezone

from courier import Courier

client = Courier()


def start_renewal_reminders(journey_id: str, admin_id: str, subscription: dict) -> str:
    # Timezone-aware; normalized to UTC so the key matches the Node version.
    renews_at: datetime = subscription["renews_at"].astimezone(timezone.utc)
    response = client.journeys.invoke(
        template_id=journey_id,
        user_id=admin_id,
        data={
            "billing_name": subscription["billing_name"],
            "billing_email": subscription["billing_email"],
            "plan_name": subscription["plan"],
            "amount": subscription["amount"],
            "renews_at": renews_at.isoformat(),
        },
        # A job that runs twice for the same renewal starts one run, not two.
        idempotency_key=f"renewal-{admin_id}-{renews_at:%Y-%m-%d}",
    )
    return response.run_id
```

## Stop it when the plan changes

```typescript
// Stop the reminders when the customer cancels or switches plans: call this
// from the code that handles either. It cancels every active run for the
// admin, and does nothing if the reminders already finished.
import Courier from "@trycourier/courier";

const client = new Courier();

export async function stopRenewalReminders(adminId: string) {
  await client.journeys.cancel({ cancelation_token: `renewal-${adminId}` });
}
```

```python
# Stop the reminders when the customer cancels or switches plans: call this
# from the code that handles either. It cancels every active run for the
# subscription, and does nothing if the reminders already finished.
from courier import Courier

client = Courier()


def stop_renewal_reminders(admin_id: str) -> None:
    client.journeys.cancel(cancelation_token=f"renewal-{admin_id}")
```

## Templates

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

templates/renewal-30d-email.json

```json
{
  "channel": "email",
  "notification": {
    "name": "Plan renews in 30 days (email)",
    "tags": ["recipe", "renewal-reminder"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Your {{plan_name}} plan renews on {{datetime-format renews_at \"MMMM d\" (default profile.timezone \"America/New_York\")}}"
            },
            {
              "type": "text",
              "content": "Hi {{billing_name}},"
            },
            {
              "type": "text",
              "content": "The annual {{plan_name}} plan renews on {{datetime-format renews_at \"MMMM d\" (default profile.timezone \"America/New_York\")}} for {{format \"$%.2f\" (divide amount 100)}}. You don't need to do anything to keep it. To switch plans or update the card first, use the link below."
            },
            {
              "type": "action",
              "content": "Manage subscription",
              "href": "https://app.acme.com/billing"
            }
          ]
        }
      ]
    }
  }
}
```

templates/renewal-7d-email.json

```json
{
  "channel": "email",
  "notification": {
    "name": "Plan renews in 7 days (email)",
    "tags": ["recipe", "renewal-reminder"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "email",
          "elements": [
            {
              "type": "meta",
              "title": "Reminder: your {{plan_name}} plan renews on {{datetime-format renews_at \"MMMM d\" (default profile.timezone \"America/New_York\")}}"
            },
            {
              "type": "text",
              "content": "Hi {{default profile.first_name \"there\"}},"
            },
            {
              "type": "text",
              "content": "Your annual {{plan_name}} plan renews on {{datetime-format renews_at \"MMMM d\" (default profile.timezone \"America/New_York\")}} for {{format \"$%.2f\" (divide amount 100)}}. If anything needs to change, now is the time."
            },
            {
              "type": "action",
              "content": "Review subscription",
              "href": "https://app.acme.com/billing"
            },
            {
              "type": "text",
              "content": "Questions about your bill? Reply to this email."
            }
          ]
        }
      ]
    }
  }
}
```

templates/renewal-7d-inbox.json

```json
{
  "channel": "inbox",
  "notification": {
    "name": "Plan renews in 7 days (Inbox)",
    "tags": ["recipe", "renewal-reminder"],
    "brand": null,
    "subscription": null,
    "content": {
      "version": "2022-01-01",
      "elements": [
        {
          "type": "channel",
          "channel": "inbox",
          "elements": [
            {
              "type": "meta",
              "title": "Your plan renews on {{datetime-format renews_at \"MMMM d\" (default profile.timezone \"America/New_York\")}}"
            },
            {
              "type": "text",
              "content": "{{format \"$%.2f\" (divide amount 100)}} will be charged for the annual {{plan_name}} plan."
            },
            {
              "type": "action",
              "content": "Review subscription",
              "href": "https://app.acme.com/billing"
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
Add renewal reminders for annual plans to this app, sent through Courier: an email to the billing contact 30 days before renewal, then an email and in-app note to the account admin 7 days before, stopped if the customer cancels or switches plans. Follow the steps at https://www.courier.com/recipes/subscription-renewal-reminder-email.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_RENEWAL_JOURNEY_ID. Replace the placeholder billing link (https://app.acme.com/billing) in the templates with ours.
2. Find where this codebase handles billing and add a daily job that finds annual subscriptions renewing in 30 days (or use the billing provider's upcoming-renewal webhook, if it fires 30 days out). Start the journey for each one, with the admin's ID. Don't start it when the plan begins: the first email sends right away, so it has to start at the 30-day mark. Pass the fields in the Data fields table from our own data.
3. Find where a subscription is canceled or changed to another plan and cancel the journey with the token renewal-<admin 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 first email sends right away and the run then waits 23 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, the account admin. "there" is the fallback when it's missing |
| billing_name | string | Sam |  |
| billing_email | email | billing@example.com | Where the 30-day email goes |
| plan_name | string | Pro |  |
| amount | integer | 58800 | In cents, as Stripe sends it. The templates show it as dollars |
| renews_at | string | 2026-11-15T17:00:00Z | An ISO timestamp. The templates format it as a date in the recipient's timezone |

## FAQ

### How far ahead should I remind customers about an annual renewal?

30 days gives them time to switch plans or cancel, and a second reminder a week out catches anyone who missed the first. Start the journey at the 30-day mark from a daily billing job, because the first email sends right away. To change the gap, edit the wait in the Journeys editor.

### Can the renewal email go to a billing contact instead of the account owner?

Yes. A recipient override on the send node routes the email to the address in your data, so the billing contact doesn't need a profile in Courier or an account in your app.

### How do I stop renewal reminders if the customer cancels or changes plans?

Each run is tagged with the admin's user ID, so cancel the token renewal-<user_id> from the code that handles the cancellation or plan change. Courier stops every reminder still waiting.

### What if the plan renews in less than 30 days when I start the journey?

The journey waits a fixed 23 days after the first email, so a late start sends the 7-day reminder later than 7 days before the renewal, and after it if the plan renews in under 23 days. Start a shorter version instead: set up a copy of the journey with the wait set to the days between now and 7 days before the renewal (P13D for a plan renewing in 20 days), and start that one. If the plan renews in under 7 days, skip ahead: use a copy with no first email and no wait, so the 7-day reminders send right away.

## Related docs

- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Send node recipient override](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

- [Expiring card reminders](https://www.courier.com/recipes/expiring-card-reminder-email): Get cards updated before they expire, so a payment never fails over a date.
- [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.
- [Cancellation follow-up](https://www.courier.com/recipes/subscription-cancellation-email): Confirm the cancellation, ask why, and make one good offer to come back.
