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.
- Account crosses 80% or 100%Your metering job starts the journey
- At 80%Email and Inbox to the admin
- At 100%Email and Inbox to the admin
- 1 hour laterEmail to your teamOnly 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.
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.
In-app Inbox
8,000 of 10,000 used this period.
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.
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.
In-app Inbox
Upgrade to raise your limit.
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.
Northwind has used all 10,000 API requests on the Team plan and hasn't upgraded yet.
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 Courierclient = 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 Courierclient = 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.
Data
Data fields
| Field | Type | Example | Note |
|---|---|---|---|
| account_id | string | acct_8F2k | Builds the cancellation token and the account link in the team email |
| alert_key | string | acct_8F2k-80 | Account ID plus threshold. The throttle keys on it |
| threshold | string | 80 | 80 or 100. Picks which alert sends, and the lower alert prints it |
| profile.first_name | string | Dana | From the profile. The emails say "there" when it's missing |
| account_name | string | Northwind | |
| plan_name | string | Team | |
| usage | string | 8,000 | For display |
| limit | string | 10,000 | For 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.
Recipes
Related recipes
Docs
How each part works
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.