Monetization

Usage limit alert email: template and code

Warn customers as they near a plan limit, and tell your own team when they hit it.

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

How it works

When to send usage limit alerts

A usage limit alert warns customers when their account nears its plan limit. At 80% and again at 100% it sends an email and an Inbox note to the account admin, and an hour after 100% it emails your own team. Each alert goes out at most once a week, and upgrading cancels the team email.

  1. Account crosses 80% or 100%
    Your metering job starts the journey
  2. At 80%
    Email and Inbox to the admin
  3. At 100%
    Email and Inbox to the admin
  4. 1 hour later
    Email to your team
    Only if the customer hasn't upgraded

Stops early: Upgrading cancels the team alert if it hasn't gone out yet.

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 an account reaches 80%

Email and Inbox to the admin.

Email

From: Acme
You've used 80% of your API requests

Hi Dana,

Northwind has used 8,000 of its 10,000 API requests on the Team plan this period. Upgrade now so you don't hit the limit.

Not sure which plan fits? Reply to this email.

Upgrade plan

In-app Inbox

80% of your API requests used

8,000 of 10,000 used this period.

Upgrade plan

Other subject lines: "Heads up: 80% of your API requests are used"

Step 2: When an account reaches 100%

Email and Inbox to the admin.

Email

From: Acme
You've reached your Team limit

Hi Dana,

Northwind has used all 10,000 API requests on the Team plan. Upgrade to raise your limit.

Not sure which plan fits? Reply to this email.

Upgrade plan

In-app Inbox

You've reached your API requests limit

Upgrade to raise your limit.

Upgrade plan

Other subject lines: "You've hit your Team limit"

Step 3: 1 hour after the 100% alert

Email to your team. Only if the customer hasn't upgraded.

Email

From: Acme
Northwind hit its Team limit

Northwind has used all 10,000 API requests on the Team plan and hasn't upgraded yet.

Open account

Other subject lines: "Northwind is at its limit"

Using different thresholds? Change the numbers in the journey's branch paths and pass the same numbers from your job.

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 an account crosses a threshold

Node.js

// Start an alert when an account crosses 80% or 100% of a plan limit, with the
// journey ID setup.ts printed. Call it from your metering job, with the ID of
// the account admin. At 100%, the journey waits 1 hour before it emails your
// team. The admin's name comes from their Courier profile, and the team
// address, limit name, and links are written in the journey and templates.
import Courier from "@trycourier/courier";
const client = new Courier();
export async function startUsageAlert(
journeyId: string,
adminId: string,
account: {
id: string;
name: string;
plan: string;
usage: number;
limit: number;
threshold: 80 | 100;
}
) {
const { runId } = await client.journeys.invoke(journeyId, {
user_id: adminId,
data: {
account_id: account.id,
alert_key: `${account.id}-${account.threshold}`,
threshold: String(account.threshold),
account_name: account.name,
plan_name: account.plan,
usage: account.usage.toLocaleString("en-US"),
limit: account.limit.toLocaleString("en-US"),
},
});
return runId;
}

Python

# Start an alert when an account crosses 80% or 100% of a plan limit, with the
# journey ID setup.ts printed. Call it from your metering job, with the ID of
# the account admin. At 100%, the journey waits 1 hour before it emails your
# team. The admin's name comes from their Courier profile, and the team
# address, limit name, and links are written in the journey and templates.
from courier import Courier
client = Courier()
def start_usage_alert(journey_id: str, admin_id: str, account: dict) -> str:
response = client.journeys.invoke(
template_id=journey_id,
user_id=admin_id,
data={
"account_id": account["id"],
"alert_key": f"{account['id']}-{account['threshold']}",
"threshold": str(account["threshold"]),
"account_name": account["name"],
"plan_name": account["plan"],
"usage": f"{account['usage']:,}",
"limit": f"{account['limit']:,}",
},
)
return response.run_id

3. Stop it when the customer upgrades

Node.js

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

Python

# Stop the team alert when the customer upgrades: call this from your billing
# webhook. It cancels every active run for the account, and does nothing if
# the alerts already finished.
from courier import Courier
client = Courier()
def stop_usage_alerts(account_id: str) -> None:
client.journeys.cancel(cancelation_token=f"usage-{account_id}")
Full definition: journey.json, setup.ts, and the 5 templates

One journey handles both thresholds. Your metering job passes the threshold, and the journey sends the 80% email and Inbox note or the 100% pair, then waits an hour at 100% before emailing your team.

A weekly throttle per account and threshold keeps a job that fires twice from emailing the customer twice. The admin's name comes from their Courier profile.

journey.json

