# Expiring card reminder email: template and code

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

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

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.

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 expiring card reminders

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | When the journey starts, 30 days before the card expires | Email | Your card ending in {{card_last4}} expires soon |
| 2 | 23 days after the first email, 7 days before the card expires | Email, Inbox | Last reminder: your card expires on {{datetime-format expires_at "MMMM d" (default profile.timezone "America/New_York")}} |

## Copy

### Card expiring soon (email)

Subject: "Your card ending in {{card_last4}} expires soon", "Update your card before {{datetime-format expires_at "MMMM d" (default profile.timezone "America/New_York")}}", "Your {{card_brand}} card is about to expire"

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

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.

It takes about a minute.

Button: Update card (https://app.acme.com/billing/card)

### Card expires in 7 days (email)

Subject: "Last reminder: your card expires on {{datetime-format expires_at "MMMM d" (default profile.timezone "America/New_York")}}", "Your {{card_brand}} card expires on {{datetime-format expires_at "MMMM d" (default profile.timezone "America/New_York")}}"

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

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.

Button: Update card (https://app.acme.com/billing/card)

### Card expires in 7 days (Inbox)

Subject: "Your card expires on {{datetime-format expires_at "MMMM d" (default profile.timezone "America/New_York")}}"

Update the card ending in {{card_last4}} so your next payment goes through.

Button: Update card (https://app.acme.com/billing/card)

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

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

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

## Stop it when the card is updated

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

## Templates

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

templates/card-expiring-email.json

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

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

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

## Hand it to your agent

```text
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 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 |  |
| card_brand | string | Visa |  |
| card_last4 | string | 4242 |  |
| expires_at | string | 2026-11-30T17:00:00Z | An ISO timestamp. The templates format it as a date in the recipient's timezone |

## FAQ

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

## Related docs

- [Send a dunning email from Stripe](https://www.courier.com/docs/guides/notify-from-stripe)
- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)

## Related recipes

- [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.
- [Renewal reminders](https://www.courier.com/recipes/subscription-renewal-reminder-email): Remind annual customers before they renew, so the charge never comes as a surprise.
- [Payment receipt](https://www.courier.com/recipes/payment-receipt-email): Send a clean receipt after every payment, by email and in your app's Inbox.
