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.
- 30 days before renewalYour billing job starts the journey
- 30 days beforeEmail to the billing contact
- 7 days beforeEmail 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.
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.
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.
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.
In-app Inbox
$588.00 will be charged for the annual Pro plan.
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, timezonefrom courier import Courierclient = 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 Courierclient = 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.
Data
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 | 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
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.
Recipes
Related recipes
Get cards updated before they expire, so a payment never fails over a date.
Recover failed payments with three reminders that stop the moment the card goes through.
Confirm the cancellation, ask why, and make one good offer to come back.
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.