Monetization

Trial ending email sequence: template and code

Remind trial users before their trial ends, and stop the moment they upgrade.

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

How it works

When to send trial ending emails

A trial ending email sequence reminds users that their free trial is about to expire. It sends an email and an Inbox note 7 days before the trial ends, another a day before, with a text if the first email wasn't opened, and a final email when it ends. Upgrading stops the rest.

  1. Trial starts
    Your app starts the journey at signup
  2. 7 days before
    Email and Inbox
  3. 1 day before
    Email and Inbox
    Plus SMS, only if the first email wasn't opened
  4. Trial ends
    Email

Stops early: Upgrade at any point and your app cancels every message 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: 7 days before the trial ends, after the journey's first wait

Email and Inbox.

Email

From: Acme
Your trial ends on October 29

Hi Dana,

Your Pro trial ends on October 29. Pick a plan to keep everything running with no gap.

Not sure which plan fits? Reply to this email.

Choose a plan

In-app Inbox

Your trial ends on October 29

Pick a plan to keep everything running with no gap.

Choose a plan

Other subject lines: "Your Pro trial ends on October 29", "Pick a plan before October 29"

Step 2: 6 days after step 1, 1 day before the trial ends

Email and Inbox. Plus SMS, only if the first email wasn't opened.

Email

From: Acme
Your trial ends tomorrow

Hi Dana,

Your Pro trial ends tomorrow, October 29. Choose a plan today and nothing changes for you or your team.

Choose a plan

In-app Inbox

Your trial ends tomorrow

Choose a plan today to keep your account running.

Choose a plan

SMS

Acme: your trial ends tomorrow. Keep your account running: https://app.acme.com/billing Reply STOP to opt out.

Other subject lines: "Last day of your Pro trial"

Step 3: 1 day after step 2, when the trial ends

Email.

Email

From: Acme
Your trial has ended

Hi Dana,

Your Pro trial ended today. Everything you set up is saved, so you can pick up where you left off when you choose a plan.

Choose a plan

Other subject lines: "Your trial ended. Your work is still here"

The first wait assumes a 14-day trial (P7D). For another length, set it to the trial length minus 7 days, like P3D for a 10-day trial.

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 when the trial starts

Node.js

// Start the reminders when a trial starts, with the journey ID setup.ts
// printed. The journey's first wait (P7D, for a 14-day trial) holds the first
// reminder until 7 days before the trial ends, and its later waits time the
// rest. For another trial length, change P7D to the trial length minus 7 days.
// Names come from the user's Courier profile. Runs are tagged with the user's
// ID, so stopTrialReminders takes the same ID.
import Courier from "@trycourier/courier";
const client = new Courier();
export async function startTrialReminders(
journeyId: string,
userId: string,
trial: { plan: string; endsAt: Date }
) {
const { runId } = await client.journeys.invoke(journeyId, {
user_id: userId,
data: {
plan_name: trial.plan,
trial_end_date: trial.endsAt.toISOString(),
},
});
return runId;
}

Python

# Start the reminders when a trial starts, with the journey ID setup.ts
# printed. The journey's first wait (P7D, for a 14-day trial) holds the first
# reminder until 7 days before the trial ends, and its later waits time the
# rest. For another trial length, change P7D to the trial length minus 7 days.
# Names come from the user's Courier profile. Runs are tagged with the user's
# ID, so stop_trial_reminders takes the same ID.
from datetime import datetime
from courier import Courier
client = Courier()
def start_trial_reminders(journey_id: str, user_id: str, trial: dict) -> str:
ends_at: datetime = trial["ends_at"]
response = client.journeys.invoke(
template_id=journey_id,
user_id=user_id,
data={
"plan_name": trial["plan"],
"trial_end_date": ends_at.isoformat(),
},
)
return response.run_id

3. Stop it when they upgrade

Node.js

// Stop the reminders when the account upgrades: call this from your billing
// webhook. It cancels every active run for the user, and does nothing if
// the sequence already finished.
import Courier from "@trycourier/courier";
const client = new Courier();
export async function stopTrialReminders(userId: string) {
await client.journeys.cancel({ cancelation_token: `trial-${userId}` });
}

Python

# Stop the reminders when the account upgrades: call this from your billing
# webhook. It cancels every active run for the user, and does nothing if
# the sequence already finished.
from courier import Courier
client = Courier()
def stop_trial_reminders(user_id: str) -> None:
client.journeys.cancel(cancelation_token=f"trial-{user_id}")
Full definition: journey.json, setup.ts, and the 6 templates

Three waits and one condition. The journey holds until 7 days before the trial ends, then sends each reminder on schedule. The SMS goes only if the first email wasn't opened and the user has a phone number.

Names come from the user's Courier profile, with "there" as the fallback. Dates show in each recipient's timezone from their profile, and New York when it isn't set. Pass trial_end_date as an ISO timestamp.

journey.json

