Monetization

Subscription renewal reminder email: template and code

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

Passing, tested 2026-10-19 · Email, Inbox

How it works

When to send renewal reminders

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.

  1. 30 days before renewal
    Your billing job starts the journey
  2. 30 days before
    Email to the billing contact
  3. 7 days before
    Email and Inbox to the admin

Stops early: Canceling or switching plans cancels every reminder 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: When the journey starts, 30 days before the plan renews

Email to the billing contact.

Email

From: Acme
Your Pro plan renews on November 15

Hi Sam,

The annual Pro plan renews on November 15 for $588.00. You don't need to do anything to keep it. To switch plans or update the card first, use the link below.

Manage subscription

Other subject lines: "Heads up: Pro renews on November 15", "Your annual renewal is coming up"

Step 2: 23 days after the first email, 7 days before the plan renews

Email and Inbox to the admin.

Email

From: Acme
Reminder: your Pro plan renews on November 15

Hi Dana,

Your annual Pro plan renews on November 15 for $588.00. If anything needs to change, now is the time.

Questions about your bill? Reply to this email.

Review subscription

In-app Inbox

Your plan renews on November 15

$588.00 will be charged for the annual Pro plan.

Review subscription

Other subject lines: "Your Pro plan renews on November 15"

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 30 days before a plan renews

Node.js

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

3. Stop it when the plan changes

Node.js

// 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}")
Full definition: journey.json, setup.ts, and the 3 templates

One wait and three sends. Your daily billing job starts the journey 30 days before renewal, so the first email goes right away to billing_email. The journey then waits 23 days and sends the admin an email and an Inbox note.

Every run is tagged with the admin's user ID, so canceling or switching plans stops it. Dates show in each recipient's timezone from their profile, and New York when it isn't set.

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

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/renewal-30d-email.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

{
"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

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

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

Data fields

FieldTypeExampleNote
profile.first_namestringDanaFrom the profile, the account admin. "there" is the fallback when it's missing
billing_namestringSam
billing_emailemailbilling@example.comWhere the 30-day email goes
plan_namestringPro
amountinteger58800In cents, as Stripe sends it. The templates show it as dollars
renews_atstring2026-11-15T17:00:00ZAn ISO timestamp. The templates format it as a date in the recipient's timezone

FAQ

Frequently asked questions

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.

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.