{
"name": "Usage limit alerts",
"cancelation_token": "usage-{{data.account_id}}",
"nodes": [
{
"type": "trigger",
"trigger_type": "api-invoke"
},
{
"type": "throttle",
"scope": "dynamic",
"throttle_key": "{{data.alert_key}}",
"max_allowed": 1,
"period": "P7D"
},
{
"type": "branch",
"paths": [
{
"label": "80%",
"conditions": ["data.threshold", "is equal", "80"],
"nodes": [
{
"type": "send",
"channel": "email",
"message": {
"template": "@usage-80-email"
}
},
{
"type": "send",
"channel": "inbox",
"message": {
"template": "@usage-80-inbox"
}
}
]
},
{
"label": "100%",
"conditions": ["data.threshold", "is equal", "100"],
"nodes": [
{
"type": "send",
"channel": "email",
"message": {
"template": "@usage-100-email"
}
},
{
"type": "send",
"channel": "inbox",
"message": {
"template": "@usage-100-inbox"
}
},
{
"type": "delay",
"mode": "duration",
"duration": "PT1H"
},
{
"type": "send",
"channel": "email",
"message": {
"template": "@usage-100-team-email",
"to": {
"email_override": "alerts@acme.com"
}
}
}
]
}
],
"default": {
"label": "Nothing to send",
"nodes": []
}
},
{
"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/usage-80-email.json

{
"channel": "email",
"notification": {
"name": "Usage at 80% (email)",
"tags": ["recipe", "usage-limit"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "You've used {{threshold}}% of your API requests"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "{{account_name}} has used {{usage}} of its {{limit}} API requests on the {{plan_name}} plan this period. Upgrade now so you don't hit the limit."
},
{
"type": "action",
"content": "Upgrade plan",
"href": "https://app.acme.com/billing/upgrade"
},
{
"type": "text",
"content": "Not sure which plan fits? Reply to this email."
}
]
}
]
}
}
}

templates/usage-80-inbox.json

{
"channel": "inbox",
"notification": {
"name": "Usage at 80% (Inbox)",
"tags": ["recipe", "usage-limit"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "inbox",
"elements": [
{
"type": "meta",
"title": "{{threshold}}% of your API requests used"
},
{
"type": "text",
"content": "{{usage}} of {{limit}} used this period."
},
{
"type": "action",
"content": "Upgrade plan",
"href": "https://app.acme.com/billing/upgrade"
}
]
}
]
}
}
}

templates/usage-100-email.json

{
"channel": "email",
"notification": {
"name": "Usage at 100% (email)",
"tags": ["recipe", "usage-limit"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "You've reached your {{plan_name}} limit"
},
{
"type": "text",
"content": "Hi {{default profile.first_name \"there\"}},"
},
{
"type": "text",
"content": "{{account_name}} has used all {{limit}} API requests on the {{plan_name}} plan. Upgrade to raise your limit."
},
{
"type": "action",
"content": "Upgrade plan",
"href": "https://app.acme.com/billing/upgrade"
},
{
"type": "text",
"content": "Not sure which plan fits? Reply to this email."
}
]
}
]
}
}
}

templates/usage-100-inbox.json

{
"channel": "inbox",
"notification": {
"name": "Usage at 100% (Inbox)",
"tags": ["recipe", "usage-limit"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "inbox",
"elements": [
{
"type": "meta",
"title": "You've reached your API requests limit"
},
{
"type": "text",
"content": "Upgrade to raise your limit."
},
{
"type": "action",
"content": "Upgrade plan",
"href": "https://app.acme.com/billing/upgrade"
}
]
}
]
}
}
}

templates/usage-100-team-email.json

{
"channel": "email",
"notification": {
"name": "Usage at 100% (team email)",
"tags": ["recipe", "usage-limit"],
"brand": null,
"subscription": null,
"content": {
"version": "2022-01-01",
"elements": [
{
"type": "channel",
"channel": "email",
"elements": [
{
"type": "meta",
"title": "{{account_name}} hit its {{plan_name}} limit"
},
{
"type": "text",
"content": "{{account_name}} has used all {{limit}} API requests on the {{plan_name}} plan and hasn't upgraded yet."
},
{
"type": "action",
"content": "Open account",
"href": "https://admin.acme.com/accounts/{{account_id}}"
}
]
}
]
}
}
}

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 usage limit alerts to this app, sent through Courier: an email and Inbox note to the account admin at 80% and again at 100% of a plan limit, plus an email to our own team an hour after 100% if the account hasn't upgraded. Follow the steps at https://www.courier.com/recipes/usage-limit-alert-email.md and use the Courier skill for SDK details. 1. Create the journey and its five 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_USAGE_ALERT_JOURNEY_ID. Replace the placeholder team address in journey.json (alerts@acme.com), the limit name (API requests), and the Acme links in the templates with ours. 2. Make sure each admin's Courier profile carries first_name, written when they sign up. Find the job or code that meters plan usage and start the journey with the admin's ID each time an account crosses 80% or 100%. 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 usage-<account_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 at 100% and show me the run's steps. The admin's email and Inbox note go out right away and the run then waits an hour for the team email, so cancel the test run once you've checked it.

Data

Data fields

FieldTypeExampleNote
account_idstringacct_8F2kBuilds the cancellation token and the account link in the team email
alert_keystringacct_8F2k-80Account ID plus threshold. The throttle keys on it
thresholdstring8080 or 100. Picks which alert sends, and the lower alert prints it
profile.first_namestringDanaFrom the profile. The emails say "there" when it's missing
account_namestringNorthwind
plan_namestringTeam
usagestring8,000For display
limitstring10,000For display

FAQ

Frequently asked questions

When should I warn customers about a usage limit?

At 80%, so they have time to upgrade, and again at 100%. Two alerts cover most plans, and more than that starts to read as noise.

How do I stop sending the same usage alert twice?

A throttle keyed on the account and the threshold lets each alert through once every 7 days. If usage dips under 80% and climbs back that week, the second alert is skipped.

How do I tell my own team when a customer hits their limit?

Add a send node with a recipient override pointing at your team's address. This recipe writes it on the node, so the customer's emails still go to the account admin and you edit the address once.

What stops the alerts when a customer upgrades?

Tag each run with the account ID and cancel that token from your billing code. The admin's emails have already gone out, so it cancels the team alert that's still waiting.

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.