Monetization

Expiring card reminder email: template and code

Get cards updated before they expire, so a payment never fails over a date.

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

How it works

When to send expiring card reminders

An expiring card reminder gets customers to update a credit card before it expires, so a renewal payment doesn't fail. It emails them 30 days before the expiry date, then sends a second email and an Inbox note a week before. Updating the card stops any reminder that hasn't gone out.

  1. 30 days before expiry
    Your billing job starts the journey
  2. 30 days before
    Email
  3. 7 days before
    Email and Inbox

Stops early: Updating the card 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 card expires

Email.

Email

From: Acme
Your card ending in 4242 expires soon

Hi Dana,

The Visa card ending in 4242 expires on November 30. Update it now so your next Pro payment goes through.

It takes about a minute.

Update card

Other subject lines: "Update your card before November 30", "Your Visa card is about to expire"

Step 2: 23 days after the first email, 7 days before the card expires

Email and Inbox.

Email

From: Acme
Last reminder: your card expires on November 30

Hi Dana,

Your Visa card ending in 4242 expires on November 30. Update it before then so your Pro payment doesn't fail.

Update card

In-app Inbox

Your card expires on November 30

Update the card ending in 4242 so your next payment goes through.

Update card

Other subject lines: "Your Visa card expires on November 30"

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

Node.js

// Start the reminders when a card is 30 days from expiring, with the journey ID
// setup.ts printed. Call it from your daily billing job. The first email sends
// right away, and the journey's 23-day wait puts the second one a week before
// the card expires. The name comes from the user's Courier profile, and the
// templates format expiresAt as a date. Runs are tagged with the user's ID, so
// stopCardReminders takes the same ID.
import Courier from "@trycourier/courier";
const client = new Courier();
export async function startCardReminders(
journeyId: string,
userId: string,
card: {
plan: string;
brand: string;
last4: string;
expiresAt: Date;
}
) {
const { runId } = await client.journeys.invoke(journeyId, {
user_id: userId,
data: {
plan_name: card.plan,
card_brand: card.brand,
card_last4: card.last4,
expires_at: card.expiresAt.toISOString(),
},
});
return runId;
}

Python

# Start the reminders when a card is 30 days from expiring, with the journey ID
# setup.ts printed. Call it from your daily billing job. The first email sends
# right away, and the journey's 23-day wait puts the second one a week before
# the card expires. The name comes from the user's Courier profile, and the
# templates format expires_at as a date. Runs are tagged with the user's ID, so
# stop_card_reminders takes the same ID.
from datetime import datetime
from courier import Courier
client = Courier()
def start_card_reminders(journey_id: str, user_id: str, card: dict) -> str:
expires_at: datetime = card["expires_at"]
response = client.journeys.invoke(
template_id=journey_id,
user_id=user_id,
data={
"plan_name": card["plan"],
"card_brand": card["brand"],
"card_last4": card["last4"],
"expires_at": expires_at.isoformat(),
},
)
return response.run_id

3. Stop it when the card is updated

Node.js

// Stop the reminders when the customer updates their card: call this from the
// code that saves the new card. It cancels every active run for the user,
// and does nothing if the reminders already finished.
import Courier from "@trycourier/courier";
const client = new Courier();
export async function stopCardReminders(userId: string) {
await client.journeys.cancel({ cancelation_token: `card-${userId}` });
}

Python

# Stop the reminders when the customer updates their card: call this from the
# code that saves the new card. It cancels every active run for the user,
# and does nothing if the reminders already finished.
from courier import Courier
client = Courier()
def stop_card_reminders(user_id: str) -> None:
client.journeys.cancel(cancelation_token=f"card-{user_id}")
Full definition: journey.json, setup.ts, and the 3 templates

One wait, three sends. The first email goes out when your billing job finds a card 30 days from expiring, then the journey waits 23 days so the second email and Inbox note land a week before.

Every run is tagged with the user's ID, so a card update stops it. Dates show in each recipient's timezone from their profile, and New York when it isn't set.

journey.json

