# Usage limit alert email: template and code

Source: https://www.courier.com/recipes/usage-limit-alert-email (manifest: https://www.courier.com/recipes/usage-limit-alert-email/manifest.json)

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

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.

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 usage limit alerts

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | When an account reaches 80% | Email, Inbox | You've used {{threshold}}% of your API requests |
| 2 | When an account reaches 100% | Email, Inbox | You've reached your {{plan_name}} limit |
| 3 | 1 hour after the 100% alert | Email | {{account_name}} hit its {{plan_name}} limit |

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

## Copy

### Usage at 80% (email)

Subject: "You've used {{threshold}}% of your API requests", "Heads up: {{threshold}}% of your API requests are used"

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

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

Not sure which plan fits? Reply to this email.

Button: Upgrade plan (https://app.acme.com/billing/upgrade)

### Usage at 80% (Inbox)

Subject: "{{threshold}}% of your API requests used"

{{usage}} of {{limit}} used this period.

Button: Upgrade plan (https://app.acme.com/billing/upgrade)

### Usage at 100% (email)

Subject: "You've reached your {{plan_name}} limit", "You've hit your {{plan_name}} limit"

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

{{account_name}} has used all {{limit}} API requests on the {{plan_name}} plan. Upgrade to raise your limit.

Not sure which plan fits? Reply to this email.

Button: Upgrade plan (https://app.acme.com/billing/upgrade)

### Usage at 100% (Inbox)

Subject: "You've reached your API requests limit"

Upgrade to raise your limit.

Button: Upgrade plan (https://app.acme.com/billing/upgrade)

### Usage at 100% (team email)

Subject: "{{account_name}} hit its {{plan_name}} limit", "{{account_name}} is at its limit"

{{account_name}} has used all {{limit}} API requests on the {{plan_name}} plan and hasn't upgraded yet.

Button: Open account (https://admin.acme.com/accounts/{{account_id}})

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

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

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

## Stop it when the customer upgrades

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

## Templates

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

templates/usage-80-email.json

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

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

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

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

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

## Hand it to your agent

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

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

## Related docs

- [Throttle node](https://www.courier.com/docs/journeys/nodes/throttle)
- [Send node recipient override](https://www.courier.com/docs/journeys/nodes/send)
- [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

- [Trial ending reminders](https://www.courier.com/recipes/trial-ending-email-sequence): Remind trial users before their trial ends, and stop the moment they upgrade.
- [Health score alert](https://www.courier.com/recipes/customer-health-score-slack-alert): Tell your customer success team in Slack the day an account's health drops.
- [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.