{
"name": "Trial ending reminders",
"cancelation_token": "trial-{{recipient}}",
"nodes": [
{
"type": "trigger",
"trigger_type": "api-invoke"
},
{
"type": "delay",
"mode": "duration",
"duration": "P7D"
},
{
"id": "trial-7d-email",
"type": "send",
"channel": "email",
"message": {
"template": "@trial-7d-email"
}
},
{
"type": "send",
"channel": "inbox",
"message": {
"template": "@trial-7d-inbox"
}
},
{
"type": "delay",
"mode": "duration",
"duration": "P6D"
},
{
"type": "send",
"channel": "email",
"message": {
"template": "@trial-1d-email"
}
},
{
"type": "send",
"channel": "inbox",
"message": {
"template": "@trial-1d-inbox"
}
},
{
"type": "send",
"channel": "sms",
"message": {
"template": "@trial-1d-sms"
},
"conditions": {
"AND": [
["send_status.trial-7d-email", "was not", "OPENED"],
["profile.phone_number", "exists"]
]
}
},
{
"type": "delay",
"mode": "duration",
"duration": "P1D"
},
{
"type": "send",
"channel": "email",
"message": {
"template": "@trial-ended-email"
}
},
{
"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/trial-7d-email.json

{
"channel": "email",
"notification": {
"name": "First trial reminder (email)",
"tags": ["recipe", "trial-ending"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "Your trial ends on {{datetime-format trial_end_date \"MMMM d\" (default profile.timezone \"America/New_York\")}}"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "Your {{plan_name}} trial ends on {{datetime-format trial_end_date \"MMMM d\" (default profile.timezone \"America/New_York\")}}. Pick a plan to keep everything running with no gap."
},
{
"type": "action",
"content": "Choose a plan",
"href": "https://app.acme.com/billing"
},
{
"type": "text",
"content": "Not sure which plan fits? Reply to this email."
}
]
}
]
}
}
}

templates/trial-7d-inbox.json

{
"channel": "inbox",
"notification": {
"name": "First trial reminder (Inbox)",
"tags": ["recipe", "trial-ending"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "inbox",
"elements": [
{
"type": "meta",
"title": "Your trial ends on {{datetime-format trial_end_date \"MMMM d\" (default profile.timezone \"America/New_York\")}}"
},
{
"type": "text",
"content": "Pick a plan to keep everything running with no gap."
},
{
"type": "action",
"content": "Choose a plan",
"href": "https://app.acme.com/billing"
}
]
}
]
}
}
}

templates/trial-1d-email.json

{
"channel": "email",
"notification": {
"name": "Trial ends tomorrow (email)",
"tags": ["recipe", "trial-ending"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "Your trial ends tomorrow"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "Your {{plan_name}} trial ends tomorrow, {{datetime-format trial_end_date \"MMMM d\" (default profile.timezone \"America/New_York\")}}. Choose a plan today and nothing changes for you or your team."
},
{
"type": "action",
"content": "Choose a plan",
"href": "https://app.acme.com/billing"
}
]
}
]
}
}
}

templates/trial-1d-inbox.json

{
"channel": "inbox",
"notification": {
"name": "Trial ends tomorrow (Inbox)",
"tags": ["recipe", "trial-ending"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "inbox",
"elements": [
{
"type": "meta",
"title": "Your trial ends tomorrow"
},
{
"type": "text",
"content": "Choose a plan today to keep your account running."
},
{
"type": "action",
"content": "Choose a plan",
"href": "https://app.acme.com/billing"
}
]
}
]
}
}
}

templates/trial-1d-sms.json

{
"channel": "sms",
"notification": {
"name": "Trial ends tomorrow (SMS)",
"tags": ["recipe", "trial-ending"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "sms",
"elements": [
{
"type": "text",
"content": "Acme: your trial ends tomorrow. Keep your account running: https://app.acme.com/billing Reply STOP to opt out."
}
]
}
]
}
}
}

templates/trial-ended-email.json

{
"channel": "email",
"notification": {
"name": "Trial ended (email)",
"tags": ["recipe", "trial-ending"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "Your trial has ended"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "Your {{plan_name}} trial ended today. Everything you set up is saved, so you can pick up where you left off when you choose a plan."
},
{
"type": "action",
"content": "Choose a plan",
"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 trial ending reminders to this app, sent through Courier: two reminders before a trial ends, a final note when it ends, and nothing more once the account upgrades. Follow the steps at https://www.courier.com/recipes/trial-ending-email-sequence.md and use the Courier skill for SDK details. 1. Create the journey and its six 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_TRIAL_JOURNEY_ID. Replace the placeholder product name (Acme) and the billing link in the templates with ours, and if our trials aren't 14 days, set the journey's first wait to the trial length minus 7 days. 2. Find where this codebase starts a trial and start the journey there with the user's ID. Pass the fields in the Data fields table from our own data. 3. Find where an account upgrades (usually the billing webhook) and cancel the journey with the token trial-<user ID>. When a trial is extended, cancel it and start it again. 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 run waits before its first reminder, 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
trial_end_datestring2026-10-29T17:00:00ZAn ISO timestamp. The templates format it as a date in the recipient's timezone

FAQ

Frequently asked questions

How many trial reminder emails should I send?

Two before the trial ends and one when it ends covers most trials. The journey starts when the trial starts and waits P7D before the first reminder, which suits a 14-day trial. For another length, set that first wait to the trial length minus 7 days. To change any other gap, edit the wait durations in the Journeys editor.

How do I stop trial reminders when someone upgrades?

Each run is tagged with the user's ID, so cancel the token trial-<user_id> from your billing webhook. Courier stops every active run for that user, including a run still on its first wait, and leaves finished runs alone.

When does a trial reminder go by SMS?

On step 2, when the first reminder email wasn't opened and the user has a phone number on their profile. Everyone else gets the email and Inbox note without the text.

What if I extend someone's trial?

Cancel the running journey and start a new one. The waits count from the start, so for an extension of a different length, edit the first wait in a copy of the journey. The templates read the new trial_end_date you pass.

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.