{
"name": "Expiring card reminders",
"cancelation_token": "card-{{recipient}}",
"nodes": [
{
"type": "trigger",
"trigger_type": "api-invoke"
},
{
"type": "send",
"channel": "email",
"message": {
"template": "@card-expiring-email"
}
},
{
"type": "delay",
"mode": "duration",
"duration": "P23D"
},
{
"type": "send",
"channel": "email",
"message": {
"template": "@card-expiring-final-email"
}
},
{
"type": "send",
"channel": "inbox",
"message": {
"template": "@card-expiring-final-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/card-expiring-email.json

{
"channel": "email",
"notification": {
"name": "Card expiring soon (email)",
"tags": ["recipe", "expiring-card"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "Your card ending in {{card_last4}} expires soon"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "The {{card_brand}} card ending in {{card_last4}} expires on {{datetime-format expires_at \"MMMM d\" (default profile.timezone \"America/New_York\")}}. Update it now so your next {{plan_name}} payment goes through."
},
{
"type": "action",
"content": "Update card",
"href": "https://app.acme.com/billing/card"
},
{
"type": "text",
"content": "It takes about a minute."
}
]
}
]
}
}
}

templates/card-expiring-final-email.json

{
"channel": "email",
"notification": {
"name": "Card expires in 7 days (email)",
"tags": ["recipe", "expiring-card"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "Last reminder: your card expires on {{datetime-format expires_at \"MMMM d\" (default profile.timezone \"America/New_York\")}}"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "Your {{card_brand}} card ending in {{card_last4}} expires on {{datetime-format expires_at \"MMMM d\" (default profile.timezone \"America/New_York\")}}. Update it before then so your {{plan_name}} payment doesn't fail."
},
{
"type": "action",
"content": "Update card",
"href": "https://app.acme.com/billing/card"
}
]
}
]
}
}
}

templates/card-expiring-final-inbox.json

{
"channel": "inbox",
"notification": {
"name": "Card expires in 7 days (Inbox)",
"tags": ["recipe", "expiring-card"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "inbox",
"elements": [
{
"type": "meta",
"title": "Your card expires on {{datetime-format expires_at \"MMMM d\" (default profile.timezone \"America/New_York\")}}"
},
{
"type": "text",
"content": "Update the card ending in {{card_last4}} so your next payment goes through."
},
{
"type": "action",
"content": "Update card",
"href": "https://app.acme.com/billing/card"
}
]
}
]
}
}
}

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 expiring card reminders to this app, sent through Courier: an email 30 days before a customer's card expires, then an email and in-app note 7 days before, stopped as soon as they update the card. Follow the steps at https://www.courier.com/recipes/expiring-card-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_CARD_JOURNEY_ID. Replace the placeholder billing link (https://app.acme.com/billing/card) in the templates with ours. 2. Find where this codebase checks payment methods (a daily billing job, or the payment provider's card-expiring webhook) and start the journey with the user's ID when a card is 30 days from expiring. Pass the fields in the Data fields table from our own data. 3. Find where a customer saves a new card and cancel the journey with the token card-<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. "there" is the fallback when it's missing
plan_namestringPro
card_brandstringVisa
card_last4string4242
expires_atstring2026-11-30T17:00:00ZAn ISO timestamp. The templates format it as a date in the recipient's timezone

FAQ

Frequently asked questions

How early should I warn customers that their card is expiring?

30 days gives people time to act, and a second reminder a week before the expiry date catches anyone who hasn't. Closer than that leaves little room before a payment fails. To change the gap between the emails, edit the wait in the Journeys editor.

How do I stop the reminders once a customer updates their card?

Each run is tagged with the user's ID, so cancel the token card-<user_id> from the code that saves the new card. Courier stops every reminder still waiting and leaves finished runs alone.

How do I find which cards are about to expire?

Your payment provider returns each card's expiry month and year. Run a daily billing job that looks for cards expiring in 30 days and starts the journey for each customer.

What is involuntary churn, and does this prevent it?

Involuntary churn is a customer lost to a failed payment rather than a decision to cancel. An expired card is a common cause, and an update before the payment date keeps the charge from failing.

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.