# SkillPatch skill: courier-notification-skills

A comprehensive agent skill for building notifications with Courier across multiple channels including email, SMS, push, in-app inbox, Slack, Teams, and WhatsApp. It covers transactional and growth notifications, multi-channel routing, preferences, journeys, template management, provider configuration, and migrations from other notification platforms. The skill includes structured workflows, routing tables, clarifying question logic, and canonical SDK patterns to guide agents in generating correct Courier notification code.

You (the agent) were given this URL and asked to use this skill. This is a **public** skill — no SkillPatch account, API key, or prior setup is required. Two ways to use it:

**1. Use it right now** — the skill's complete file tree (SKILL.md + all reference files) is inlined below; read `SKILL.md` first, then follow it, consulting the other files as it directs.

**2. Install the exact package onto disk** (recommended if you can run a shell — this reproduces the skill byte-for-byte, including any binary assets that can't be inlined):

```bash
mkdir -p .claude/skills/courier-notification-skills
curl -sSL https://skillpatch.dev/install_skill/courier-notification-skills | tar -xz -C .claude/skills/
```

(`.claude/skills/` is Claude Code's convention; use whatever directory your agent loads skills from.)


---

## Skill files (69)

- `SKILL.md`
- `.git/config`
- `.git/description`
- `.git/HEAD`
- `.git/hooks/applypatch-msg.sample`
- `.git/hooks/commit-msg.sample`
- `.git/hooks/fsmonitor-watchman.sample`
- `.git/hooks/post-update.sample`
- `.git/hooks/pre-applypatch.sample`
- `.git/hooks/pre-commit.sample`
- `.git/hooks/pre-merge-commit.sample`
- `.git/hooks/pre-push.sample`
- `.git/hooks/pre-rebase.sample`
- `.git/hooks/pre-receive.sample`
- `.git/hooks/prepare-commit-msg.sample`
- `.git/hooks/push-to-checkout.sample`
- `.git/hooks/update.sample`
- `.git/index`
- `.git/info/exclude`
- `.git/logs/HEAD`
- `.git/logs/refs/heads/main`
- `.git/logs/refs/remotes/origin/HEAD`
- `.git/objects/pack/pack-3f0a79b3941f9365288c98941767e28e9f22e057.idx`
- `.git/objects/pack/pack-3f0a79b3941f9365288c98941767e28e9f22e057.pack`
- `.git/packed-refs`
- `.git/refs/heads/main`
- `.git/refs/remotes/origin/HEAD`
- `.git/shallow`
- `LICENSE`
- `README.md`
- `resources/channels/email.md`
- `resources/channels/inbox-v7-legacy.md`
- `resources/channels/inbox.md`
- `resources/channels/ms-teams.md`
- `resources/channels/push.md`
- `resources/channels/slack.md`
- `resources/channels/sms.md`
- `resources/channels/whatsapp.md`
- `resources/growth/adoption.md`
- `resources/growth/campaigns.md`
- `resources/growth/engagement.md`
- `resources/growth/index.md`
- `resources/growth/onboarding.md`
- `resources/growth/reengagement.md`
- `resources/growth/referral.md`
- `resources/guides/batching.md`
- `resources/guides/catalog.md`
- `resources/guides/cli.md`
- `resources/guides/elemental.md`
- `resources/guides/journeys.md`
- `resources/guides/mcp.md`
- `resources/guides/migrate-from-knock.md`
- `resources/guides/migrate-from-novu.md`
- `resources/guides/migrate-general.md`
- `resources/guides/multi-channel.md`
- `resources/guides/patterns.md`
- `resources/guides/preferences.md`
- `resources/guides/providers.md`
- `resources/guides/quickstart.md`
- `resources/guides/reliability.md`
- `resources/guides/routing-strategies.md`
- `resources/guides/templates.md`
- `resources/guides/throttling.md`
- `resources/transactional/account.md`
- `resources/transactional/appointments.md`
- `resources/transactional/authentication.md`
- `resources/transactional/billing.md`
- `resources/transactional/index.md`
- `resources/transactional/orders.md`


### `SKILL.md`

````markdown
---
name: courier-notification-skills
description: Use when building notifications with Courier across email, SMS, push, in-app inbox, Slack, Teams, or WhatsApp. Covers transactional messages (password reset, OTP, orders, billing), growth notifications (onboarding, engagement, referral), multi-channel routing, preferences and topics, reliability and webhooks, journeys (multi-step notification sequences via API), template CRUD and Elemental content, routing strategies, provider configuration, the Courier CLI and MCP server, and migrations from Knock, Novu, or other notification systems.
---

# Courier Notification Skills

Guidance for building deliverable and engaging notifications across all channels.

## How to Use This Skill

1. **Identify the task** — What channel, notification type, or cross-cutting concern is the user working on?
2. **Read only what's needed** — Use the routing tables below to find the 1-2 files relevant to the task. Do NOT read all files.
3. **Check for live docs** — For current API signatures and SDK methods, fetch `https://www.courier.com/docs/llms.txt`
4. **Synthesize before coding** — Plan the complete implementation (channels, routing, error handling) before writing code.
5. **Apply the rules** — Each resource file starts with a "Quick Reference" section containing hard rules. Treat these as constraints, not suggestions.
6. **Check universal rules** — Before generating any notification code, verify it doesn't violate the Universal Rules below.

## Handling Vague Requests

If the user's request doesn't clearly map to a specific channel, notification type, or guide, **ask clarifying questions before reading any resource files**. Don't guess — a wrong routing wastes time and produces irrelevant code.

**Ask these questions as needed:**

1. **What channel?** — "Which channel are you sending through: email, SMS, push, in-app, Slack, Teams, or WhatsApp?"
2. **What type?** — "Is this a transactional notification (triggered by a user action, like a password reset or order confirmation) or a marketing/growth notification (sent proactively, like a feature announcement)?"
3. **New or existing?** — "Are you starting from scratch, or do you have existing Courier code? If existing, what SDK packages do you have installed?"
4. **What language?** — "Are you using TypeScript/Node.js, Python, or another language?"

You don't need to ask all four — just the ones needed to route to the right 1-2 files. If the request is clearly about a specific topic (e.g., "help me with SMS"), skip the questions and go directly to the relevant resource.

**Routing consequences of question 3 ("new or existing"):**

| Answer | Skip | Load |
|--------|------|------|
| New to Courier / no existing code | (nothing) | [quickstart.md](./resources/guides/quickstart.md) + the relevant channel or type file |
| Existing — has `@trycourier/courier` or `trycourier` installed | `quickstart.md` install + env-setup sections | Jump directly to channel or type file; assume `client` is constructed. Offer `courier messages list` as a one-line health check if useful. |
| Existing — Inbox v7 (`@trycourier/react-*`) | v8 guidance | See "Courier Inbox Version Detection" block below, then [inbox-v7-legacy.md](./resources/channels/inbox-v7-legacy.md) |

## Canonical SDK Shape

Before you write or evaluate any Courier code, ground it in this shape. If anything in a file below appears to contradict it, trust this block and fetch live docs to resolve — do **not** paste the contradicting snippet.

**Node.js (`@trycourier/courier`):**

```typescript
import Courier from "@trycourier/courier";

// Reads process.env.COURIER_API_KEY by default
const client = new Courier();

await client.send.message({
  message: {
    to: { user_id: "user-123" },           // or { email }, { phone_number }, { list_id }, { tenant_id }, etc.
    template: "nt_01kmrbq6ypf25tsge12qek41r0", // OR content: { title, body } / { version, elements }
    data: { /* merge variables */ },
  },
}, {
  // Pass the Idempotency-Key via headers. Always set it explicitly here —
  // that is the one path guaranteed to be sent to the API across SDK
  // versions. Verify against your installed SDK version before relying on
  // any other `idempotencyKey` request option.
  headers: { "Idempotency-Key": "order-confirmation-12345" },
});
```

**Python (`trycourier`):**

```python
from courier import Courier

# Reads COURIER_API_KEY from env by default
client = Courier()

client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbq6ypf25tsge12qek41r0",
        "data": {},
    },
    # Pass the Idempotency-Key via extra_headers. Python does not accept
    # idempotency_key= as a keyword argument — the header is the only way.
    extra_headers={"Idempotency-Key": "order-confirmation-12345"},
)
```

**Method naming quick lookup (both SDKs follow the same structure, Node = camelCase, Python = snake_case):**

| Operation | Node | Python |
|-----------|------|--------|
| Send a message | `client.send.message({ message })` | `client.send.message(message=...)` |
| Create a template | `client.notifications.create({ notification, state })` → returns `{ id, name, content, … }` at top level | `client.notifications.create(notification=..., state=...)` → `response.id` |
| Publish a template | `client.notifications.publish(templateId)` | `client.notifications.publish(template_id)` |
| Retrieve a message | `client.messages.retrieve(id)` | `client.messages.retrieve(id)` |
| List messages | `client.messages.list({ ... })` | `client.messages.list(...)` |
| Subscribe a user to a list (additive) | `client.lists.subscriptions.subscribeUser(userId, { list_id })` | `client.lists.subscriptions.subscribe_user(user_id, list_id=...)` |
| Replace a list's subscribers | `client.lists.subscriptions.subscribe(listId, { recipients })` | `client.lists.subscriptions.subscribe(list_id, recipients=...)` |
| Create/replace a tenant | `client.tenants.update(tenantId, body)` | `client.tenants.update(tenant_id, ...)` |
| Add a user to a tenant | `client.users.tenants.addSingle(tenantId, { user_id })` | `client.users.tenants.add_single(tenant_id, user_id=...)` |
| Create a bulk job | `client.bulk.createJob({ message: { event } })` (event required) | `client.bulk.create_job(message={"event": ...})` |
| Create/update a profile (merge) | `client.profiles.create(userId, { profile })` | `client.profiles.create(user_id, profile=...)` |
| Get a user's preferences | `client.users.preferences.retrieve(userId)` | `client.users.preferences.retrieve(user_id)` |
| Update a user's preference for a topic | `client.users.preferences.updateOrCreateTopic(topicId, { user_id, topic: { status, ... } })` | `client.users.preferences.update_or_create_topic(topic_id, user_id=..., topic=...)` |
| Register a user's device token | `client.users.tokens.addSingle(token, { user_id, provider_key, device })` | `client.users.tokens.add_single(token, user_id=..., provider_key=..., device=...)` |
| Create a journey | `client.journeys.create({ name, nodes, enabled })` | `client.journeys.create(name=..., nodes=..., enabled=...)` |
| Replace a journey (draft) | `client.journeys.replace(id, { name, nodes, enabled })` | `client.journeys.replace(id, name=..., nodes=..., enabled=...)` |
| Publish a journey | `client.journeys.publish(id)` | `client.journeys.publish(id)` |
| Invoke a journey (start a run) | `client.journeys.invoke(id, { user_id, data, profile })` → `{ runId }` | `client.journeys.invoke(template_id=id, user_id=..., data=..., profile=...)` → `.run_id` |
| Create a journey-scoped template | `POST /journeys/{id}/templates` (no SDK helper) | `POST /journeys/{id}/templates` (no SDK helper) |
| Publish a journey-scoped template | `POST /journeys/{id}/templates/{templateId}/publish` (no SDK helper) | `POST /journeys/{id}/templates/{templateId}/publish` (no SDK helper) |
| Trigger an automation (legacy) | `client.automations.invoke.invokeByTemplate(templateId, { recipient, data })` | `client.automations.invoke.invoke_by_template(template_id, recipient=..., data=...)` |
| Trigger an ad-hoc automation (legacy) | `client.automations.invoke.invokeAdHoc({ recipient, automation })` | `client.automations.invoke.invoke_ad_hoc(recipient=..., automation=...)` |
| Create a routing strategy | `client.routingStrategies.create({ name, routing, channels?, providers? })` → returns `{ id: "rs_...", ... }` | `client.routing_strategies.create(name=..., routing=..., ...)` |
| Replace a routing strategy (full PUT) | `client.routingStrategies.replace(id, { name, routing, ... })` | `client.routing_strategies.replace(id, name=..., routing=..., ...)` |
| Configure a provider | `client.providers.create({ provider, settings, title?, alias? })` | `client.providers.create(provider=..., settings=..., ...)` |
| List provider catalog (required `settings` schema) | `client.providers.catalog.list({ keys?, name?, channel? })` | `client.providers.catalog.list(keys=..., channel=...)` |
| Cancel a message | `client.messages.cancel(messageId)` | `client.messages.cancel(message_id)` |
| Retrieve a template | `client.notifications.retrieve(templateId)` | `client.notifications.retrieve(template_id)` |
| List templates | `client.notifications.list()` | `client.notifications.list()` |
| Replace a template (full PUT) | `client.notifications.replace(templateId, { notification, state })` | `client.notifications.replace(template_id, notification=..., state=...)` |
| Archive a template | `client.notifications.archive(templateId)` | `client.notifications.archive(template_id)` |
| Get published template content | `client.notifications.retrieveContent(templateId)` | `client.notifications.retrieve_content(template_id)` |

> The table above covers the most common operations. [journeys.md](./resources/guides/journeys.md), [templates.md](./resources/guides/templates.md), [routing-strategies.md](./resources/guides/routing-strategies.md), and [providers.md](./resources/guides/providers.md) each contain their own complete SDK shape tables for CRUD on their respective resources (including `list`, `retrieve`, `replace`, `archive`). **For new multi-step flows, use Journeys instead of Automations** — see [Journeys](./resources/guides/journeys.md).

**Shapes that do NOT exist (do not invent them):**

- `client.messages.archive(...)` — archive is REST-only: `POST /messages/{id}/archive`. Note: `client.notifications.archive(id)` and `client.routingStrategies.archive(id)` / `client.providers.archive(id)` DO exist — this restriction is specific to the messages namespace.
- `client.tenants.createOrReplace(...)` — use `client.tenants.update`
- `client.lists.subscribe(listId, userId)` — use `subscriptions.subscribeUser` or `subscriptions.subscribe`
- Bulk `createJob({ message: { template } })` without `event` — `event` is required
- `client.users.preferences.update(...)` — use `client.users.preferences.updateOrCreateTopic(topicId, { user_id, topic })`.
- `client.automations.invoke(templateId, ...)` — the real shape is `client.automations.invoke.invokeByTemplate(...)` or `client.automations.invoke.invokeAdHoc(...)`. Note: for new multi-step flows, prefer Journeys (`POST /journeys`) over Automations.
- Journey **management** SDK methods (`client.journeys.create/replace/publish/invoke`) DO exist and should be preferred over raw REST. **Journey-scoped template** operations (`POST /journeys/{id}/templates`, `.../publish`) currently have no SDK helper — use REST/curl for those. Journeys are not in MCP yet.
- `client.routing.create(...)` / `client.strategies.*` — the real namespace is `client.routingStrategies.*` (Node) / `client.routing_strategies.*` (Python).
- `client.integrations.*` — there is no `integrations` namespace; provider configurations live under `client.providers.*` and the provider type catalog under `client.providers.catalog.*`.

**Shapes that exist but should not be the default:**

- `client.profiles.update(userId, { patch: [...] })` — this DOES exist and applies a JSON Patch (RFC 6902). Use it only when the user specifically needs atomic field-level ops (`add`/`remove`/`replace`/`test` on specific paths). For the common "merge these fields into the profile" case, use `client.profiles.create(userId, { profile })` (POST, deep-merge).
- `client.profiles.replace(userId, { profile })` — this DOES exist and is a full PUT that overwrites the profile. Use it only when you need to reset a profile to a known-good state. For everyday writes, `client.profiles.create` (merge) is safer because it won't silently drop fields.

## Universal Rules

- NEVER batch or delay OTP, password reset, or security alert notifications
- Use idempotency keys for sends where duplicates would be harmful (payments, security alerts, OTPs)
- NEVER expose full email/phone in security change notifications (mask them)
- ALWAYS include "I didn't request this" links in security-related emails
- ALWAYS use E.164 format for phone numbers
- Only send to channels the user has asked for or that make sense for the use case — don't blast every channel by default
- For template sends, use Courier-generated `nt_...` IDs as canonical; treat IDs as opaque workspace-specific values and resolve aliases to `nt_...` before sending

### See also (not duplicated here)

- **Quiet hours** (non-OTP, non-security): [resources/guides/patterns.md](./resources/guides/patterns.md) and [resources/guides/throttling.md](./resources/guides/throttling.md)
- **429 / provider rate limits and retries**: [resources/guides/throttling.md](./resources/guides/throttling.md) and [resources/guides/reliability.md](./resources/guides/reliability.md)
- **Compliance (GDPR, CAN-SPAM, TCPA, 10DLC)**: app-layer concern — see channel guides ([resources/channels/email.md](./resources/channels/email.md), [resources/channels/sms.md](./resources/channels/sms.md)) for sender-auth and opt-in mechanics; consult legal counsel for jurisdictional requirements
- **Test vs. production workspaces and safe deploys**: [resources/guides/quickstart.md](./resources/guides/quickstart.md) (API keys per environment) and [resources/guides/reliability.md](./resources/guides/reliability.md)

### Courier Inbox Version Detection

Before providing Inbox guidance, **determine which SDK version the user is on**:

1. **Check for v7 indicators** — Look for any of: `@trycourier/react-provider`, `@trycourier/react-inbox`, `@trycourier/react-toast`, `@trycourier/react-hooks`, `<CourierProvider>`, `useInbox()`, `useToast()`, `<Inbox />` (not `<CourierInbox />`), `clientKey` prop, `renderMessage` prop. Check `package.json` if available.
2. **Check for v8 indicators** — Look for any of: `@trycourier/courier-react`, `@trycourier/courier-react-17`, `@trycourier/courier-ui-inbox`, `useCourier()`, `<CourierInbox />`, `<CourierToast />`, `courier.shared.signIn()`, `registerFeeds`, `listenForUpdates`.
3. **If unclear, ask** — "Which version of the Courier Inbox SDK are you using? If you have `@trycourier/react-inbox` in your package.json, that's v7. If you have `@trycourier/courier-react`, that's v8."

**ALWAYS use v8 for new projects — v7 is legacy.** If the user is on v7:
- **Do NOT write new v7 code.** The correct path is to upgrade to v8.
- **Read [resources/channels/inbox-v7-legacy.md](./resources/channels/inbox-v7-legacy.md)** before touching v7 code — it documents recognition patterns and the migration path.
- **Guide them to migrate** using the step-by-step guide: `https://www.courier.com/docs/sdk-libraries/courier-react-v8-migration-guide`
- v8 is a smaller bundle, has no third-party dependencies, built-in dark mode, and a modern UI.
- The v7 and v8 APIs are completely different — v7 code will not work with v8 and vice versa.
- **Only exception:** v8 does not yet support Tags or Pins. If the user depends on those, they may need to stay on v7 temporarily, but should plan to migrate once v8 adds support.

## Official Courier Documentation

When you need current API signatures, SDK methods, or features not covered in these resources:

1. Fetch `https://www.courier.com/docs/llms.txt` — returns a structured markdown index of all Courier documentation pages with URLs and descriptions
2. Scan the index for the relevant page, then fetch that page's URL for full details
3. Prefer the patterns in THIS skill for best practices; use llms.txt for API specifics

**When to use llms.txt:**
- You need the exact signature for a method not shown in these resources (e.g., `client.audiences.create()`)
- A developer asks about a Courier feature this skill doesn't cover (e.g., Audiences, Brands, Translations)
- You need to verify that a code example in this skill matches the current SDK version

**When NOT to use llms.txt:**
- The answer is already in these resource files (prefer this skill's opinionated patterns over raw docs)
- The question is about best practices or notification design (llms.txt won't help)

## Architecture Overview

```
[User Action / System Event]
            │
            ▼
    ┌───────────────┐
    │ Notification  │
    │   Trigger     │
    └───────┬───────┘
            │
            ▼
    ┌───────────────┐
    │   Routing     │──── User Preferences
    │   Decision    │──── Channel Availability
    └───────┬───────┘──── Urgency Level
            │
            ▼
    ┌───────────────────────────────────────┐
    │           Channel Selection           │
    ├───────┬───────┬───────┬───────┬──────┤
    │ Email │  SMS  │ Push  │ Inbox │ Chat │
    └───┬───┴───┬───┴───┬───┴───┬───┴───┬──┘
        │       │       │       │       │
        ▼       ▼       ▼       ▼       ▼
    [Delivery] [Delivery] [Delivery] [Delivery] [Delivery]
        │       │       │       │       │
        └───────┴───────┴───────┴───────┘
                        │
                        ▼
                ┌───────────────┐
                │   Webhooks    │
                │   & Events    │
                └───────────────┘
```

## Quick Reference

### By Channel

| Need to... | Pick when... | See |
|------------|--------------|-----|
| Send emails, fix deliverability, set up SPF/DKIM/DMARC | You need a durable, detailed record. Receipts, confirmations, long-form content, attachments, rich formatting. Deliverability depends on sender reputation (SPF/DKIM/DMARC); not real-time. | [Email](./resources/channels/email.md) |
| Send SMS, handle 10DLC registration | You need reach and speed for short, time-sensitive messages. OTP, appointment reminders, shipping updates. 10DLC registration required in US; small character budget; per-message cost. | [SMS](./resources/channels/sms.md) |
| Send push notifications, handle iOS/Android differences | You need to nudge an engaged app user. Activity notifications, real-time alerts, re-engagement. Requires device token + OS permission; iOS and Android permission models differ; silent for users who disabled permission. | [Push](./resources/channels/push.md) |
| Build in-app notification center | You need persistent, in-app notifications with read state, cross-device sync, and an inbox UI. Only visible in-app. Requires the Courier Inbox SDK (v7 vs v8 matters — see the file's header and the Inbox Version Detection section above). | [Inbox (v8)](./resources/channels/inbox.md) — v8 primary. If you have existin
...<truncated>
````


### `.git/config`

```
[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
	ignorecase = true
	precomposeunicode = true
[remote "origin"]
	url = https://github.com/trycourier/courier-skills
	fetch = +refs/heads/main:refs/remotes/origin/main
[branch "main"]
	remote = origin
	merge = refs/heads/main

```


### `.git/description`

```
Unnamed repository; edit this file 'description' to name the repository.

```


### `.git/HEAD`

```
ref: refs/heads/main

```


### `.git/hooks/applypatch-msg.sample`

```
#!/bin/sh
#
# An example hook script to check the commit log message taken by
# applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit.  The hook is
# allowed to edit the commit message file.
#
# To enable this hook, rename this file to "applypatch-msg".

. git-sh-setup
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
:

```


### `.git/hooks/commit-msg.sample`

```
#!/bin/sh
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message.  The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit.  The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".

# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"

# This example catches duplicate Signed-off-by lines.

test "" = "$(grep '^Signed-off-by: ' "$1" |
	 sort | uniq -c | sed -e '/^[ 	]*1[ 	]/d')" || {
	echo >&2 Duplicate Signed-off-by lines.
	exit 1
}

```


### `.git/hooks/fsmonitor-watchman.sample`

```
#!/usr/bin/perl

use strict;
use warnings;
use IPC::Open2;

# An example hook script to integrate Watchman
# (https://facebook.github.io/watchman/) with git to speed up detecting
# new and modified files.
#
# The hook is passed a version (currently 2) and last update token
# formatted as a string and outputs to stdout a new update token and
# all files that have been modified since the update token. Paths must
# be relative to the root of the working tree and separated by a single NUL.
#
# To enable this hook, rename this file to "query-watchman" and set
# 'git config core.fsmonitor .git/hooks/query-watchman'
#
my ($version, $last_update_token) = @ARGV;

# Uncomment for debugging
# print STDERR "$0 $version $last_update_token\n";

# Check the hook interface version
if ($version ne 2) {
	die "Unsupported query-fsmonitor hook version '$version'.\n" .
	    "Falling back to scanning...\n";
}

my $git_work_tree = get_working_dir();

my $retry = 1;

my $json_pkg;
eval {
	require JSON::XS;
	$json_pkg = "JSON::XS";
	1;
} or do {
	require JSON::PP;
	$json_pkg = "JSON::PP";
};

launch_watchman();

sub launch_watchman {
	my $o = watchman_query();
	if (is_work_tree_watched($o)) {
		output_result($o->{clock}, @{$o->{files}});
	}
}

sub output_result {
	my ($clockid, @files) = @_;

	# Uncomment for debugging watchman output
	# open (my $fh, ">", ".git/watchman-output.out");
	# binmode $fh, ":utf8";
	# print $fh "$clockid\n@files\n";
	# close $fh;

	binmode STDOUT, ":utf8";
	print $clockid;
	print "\0";
	local $, = "\0";
	print @files;
}

sub watchman_clock {
	my $response = qx/watchman clock "$git_work_tree"/;
	die "Failed to get clock id on '$git_work_tree'.\n" .
		"Falling back to scanning...\n" if $? != 0;

	return $json_pkg->new->utf8->decode($response);
}

sub watchman_query {
	my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
	or die "open2() failed: $!\n" .
	"Falling back to scanning...\n";

	# In the query expression below we're asking for names of files that
	# changed since $last_update_token but not from the .git folder.
	#
	# To accomplish this, we're using the "since" generator to use the
	# recency index to select candidate nodes and "fields" to limit the
	# output to file names only. Then we're using the "expression" term to
	# further constrain the results.
	my $last_update_line = "";
	if (substr($last_update_token, 0, 1) eq "c") {
		$last_update_token = "\"$last_update_token\"";
		$last_update_line = qq[\n"since": $last_update_token,];
	}
	my $query = <<"	END";
		["query", "$git_work_tree", {$last_update_line
			"fields": ["name"],
			"expression": ["not", ["dirname", ".git"]]
		}]
	END

	# Uncomment for debugging the watchman query
	# open (my $fh, ">", ".git/watchman-query.json");
	# print $fh $query;
	# close $fh;

	print CHLD_IN $query;
	close CHLD_IN;
	my $response = do {local $/; <CHLD_OUT>};

	# Uncomment for debugging the watch response
	# open ($fh, ">", ".git/watchman-response.json");
	# print $fh $response;
	# close $fh;

	die "Watchman: command returned no output.\n" .
	"Falling back to scanning...\n" if $response eq "";
	die "Watchman: command returned invalid output: $response\n" .
	"Falling back to scanning...\n" unless $response =~ /^\{/;

	return $json_pkg->new->utf8->decode($response);
}

sub is_work_tree_watched {
	my ($output) = @_;
	my $error = $output->{error};
	if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
		$retry--;
		my $response = qx/watchman watch "$git_work_tree"/;
		die "Failed to make watchman watch '$git_work_tree'.\n" .
		    "Falling back to scanning...\n" if $? != 0;
		$output = $json_pkg->new->utf8->decode($response);
		$error = $output->{error};
		die "Watchman: $error.\n" .
		"Falling back to scanning...\n" if $error;

		# Uncomment for debugging watchman output
		# open (my $fh, ">", ".git/watchman-output.out");
		# close $fh;

		# Watchman will always return all files on the first query so
		# return the fast "everything is dirty" flag to git and do the
		# Watchman query just to get it over with now so we won't pay
		# the cost in git to look up each individual file.
		my $o = watchman_clock();
		$error = $output->{error};

		die "Watchman: $error.\n" .
		"Falling back to scanning...\n" if $error;

		output_result($o->{clock}, ("/"));
		$last_update_token = $o->{clock};

		eval { launch_watchman() };
		return 0;
	}

	die "Watchman: $error.\n" .
	"Falling back to scanning...\n" if $error;

	return 1;
}

sub get_working_dir {
	my $working_dir;
	if ($^O =~ 'msys' || $^O =~ 'cygwin') {
		$working_dir = Win32::GetCwd();
		$working_dir =~ tr/\\/\//;
	} else {
		require Cwd;
		$working_dir = Cwd::cwd();
	}

	return $working_dir;
}

```


### `.git/hooks/post-update.sample`

```
#!/bin/sh
#
# An example hook script to prepare a packed repository for use over
# dumb transports.
#
# To enable this hook, rename this file to "post-update".

exec git update-server-info

```


### `.git/hooks/pre-applypatch.sample`

```
#!/bin/sh
#
# An example hook script to verify what is about to be committed
# by applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-applypatch".

. git-sh-setup
precommit="$(git rev-parse --git-path hooks/pre-commit)"
test -x "$precommit" && exec "$precommit" ${1+"$@"}
:

```


### `.git/hooks/pre-commit.sample`

```
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments.  The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".

if git rev-parse --verify HEAD >/dev/null 2>&1
then
	against=HEAD
else
	# Initial commit: diff against an empty tree object
	against=$(git hash-object -t tree /dev/null)
fi

# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)

# Redirect output to stderr.
exec 1>&2

# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
	# Note that the use of brackets around a tr range is ok here, (it's
	# even required, for portability to Solaris 10's /usr/bin/tr), since
	# the square bracket bytes happen to fall in the designated range.
	test $(git diff --cached --name-only --diff-filter=A -z $against |
	  LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
	cat <<\EOF
Error: Attempt to add a non-ASCII file name.

This can cause problems if you want to work with people on other platforms.

To be portable it is advisable to rename the file.

If you know what you are doing you can disable this check using:

  git config hooks.allownonascii true
EOF
	exit 1
fi

# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

```


### `.git/hooks/pre-merge-commit.sample`

```
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git merge" with no arguments.  The hook should
# exit with non-zero status after issuing an appropriate message to
# stderr if it wants to stop the merge commit.
#
# To enable this hook, rename this file to "pre-merge-commit".

. git-sh-setup
test -x "$GIT_DIR/hooks/pre-commit" &&
        exec "$GIT_DIR/hooks/pre-commit"
:

```


### `.git/hooks/pre-push.sample`

```
#!/bin/sh

# An example hook script to verify what is about to be pushed.  Called by "git
# push" after it has checked the remote status, but before anything has been
# pushed.  If this script exits with a non-zero status nothing will be pushed.
#
# This hook is called with the following parameters:
#
# $1 -- Name of the remote to which the push is being done
# $2 -- URL to which the push is being done
#
# If pushing without using a named remote those arguments will be equal.
#
# Information about the commits which are being pushed is supplied as lines to
# the standard input in the form:
#
#   <local ref> <local oid> <remote ref> <remote oid>
#
# This sample shows how to prevent push of commits where the log message starts
# with "WIP" (work in progress).

remote="$1"
url="$2"

zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')

while read local_ref local_oid remote_ref remote_oid
do
	if test "$local_oid" = "$zero"
	then
		# Handle delete
		:
	else
		if test "$remote_oid" = "$zero"
		then
			# New branch, examine all commits
			range="$local_oid"
		else
			# Update to existing branch, examine new commits
			range="$remote_oid..$local_oid"
		fi

		# Check for WIP commit
		commit=$(git rev-list -n 1 --grep '^WIP' "$range")
		if test -n "$commit"
		then
			echo >&2 "Found WIP commit in $local_ref, not pushing"
			exit 1
		fi
	fi
done

exit 0

```


### `.git/hooks/pre-rebase.sample`

```
#!/bin/sh
#
# Copyright (c) 2006, 2008 Junio C Hamano
#
# The "pre-rebase" hook is run just before "git rebase" starts doing
# its job, and can prevent the command from running by exiting with
# non-zero status.
#
# The hook is called with the following parameters:
#
# $1 -- the upstream the series was forked from.
# $2 -- the branch being rebased (or empty when rebasing the current branch).
#
# This sample shows how to prevent topic branches that are already
# merged to 'next' branch from getting rebased, because allowing it
# would result in rebasing already published history.

publish=next
basebranch="$1"
if test "$#" = 2
then
	topic="refs/heads/$2"
else
	topic=`git symbolic-ref HEAD` ||
	exit 0 ;# we do not interrupt rebasing detached HEAD
fi

case "$topic" in
refs/heads/??/*)
	;;
*)
	exit 0 ;# we do not interrupt others.
	;;
esac

# Now we are dealing with a topic branch being rebased
# on top of master.  Is it OK to rebase it?

# Does the topic really exist?
git show-ref -q "$topic" || {
	echo >&2 "No such branch $topic"
	exit 1
}

# Is topic fully merged to master?
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
if test -z "$not_in_master"
then
	echo >&2 "$topic is fully merged to master; better remove it."
	exit 1 ;# we could allow it, but there is no point.
fi

# Is topic ever merged to next?  If so you should not be rebasing it.
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
only_next_2=`git rev-list ^master           ${publish} | sort`
if test "$only_next_1" = "$only_next_2"
then
	not_in_topic=`git rev-list "^$topic" master`
	if test -z "$not_in_topic"
	then
		echo >&2 "$topic is already up to date with master"
		exit 1 ;# we could allow it, but there is no point.
	else
		exit 0
	fi
else
	not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
	/usr/bin/perl -e '
		my $topic = $ARGV[0];
		my $msg = "* $topic has commits already merged to public branch:\n";
		my (%not_in_next) = map {
			/^([0-9a-f]+) /;
			($1 => 1);
		} split(/\n/, $ARGV[1]);
		for my $elem (map {
				/^([0-9a-f]+) (.*)$/;
				[$1 => $2];
			} split(/\n/, $ARGV[2])) {
			if (!exists $not_in_next{$elem->[0]}) {
				if ($msg) {
					print STDERR $msg;
					undef $msg;
				}
				print STDERR " $elem->[1]\n";
			}
		}
	' "$topic" "$not_in_next" "$not_in_master"
	exit 1
fi

<<\DOC_END

This sample hook safeguards topic branches that have been
published from being rewound.

The workflow assumed here is:

 * Once a topic branch forks from "master", "master" is never
   merged into it again (either directly or indirectly).

 * Once a topic branch is fully cooked and merged into "master",
   it is deleted.  If you need to build on top of it to correct
   earlier mistakes, a new topic branch is created by forking at
   the tip of the "master".  This is not strictly necessary, but
   it makes it easier to keep your history simple.

 * Whenever you need to test or publish your changes to topic
   branches, merge them into "next" branch.

The script, being an example, hardcodes the publish branch name
to be "next", but it is trivial to make it configurable via
$GIT_DIR/config mechanism.

With this workflow, you would want to know:

(1) ... if a topic branch has ever been merged to "next".  Young
    topic branches can have stupid mistakes you would rather
    clean up before publishing, and things that have not been
    merged into other branches can be easily rebased without
    affecting other people.  But once it is published, you would
    not want to rewind it.

(2) ... if a topic branch has been fully merged to "master".
    Then you can delete it.  More importantly, you should not
    build on top of it -- other people may already want to
    change things related to the topic as patches against your
    "master", so if you need further changes, it is better to
    fork the topic (perhaps with the same name) afresh from the
    tip of "master".

Let's look at this example:

		   o---o---o---o---o---o---o---o---o---o "next"
		  /       /           /           /
		 /   a---a---b A     /           /
		/   /               /           /
	       /   /   c---c---c---c B         /
	      /   /   /             \         /
	     /   /   /   b---b C     \       /
	    /   /   /   /             \     /
    ---o---o---o---o---o---o---o---o---o---o---o "master"


A, B and C are topic branches.

 * A has one fix since it was merged up to "next".

 * B has finished.  It has been fully merged up to "master" and "next",
   and is ready to be deleted.

 * C has not merged to "next" at all.

We would want to allow C to be rebased, refuse A, and encourage
B to be deleted.

To compute (1):

	git rev-list ^master ^topic next
	git rev-list ^master        next

	if these match, topic has not merged in next at all.

To compute (2):

	git rev-list master..topic

	if this is empty, it is fully merged to "master".

DOC_END

```


### `.git/hooks/pre-receive.sample`

```
#!/bin/sh
#
# An example hook script to make use of push options.
# The example simply echoes all push options that start with 'echoback='
# and rejects all pushes when the "reject" push option is used.
#
# To enable this hook, rename this file to "pre-receive".

if test -n "$GIT_PUSH_OPTION_COUNT"
then
	i=0
	while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
	do
		eval "value=\$GIT_PUSH_OPTION_$i"
		case "$value" in
		echoback=*)
			echo "echo from the pre-receive-hook: ${value#*=}" >&2
			;;
		reject)
			exit 1
		esac
		i=$((i + 1))
	done
fi

```


### `.git/hooks/prepare-commit-msg.sample`

```
#!/bin/sh
#
# An example hook script to prepare the commit log message.
# Called by "git commit" with the name of the file that has the
# commit message, followed by the description of the commit
# message's source.  The hook's purpose is to edit the commit
# message file.  If the hook fails with a non-zero status,
# the commit is aborted.
#
# To enable this hook, rename this file to "prepare-commit-msg".

# This hook includes three examples. The first one removes the
# "# Please enter the commit message..." help message.
#
# The second includes the output of "git diff --name-status -r"
# into the message, just before the "git status" output.  It is
# commented because it doesn't cope with --amend or with squashed
# commits.
#
# The third example adds a Signed-off-by line to the message, that can
# still be edited.  This is rarely a good idea.

COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3

/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"

# case "$COMMIT_SOURCE,$SHA1" in
#  ,|template,)
#    /usr/bin/perl -i.bak -pe '
#       print "\n" . `git diff --cached --name-status -r`
# 	 if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
#  *) ;;
# esac

# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
# if test -z "$COMMIT_SOURCE"
# then
#   /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
# fi

```


### `.git/hooks/push-to-checkout.sample`

```
#!/bin/sh

# An example hook script to update a checked-out tree on a git push.
#
# This hook is invoked by git-receive-pack(1) when it reacts to git
# push and updates reference(s) in its repository, and when the push
# tries to update the branch that is currently checked out and the
# receive.denyCurrentBranch configuration variable is set to
# updateInstead.
#
# By default, such a push is refused if the working tree and the index
# of the remote repository has any difference from the currently
# checked out commit; when both the working tree and the index match
# the current commit, they are updated to match the newly pushed tip
# of the branch. This hook is to be used to override the default
# behaviour; however the code below reimplements the default behaviour
# as a starting point for convenient modification.
#
# The hook receives the commit with which the tip of the current
# branch is going to be updated:
commit=$1

# It can exit with a non-zero status to refuse the push (when it does
# so, it must not modify the index or the working tree).
die () {
	echo >&2 "$*"
	exit 1
}

# Or it can make any necessary changes to the working tree and to the
# index to bring them to the desired state when the tip of the current
# branch is updated to the new commit, and exit with a zero status.
#
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
# in order to emulate git fetch that is run in the reverse direction
# with git push, as the two-tree form of git read-tree -u -m is
# essentially the same as git switch or git checkout that switches
# branches while keeping the local changes in the working tree that do
# not interfere with the difference between the branches.

# The below is a more-or-less exact translation to shell of the C code
# for the default behaviour for git's push-to-checkout hook defined in
# the push_to_deploy() function in builtin/receive-pack.c.
#
# Note that the hook will be executed from the repository directory,
# not from the working tree, so if you want to perform operations on
# the working tree, you will have to adapt your code accordingly, e.g.
# by adding "cd .." or using relative paths.

if ! git update-index -q --ignore-submodules --refresh
then
	die "Up-to-date check failed"
fi

if ! git diff-files --quiet --ignore-submodules --
then
	die "Working directory has unstaged changes"
fi

# This is a rough translation of:
#
#   head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
if git cat-file -e HEAD 2>/dev/null
then
	head=HEAD
else
	head=$(git hash-object -t tree --stdin </dev/null)
fi

if ! git diff-index --quiet --cached --ignore-submodules $head --
then
	die "Working directory has staged changes"
fi

if ! git read-tree -u -m "$commit"
then
	die "Could not update working tree to new HEAD"
fi

```


### `.git/hooks/update.sample`

```
#!/bin/sh
#
# An example hook script to block unannotated tags from entering.
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
#
# To enable this hook, rename this file to "update".
#
# Config
# ------
# hooks.allowunannotated
#   This boolean sets whether unannotated tags will be allowed into the
#   repository.  By default they won't be.
# hooks.allowdeletetag
#   This boolean sets whether deleting tags will be allowed in the
#   repository.  By default they won't be.
# hooks.allowmodifytag
#   This boolean sets whether a tag may be modified after creation. By default
#   it won't be.
# hooks.allowdeletebranch
#   This boolean sets whether deleting branches will be allowed in the
#   repository.  By default they won't be.
# hooks.denycreatebranch
#   This boolean sets whether remotely creating branches will be denied
#   in the repository.  By default this is allowed.
#

# --- Command line
refname="$1"
oldrev="$2"
newrev="$3"

# --- Safety check
if [ -z "$GIT_DIR" ]; then
	echo "Don't run this script from the command line." >&2
	echo " (if you want, you could supply GIT_DIR then run" >&2
	echo "  $0 <ref> <oldrev> <newrev>)" >&2
	exit 1
fi

if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
	echo "usage: $0 <ref> <oldrev> <newrev>" >&2
	exit 1
fi

# --- Config
allowunannotated=$(git config --type=bool hooks.allowunannotated)
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)

# check for no description
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
case "$projectdesc" in
"Unnamed repository"* | "")
	echo "*** Project description file hasn't been set" >&2
	exit 1
	;;
esac

# --- Check types
# if $newrev is 0000...0000, it's a commit to delete a ref.
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
if [ "$newrev" = "$zero" ]; then
	newrev_type=delete
else
	newrev_type=$(git cat-file -t $newrev)
fi

case "$refname","$newrev_type" in
	refs/tags/*,commit)
		# un-annotated tag
		short_refname=${refname##refs/tags/}
		if [ "$allowunannotated" != "true" ]; then
			echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
			echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
			exit 1
		fi
		;;
	refs/tags/*,delete)
		# delete tag
		if [ "$allowdeletetag" != "true" ]; then
			echo "*** Deleting a tag is not allowed in this repository" >&2
			exit 1
		fi
		;;
	refs/tags/*,tag)
		# annotated tag
		if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
		then
			echo "*** Tag '$refname' already exists." >&2
			echo "*** Modifying a tag is not allowed in this repository." >&2
			exit 1
		fi
		;;
	refs/heads/*,commit)
		# branch
		if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
			echo "*** Creating a branch is not allowed in this repository" >&2
			exit 1
		fi
		;;
	refs/heads/*,delete)
		# delete branch
		if [ "$allowdeletebranch" != "true" ]; then
			echo "*** Deleting a branch is not allowed in this repository" >&2
			exit 1
		fi
		;;
	refs/remotes/*,commit)
		# tracking branch
		;;
	refs/remotes/*,delete)
		# delete tracking branch
		if [ "$allowdeletebranch" != "true" ]; then
			echo "*** Deleting a tracking branch is not allowed in this repository" >&2
			exit 1
		fi
		;;
	*)
		# Anything else (is there anything else?)
		echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
		exit 1
		;;
esac

# --- Finished
exit 0

```


### `.git/index`

```
<binary file omitted>
```


### `.git/info/exclude`

```
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

```


### `.git/logs/HEAD`

```
0000000000000000000000000000000000000000 9fd7096f88b8a9cb2ae9fc31f23a255f060eaff6 Maitreyi V <maitreyi.v@latentforce.ai> 1782222642 +0530	clone: from https://github.com/trycourier/courier-skills

```


### `.git/logs/refs/heads/main`

```
0000000000000000000000000000000000000000 9fd7096f88b8a9cb2ae9fc31f23a255f060eaff6 Maitreyi V <maitreyi.v@latentforce.ai> 1782222642 +0530	clone: from https://github.com/trycourier/courier-skills

```


### `.git/logs/refs/remotes/origin/HEAD`

```
0000000000000000000000000000000000000000 9fd7096f88b8a9cb2ae9fc31f23a255f060eaff6 Maitreyi V <maitreyi.v@latentforce.ai> 1782222642 +0530	clone: from https://github.com/trycourier/courier-skills

```


### `.git/objects/pack/pack-3f0a79b3941f9365288c98941767e28e9f22e057.idx`

```
<binary file omitted>
```


### `.git/objects/pack/pack-3f0a79b3941f9365288c98941767e28e9f22e057.pack`

```
<binary file omitted>
```


### `.git/packed-refs`

```
# pack-refs with: peeled fully-peeled sorted 
9fd7096f88b8a9cb2ae9fc31f23a255f060eaff6 refs/remotes/origin/main

```


### `.git/refs/heads/main`

```
9fd7096f88b8a9cb2ae9fc31f23a255f060eaff6

```


### `.git/refs/remotes/origin/HEAD`

```
ref: refs/remotes/origin/main

```


### `.git/shallow`

```
9fd7096f88b8a9cb2ae9fc31f23a255f060eaff6

```


### `LICENSE`

```
MIT License

Copyright (c) 2026 Courier, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

```


### `README.md`

````markdown
# Courier Notification Skills

A comprehensive agent skill for building production-ready notification systems across multiple channels. Covers everything from email deliverability to push permission priming, with a focus on user experience and reliability.

> **For AI Agents & Developers**: This skill provides structured guidance for implementing notifications with the [Courier API](https://www.courier.com). Use it to send emails, SMS, push notifications, Slack messages, and more through a unified interface.

## Why Use This Skill

- **Multi-channel notifications** — Send messages via email, SMS, push, Slack, Microsoft Teams, WhatsApp, and in-app inbox from a single API
- **Production-ready patterns** — Battle-tested code examples for authentication flows, order updates, billing alerts, and more
- **Developer-first** — TypeScript, Python, CLI, and curl examples for key patterns

## Who This Is For

- Developers building SaaS, e-commerce, marketplaces, or mobile apps
- Teams consolidating notification providers into a single API
- Engineers implementing user preferences, unsubscribe handling, or multi-channel routing

## Installation

**Cursor** (global, available in all projects):

```bash
git clone https://github.com/trycourier/courier-skills.git ~/.cursor/skills/courier-skills
```

**Cursor** (project-specific):

```bash
git clone https://github.com/trycourier/courier-skills.git .cursor/skills/courier-skills
```

**Claude Code**:

```bash
git clone https://github.com/trycourier/courier-skills.git ~/.claude/skills/courier-skills
```

Claude Code discovers skills from `~/.claude/skills/` automatically. The skill's `SKILL.md` frontmatter (`name` and `description` fields) is used for discovery — no additional configuration needed.

**Other AI Assistants** (Windsurf, Cline, etc.):

Clone to the skill directory supported by your assistant, or point it at the `SKILL.md` file manually. The skill follows standard markdown conventions and works with any AI coding tool that supports agent skills.

## What This Skill Covers

**Channels**
- Email (deliverability, SPF/DKIM/DMARC, design)
- SMS (10DLC registration, character limits)
- Push notifications (iOS/Android, permission priming)
- In-app inbox (real-time, badges, read states)
- Slack (Block Kit, bot setup)
- Microsoft Teams (Adaptive Cards)
- WhatsApp (templates, 24hr window)

**Transactional Notifications**
- Authentication (password reset, OTP, verification, security alerts)
- Orders (confirmation, shipping, delivery)
- Billing (receipts, dunning, subscriptions)
- Appointments (booking, reminders, rescheduling)
- Account (welcome, profile updates, settings)

**Growth Notifications**
- Onboarding (activation, first value, setup)
- Feature adoption (discovery, education, milestones)
- Engagement (activity, retention, habits)
- Re-engagement (winback, cart abandonment)
- Referral (viral loops, invites, rewards)
- Campaigns (promotional, upgrades)

**Cross-Cutting Guides**
- Quickstart (send your first notification)
- Multi-channel orchestration and routing
- User preference management
- Reliability (idempotency, retry logic, webhook signature verification)
- Batching and digests
- Throttling and rate limiting
- Notification catalog by app type
- Journeys (multi-step notification sequences via API — delays, branches, throttling)
- Template management (CRUD, versioning, publish lifecycle)
- Elemental content format (element types, control flow, localization)
- Reusable code patterns (idempotency, consent, quiet hours, masking, retry)
- CLI (ad-hoc operations, debugging, agent workflows)
- MCP Server (structured API access for AI agents, setup for all editors)
- General migration (from any custom or third-party system)
- Migrate from Knock (concept mapping, code migration)
- Migrate from Novu (concept mapping, code migration)

## Structure

```
courier-skills/
├── SKILL.md                    # Start here - routes to the right resource
├── README.md                   # This file
└── resources/
    ├── channels/               # Channel-specific best practices
    │   ├── email.md
    │   ├── sms.md
    │   ├── push.md
    │   ├── inbox.md
    │   ├── inbox-v7-legacy.md
    │   ├── slack.md
    │   ├── ms-teams.md
    │   └── whatsapp.md
    ├── transactional/          # Transactional notification types
    │   ├── index.md
    │   ├── authentication.md
    │   ├── orders.md
    │   ├── billing.md
    │   ├── appointments.md
    │   └── account.md
    ├── growth/                 # Growth & lifecycle notifications
    │   ├── index.md
    │   ├── onboarding.md
    │   ├── adoption.md
    │   ├── engagement.md
    │   ├── reengagement.md
    │   ├── referral.md
    │   └── campaigns.md
    └── guides/                 # Cross-cutting concerns
        ├── quickstart.md
        ├── cli.md
        ├── mcp.md
        ├── multi-channel.md
        ├── preferences.md
        ├── reliability.md
        ├── batching.md
        ├── throttling.md
        ├── catalog.md
        ├── journeys.md
        ├── templates.md
        ├── routing-strategies.md
        ├── providers.md
        ├── elemental.md
        ├── patterns.md
        ├── migrate-general.md
        ├── migrate-from-knock.md
        └── migrate-from-novu.md
```

## Quick Start

Open `SKILL.md` - it has a routing table that directs you to the right resource based on what you need to do.

## Integrations & Providers

This skill covers best practices for working with:

| Channel | Providers |
|---------|-----------|
| Email | SendGrid, Amazon SES, Postmark, Mailgun, Resend, SparkPost |
| SMS | Twilio, MessageBird, Vonage, Plivo, Telnyx |
| Push | Firebase Cloud Messaging (FCM), Apple Push Notification Service (APNs), Expo |
| Chat | Slack, Microsoft Teams |
| Messaging | WhatsApp Business API, Facebook Messenger |

## Frequently Asked Questions

**How do I send a notification with Courier?**  
Call `client.send.message({ message: { to, template, data } })` with the Node SDK (`@trycourier/courier`) or `client.send.message(message={...})` with the Python SDK (`trycourier`). Both SDKs read the API key from the `COURIER_API_KEY` environment variable by default. See the channel-specific guides for full examples.

**What's the difference between transactional and marketing notifications?**  
Transactional notifications are triggered by user actions (password reset, order confirmation). Marketing notifications are sent proactively for engagement.

**How do I handle notification preferences?**  
See `resources/guides/preferences.md` for implementing user preference centers, channel opt-outs, and frequency controls.

**How do I ensure email deliverability?**  
Configure SPF, DKIM, and DMARC. Warm up your sending domain. Monitor bounce rates. Full guide in `resources/channels/email.md`.

**What about rate limiting and throttling?**
Courier handles provider rate limits automatically. For custom throttling logic, see `resources/guides/throttling.md`.

## Contributing

Found an issue or want to add a notification pattern? PRs welcome.

## License

[MIT](./LICENSE) © Courier, Inc.

---

Built for the [Courier](https://www.courier.com) notification platform. Works with any AI coding assistant that supports agent skills.

````


### `resources/channels/email.md`

````markdown
# Email Channel

## Quick Reference

### Rules
- MUST configure SPF, DKIM, and DMARC before sending (Gmail/Yahoo/Microsoft require it)
- Only ONE SPF record per domain (combine includes if multiple providers)
- DMARC progression: start with `p=none`, then `p=quarantine`, finally `p=reject`
- Keep bounce rate under 2% (under 1% is good)
- Keep complaint rate under 0.1%
- Marketing emails MUST include a physical address and unsubscribe link
- Use subdomains to separate transactional (`t.acme.com`) from marketing (`m.acme.com`)
- Avoid `noreply@` addresses - use monitored inboxes
- Subject lines: under 50 characters for mobile
- Pre-header text: under 90 characters, don't repeat subject

### Common Mistakes
- Missing SPF/DKIM/DMARC authentication (emails go to spam or get rejected)
- Having multiple SPF records (only one allowed per domain)
- Rushing IP warming (causes reputation damage)
- Sending to purchased lists (destroys sender reputation)
- Adding promotional content to transactional emails (reclassifies as marketing)
- Ignoring bounces and complaints (reputation damage)
- Sudden volume spikes (triggers spam filters)
- Using URL shorteners like bit.ly (spam trigger)
- Missing unsubscribe link in marketing emails

### Templates

**Basic Email Send (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { email: "jane@example.com" },
    template: "nt_01kmrc1k3q6x9v2d5c8n1w4ht",
    data: { userName: "Jane" }
  }
});
```

**Basic Email Send (Python):**
```python
client.send.message(
    message={
        "to": {"email": "jane@example.com"},
        "template": "nt_01kmrc1k3q6x9v2d5c8n1w4ht",
        "data": {"userName": "Jane"},
    }
)
```

**With Provider Override (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { email: "jane@example.com" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    channels: {
      email: {
        override: {
          from: { email: "orders@t.acme.com", name: "Acme Orders" },
          reply_to: "support@acme.com"
        }
      }
    }
  }
});
```

---

Best practices for sending emails that reach inboxes and engage users.

## Email Authentication

**Required by Gmail/Yahoo/Microsoft** - unauthenticated emails will be rejected or spam-filtered.

### SPF (Sender Policy Framework)

Specifies which servers can send email for your domain.

```
# Example DNS TXT record
v=spf1 include:sendgrid.net include:_spf.google.com ~all
```

- Add the SPF TXT record provided by your email provider
- `~all` (soft fail) for setup flexibility, `-all` (hard fail) for production
- Only one SPF record per domain (combine includes if multiple providers)

### DKIM (DomainKeys Identified Mail)

Cryptographic signature proving email authenticity.

- Your email provider generates DKIM keys
- Add CNAME or TXT records to your DNS as instructed
- Common selector names: `s1._domainkey`, `mail._domainkey`

### DMARC

Policy for handling SPF/DKIM failures + reporting.

```
# Start with monitoring (p=none)
_dmarc.yourdomain.com  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com"

# Progress to quarantine
v=DMARC1; p=quarantine; pct=10; rua=mailto:dmarc@yourdomain.com

# Finally, reject
v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com
```

**Progression:** `p=none` (monitor) → `p=quarantine` (spam folder) → `p=reject` (block)

## Sender Configuration

| Field | Best Practice | Example |
|-------|--------------|---------|
| From Name | Brand name for transactional/system mail (recognizable sender builds reputation); a person's name for Primary-inbox marketing (e.g., Gmail Promotions → Primary) | Transactional: `Acme` · Marketing: `Jane at Acme` |
| From Email | Subdomain for transactional | notifications@t.acme.com |
| Reply-To | Monitored inbox | support@acme.com |

### Subdomain Strategy

Separate sender reputation by email type:

| Subdomain | Purpose | Example |
|-----------|---------|---------|
| `t.acme.com` | Transactional | receipts, password resets |
| `m.acme.com` | Marketing | newsletters, promotions |
| `alerts.acme.com` | Alerts | security, system status |

**Why?** If marketing reputation suffers, transactional emails still deliver.

### Avoid `noreply@`

- Users DO reply to transactional emails (questions about orders, etc.)
- Signals low trust to spam filters
- Use `notifications@` or `hello@` with forwarding rules instead

## Sender Reputation

### IP Warming Schedule

New sending domain/IP? Gradually increase volume:

| Week | Daily Volume | Target Audience |
|------|-------------|-----------------|
| 1 | 50-100 | Most engaged users |
| 2 | 200-500 | Recent active users |
| 3 | 1,000-2,000 | Active in last 30 days |
| 4 | 5,000-10,000 | Full list |
| 5+ | Full volume | All subscribers |

**Tips:**
- Send to engaged users first (recently opened/clicked)
- Maintain consistent daily volume
- Monitor bounce rates closely
- Pause if bounce rate exceeds 2%

### Reputation Metrics

| Metric | Good | Warning | Critical |
|--------|------|---------|----------|
| Bounce rate | <1% | 1-2% | >2% |
| Complaint rate | <0.05% | 0.05-0.1% | >0.1% |
| Open rate | >20% | 10-20% | <10% |

### List Hygiene

```typescript
// Implement automatic list cleaning
async function cleanEmailList() {
  // Remove hard bounces immediately
  await removeHardBounces();
  
  // Remove soft bounces after 3 consecutive failures
  await removePersistentSoftBounces(3);
  
  // Sunset inactive subscribers (no opens in 6+ months)
  await archiveInactiveSubscribers(180);
}
```

## Email Design

### Mobile-First (60%+ opens on mobile)

| Element | Specification |
|---------|---------------|
| Layout | Single column, max 600px |
| Body text | 16px minimum |
| Headings | 20-24px |
| CTA buttons | 44x44px minimum tap target |
| Line height | 1.5 for readability |

### Email Structure

```
┌─────────────────────────────────────┐
│           Logo / Header             │
├─────────────────────────────────────┤
│                                     │
│       Primary Message               │
│       (Above the fold)              │
│                                     │
│         [ CTA Button ]              │
│                                     │
├─────────────────────────────────────┤
│       Supporting Details            │
│       • Item 1                      │
│       • Item 2                      │
├─────────────────────────────────────┤
│   Footer: Unsubscribe | Address     │
└─────────────────────────────────────┘
```

### Subject Lines

| Do | Don't |
|----|-------|
| "Your order #12345 shipped" | "Order update" |
| "Reset your Acme password" | "Important: Action Required!!!" |
| "Jane commented on your post" | "New notification" |
| Keep under 50 characters | ALL CAPS or excessive punctuation |

### Pre-header Text

The preview text shown after the subject line:

```html
<!-- Visible pre-header -->
<span style="display:block;max-width:0;overflow:hidden;">
  Your order arrives Thursday. Track your package →
</span>
```

- Reinforce or complement the subject
- Keep under 90 characters
- Don't repeat the subject line

## Courier Integration

For the basic email send pattern, see the [Quick Reference templates](#templates) above. The examples below show additional options.

### With Inline Content

```typescript
await client.send.message({
  message: {
    to: { email: "jane@example.com" },
    content: {
      title: "Your order has shipped",
      body: "Hi Jane, your order #12345 is on the way!\n\nTrack at: https://acme.com/track/12345"
    }
  }
});
```

### With Email-Specific Overrides

```typescript
await client.send.message({
  message: {
    to: { email: "jane@example.com" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: { orderNumber: "12345" },
    channels: {
      email: {
        override: {
          from: {
            email: "orders@t.acme.com",
            name: "Acme Orders"
          },
          reply_to: "support@acme.com",
          bcc: "records@acme.com",
          headers: {
            "X-Order-ID": "12345"
          }
        }
      }
    }
  }
});
```

### Provider Failover

Configure multiple email providers in Courier dashboard. If SendGrid fails, automatically try Mailgun:

```
Priority 1: SendGrid
Priority 2: Mailgun  
Priority 3: AWS SES
```

Courier handles failover automatically based on your configuration.

## Link Tracking

Courier can track link clicks for analytics:

```typescript
await client.send.message({
  message: {
    to: { email: "jane@example.com" },
    template: "nt_01kmrbu5x8q2v6d1c4n7w9hj",
    // Link tracking enabled in template settings
  }
});

// Receive webhook when links are clicked
// POST /webhooks/courier
// { type: "message:updated", data: { status: "CLICKED", id: "...", ... } }
```

## Bounce Handling

| Type | Cause | Action |
|------|-------|--------|
| Hard bounce | Invalid address, domain doesn't exist | Remove immediately |
| Soft bounce | Mailbox full, server temporarily down | Retry: 1h → 4h → 24h |
| Block | Spam filter rejection | Review content, check authentication |

```typescript
// Handle delivery webhooks from Courier
// Bounces surface as UNDELIVERABLE message:updated events with
// provider-specific reason in data.providers[].error and data.reason.
app.post('/webhooks/courier', async (req, res) => {
  const { type, data } = req.body;

  if (type === 'message:updated' && data.status === 'UNDELIVERABLE') {
    const providerError = data.providers?.[0]?.error;
    if (providerError?.type === 'hard_bounce') {
      await markEmailInvalid(data.recipient);
    } else {
      await incrementSoftBounceCount(data.recipient);
    }
  }

  res.sendStatus(200);
});
```

## Common Templates

### Password Reset

```jsonc
// Template: PASSWORD_RESET
// Subject: Reset your Acme password
// Body:    Click to reset. Expires in 1 hour. Didn't request this? Ignore or contact support.
{
  "resetUrl": "https://acme.com/reset?token=abc123",
  "expiresIn": "1 hour",
  "userEmail": "jane@example.com"
}
```

### Order Confirmation

```jsonc
// Template: ORDER_CONFIRMATION (data passed to message.data)
{
  "orderNumber": "12345",
  "items": [
    { "name": "Widget", "quantity": 2, "price": 29.99 },
    { "name": "Gadget", "quantity": 1, "price": 49.99 }
  ],
  "subtotal": 109.97,
  "shipping": 5.99,
  "total": 115.96,
  "shippingAddress": { "line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105" },
  "estimatedDelivery": "January 30-31"
}
```

### Weekly Digest

```jsonc
// Template: WEEKLY_DIGEST (data passed to message.data)
{
  "userName": "Jane",
  "weekRange": "Jan 20-26",
  "stats": {
    "views": 1234,
    "likes": 56,
    "comments": 12
  },
  "topContent": [{ "id": "post-1", "title": "Intro to Courier", "url": "https://acme.com/post-1" }],
  "recommendations": [{ "id": "post-2", "title": "Deep dive: idempotency", "url": "https://acme.com/post-2" }]
}
```

## Troubleshooting

### Emails Going to Spam?

1. **Check authentication:** Verify SPF, DKIM, DMARC at [MXToolbox](https://mxtoolbox.com)
2. **Check reputation:** [Google Postmaster Tools](https://postmaster.google.com), [Microsoft SNDS](https://sendersupport.olc.protection.outlook.com/snds/)
3. **Review content:** Avoid spam trigger words, excessive images, URL shorteners
4. **Check patterns:** Sudden volume spikes damage reputation

### Low Open Rates?

| Issue | Solution |
|-------|----------|
| Poor subject lines | A/B test, be specific |
| Wrong send time | Test different times, use send-time optimization |
| Low relevance | Segment by interest, personalize |
| Deliverability issues | Check spam folder placement |

### Gmail Tabs

To land in Primary (not Promotions):
- Avoid heavy images and marketing language
- Include personalization
- Prefer a person-in-brand sender (e.g., `Jane at Acme`) for marketing mail — raw brand senders (`Acme`) are fine for transactional but tend to get sorted into Promotions for promotional copy
- Keep content relevant and expected

## Testing

### Pre-Send Checklist

- [ ] Subject line under 50 characters
- [ ] Pre-header text set
- [ ] Unsubscribe link present
- [ ] Physical address in footer
- [ ] Links tested and working
- [ ] Mobile preview checked
- [ ] Plain text version included
- [ ] Personalization tokens have fallbacks

### Send Test Emails

```typescript
// Send to yourself first
await client.send.message({
  message: {
    to: { email: "your-test@example.com" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: { /* test data */ }
  }
});
```

## Related

- [Quickstart](../guides/quickstart.md) - New to Courier? Start here for install, API key, first send
- [Multi-Channel](../guides/multi-channel.md) - Email as part of routing strategy
- [Reliability](../guides/reliability.md) - Retry logic and error handling
- [Batching](../guides/batching.md) - Email digests
- [Throttling](../guides/throttling.md) - Send rate management

````


### `resources/channels/inbox-v7-legacy.md`

````markdown
# In-App Inbox — v7 Legacy Reference

> **For new projects: use v8.** See [inbox.md](./inbox.md). Only read this file when you are confirming that existing code is on v7 **and** the user has an explicit blocker preventing upgrade (e.g., v7-only Tags or Pins that haven't landed in v8 yet).

## Quick Reference

### When to use this file

- An existing codebase imports `@trycourier/react-provider`, `@trycourier/react-inbox`, or `@trycourier/react-toast`, **and**
- The user has asked about maintaining that code, **and**
- They cannot upgrade yet.

For every other case — including new inboxes, new features on existing inboxes, or any refactor — write v8 code per [inbox.md](./inbox.md) and help the user migrate.

### Rules for v7 work

- Avoid using v7 for new projects; default to v8 unless there's a concrete blocker.
- For net-new inbox features, prefer a v8 migration first. If migration is temporarily blocked, keep v7 changes as small and local as possible.
- Client Keys are still accepted by v7 but JWT is strongly preferred. If a codebase passes a `clientKey` prop, recommend moving to JWT as part of the next meaningful touch.
- If staying on v7, leave a migration follow-up note (issue or TODO) so the codebase does not get stuck in legacy mode.

### Quick validation checklist (before shipping a v7 change)

- Confirm this is truly v7 using package/import signals in the Version Detection table below.
- Confirm and document the migration blocker (for example, dependency on v7-only Tags or Pins).
- Keep scope to maintenance/bugfix updates; avoid introducing new v7-only architectural surface.
- Run a smoke test: inbox renders, auth succeeds, and new messages appear in the feed.
- Add a migration pointer to v8 docs in the PR or task notes.

## Version Detection

| Signal | Version |
|--------|---------|
| `@trycourier/courier-react` or `@trycourier/courier-react-17` in `package.json` | **v8** |
| `@trycourier/courier-ui-inbox` (Web Components) in `package.json` | **v8** |
| `<CourierInbox />`, `useCourier()`, `courier.shared.signIn()` | **v8** |
| `@trycourier/react-provider`, `@trycourier/react-inbox`, `@trycourier/react-toast` in `package.json` | **v7** |
| `<CourierProvider>`, `<Inbox />`, `useInbox()`, `clientKey` prop | **v7** |
| New project / no existing code | **Use v8** |

## v7 Shape (for recognition only)

A typical v7 setup uses three separate packages and a provider wrapper:

```tsx
// v7 — legacy, do not use for new code
import { CourierProvider } from "@trycourier/react-provider";
import { Inbox } from "@trycourier/react-inbox";

export default function App() {
  return (
    <CourierProvider
      clientKey={process.env.NEXT_PUBLIC_COURIER_CLIENT_KEY}
      userId="user-123"
    >
      <Inbox />
    </CourierProvider>
  );
}
```

Contrast with the v8 shape in [inbox.md](./inbox.md): a single package (`@trycourier/courier-react`), `useCourier()` + `courier.shared.signIn()`, JWT-only, `<CourierInbox />`.

## Key v7 → v8 Differences

| Concern | v7 | v8 |
|---------|----|----|
| Packages | `@trycourier/react-provider` + `@trycourier/react-inbox` + `@trycourier/react-toast` | Single: `@trycourier/courier-react` |
| Wrapping | `<CourierProvider>` | No provider; `useCourier()` hook |
| Auth | `clientKey` prop accepted (JWT preferred) | JWT required |
| Sign in | Implicit via provider props | Explicit: `courier.shared.signIn({ userId, jwt })` |
| Real-time | Auto | Explicit: `inbox.listenForUpdates()` |
| Tags / Pins | Supported | **Not yet** — only blocker where v7 is still needed |

## Migration Path

When the user is ready to upgrade, the canonical migration is documented here: https://www.courier.com/docs/sdk-libraries/courier-react-v8-migration-guide

High-level steps:

1. Replace the three v7 packages with `@trycourier/courier-react`.
2. Remove `<CourierProvider>`; call `courier.shared.signIn({ userId, jwt })` inside an effect once the JWT is available.
3. Replace `<Inbox />` with `<CourierInbox />`.
4. Wire a server endpoint that issues a JWT (example in [inbox.md](./inbox.md) "Authentication").
5. After sign-in, call `inbox.registerFeeds(defaultFeeds())` and `inbox.listenForUpdates()`.
6. Migrate custom styling — v8 uses a different theming API.

## Related

- [Inbox (v8)](./inbox.md) — primary, authoritative guide
- [v8 Migration Guide](https://www.courier.com/docs/sdk-libraries/courier-react-v8-migration-guide) — official step-by-step

<!-- Target line budget: <= 200 lines. This file exists to recognize v7 and route to migration; detailed v8 content must live in inbox.md. -->

````


### `resources/channels/inbox.md`

````markdown
# In-App Inbox

## Quick Reference

### Rules
- MUST use JWT authentication (v8 requires JWT; v7 also accepted client keys but JWT is preferred)
- Generate JWT server-side using Courier API key — never expose API key to client
- JWT scopes needed: `user_id:{id} inbox:read:messages inbox:write:events read:preferences`
- Title: under 50 characters
- Body: under 150 characters
- Actions: 1-2 CTAs maximum
- Always include deep link data for navigation
- Batch similar notifications (don't send 10 separate "liked your post")
- No permission required — works for all users
- **ALWAYS use v8 SDK** for new projects — v7 is legacy and should not be used for new integrations
- If the user is on v7, guide them to upgrade to v8; do not write new v7 code

### Version Detection

This file documents **v8, the current version**. Write v8 code for all new integrations.

Quick v7 vs v8 sniff: if the codebase imports `@trycourier/courier-react` or uses `<CourierInbox />` / `useCourier()`, it's v8 — continue with this file. If it imports `@trycourier/react-provider` / `@trycourier/react-inbox` / `@trycourier/react-toast` or uses `<CourierProvider>` / `<Inbox />` / a `clientKey` prop, it's **v7 (legacy)** — see [inbox-v7-legacy.md](./inbox-v7-legacy.md) for recognition patterns and migration guidance before touching the code.

**Do not write new v7 code.** If the existing project is on v7, propose migration to v8 before adding features. The v7 file exists only to help maintain existing code and guide upgrades.

### Common Mistakes
- Not using JWT authentication (JWT is required in v8)
- Exposing Courier API key in client-side code
- Generating JWT client-side (must be server-side)
- Not calling `inbox.listenForUpdates()` after auth (required for real-time updates in v8)
- Sending individual notifications instead of batching
- Missing deep link data (user clicks, nothing happens)
- Not syncing read state across channels
- Forgetting to mark inbox as read when user engages via email
- Using v7 packages (`@trycourier/react-inbox`) in a new project instead of v8 (`@trycourier/courier-react`)

---

Best practices for building in-app notification centers using Courier Inbox.

## Why In-App Inbox?

- **Always available** — No permission required, works for all users
- **Persistent** — Users can return to read later
- **Rich content** — Full formatting, images, actions
- **Cross-device sync** — Read state persists across sessions
- **Low friction** — Complements push without notification fatigue

## Courier Inbox Components

Courier provides pre-built, customizable inbox components for:

- React (v8: `@trycourier/courier-react`)
- Web Components (v8: `@trycourier/courier-ui-inbox` — works with Vue, Angular, Svelte, vanilla JS)
- React Native (`@trycourier/courier-react-native`) — see [React Native Integration](#react-native-integration) below
- iOS (Swift) — see the [Courier iOS SDK docs](https://www.courier.com/docs/sdk-libraries/ios)
- Android (Kotlin) — see the [Courier Android SDK docs](https://www.courier.com/docs/sdk-libraries/android)
- Flutter — see the [Courier Flutter SDK docs](https://www.courier.com/docs/sdk-libraries/flutter)

---

## v8 React Integration (Recommended)

v8 is a single-package SDK with a smaller bundle, no third-party dependencies, built-in dark mode, and a modern default UI.

### Installation

```bash
# React 18+
npm install @trycourier/courier-react

# React 17
npm install @trycourier/courier-react-17
```

### Authentication

Courier uses three types of credentials in different contexts:

| Credential | Where used | Exposure |
|------------|-----------|----------|
| **API Key** | Server-side SDK, CLI, raw HTTP (`COURIER_API_KEY` env var) | Never expose to client |
| **Client Key** | Deprecated v7 setups only | Safe for client-side, limited scope |
| **JWT** | Inbox, Preferences, Toast components | Generated server-side, passed to client |

The API Key is the same key regardless of which env var you store it in. The SDK and CLI just look for different variable names by convention.

JWT is required for Inbox. Generate tokens server-side using your API key. The SDKs expose the issue-token endpoint directly — prefer that over raw HTTP.

**TypeScript (`@trycourier/courier`):**

```typescript
import Courier from "@trycourier/courier";
const client = new Courier(); // reads COURIER_API_KEY

const { token } = await client.auth.issueToken({
  scope: "user_id:user-123 inbox:read:messages inbox:write:events read:preferences",
  expires_in: "7 days",
});
```

**Python (`trycourier`):**

```python
from courier import Courier
client = Courier()  # reads COURIER_API_KEY

resp = client.auth.issue_token(
    scope="user_id:user-123 inbox:read:messages inbox:write:events read:preferences",
    expires_in="7 days",
)
token = resp.token
```

**Raw HTTP** (for languages without a Courier SDK):

```bash
curl -X POST https://api.courier.com/auth/issue-token \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scope":"user_id:user-123 inbox:read:messages inbox:write:events read:preferences","expires_in":"7 days"}'
```

### JWT Refresh Strategy

JWTs expire based on `expires_in`. Build a refresh mechanism to avoid broken inbox connections:

```typescript
import Courier from "@trycourier/courier";
const courier = new Courier();

app.get("/api/courier-token", authenticate, async (req, res) => {
  const { token } = await courier.auth.issueToken({
    scope: `user_id:${req.user.id} inbox:read:messages inbox:write:events read:preferences`,
    expires_in: "7 days",
  });
  res.json({ token });
});
```

```tsx
// Client-side: refresh before expiry
function useCourierToken(userId: string) {
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    const fetchToken = () =>
      fetch("/api/courier-token")
        .then((r) => r.json())
        .then((d) => setToken(d.token));

    fetchToken();
    const interval = setInterval(fetchToken, 6 * 24 * 60 * 60 * 1000); // refresh 1 day before 7-day expiry
    return () => clearInterval(interval);
  }, [userId]);

  return token;
}
```

### Basic Setup (v8)

v8 uses `useCourier()` hook + `courier.shared.signIn()` — no provider wrapper needed:

```tsx
import { useEffect } from "react";
import { CourierInbox, useCourier } from "@trycourier/courier-react";

export default function App() {
  const courier = useCourier();

  useEffect(() => {
    fetch("/api/courier-token")
      .then((res) => res.json())
      .then((data) => {
        courier.shared.signIn({ userId: "user-123", jwt: data.token });
      });
  }, []);

  return <CourierInbox />;
}
```

### Popup Menu (v8)

```tsx
import { useEffect } from "react";
import { CourierInboxPopupMenu, useCourier } from "@trycourier/courier-react";
// See the "JWT Refresh Strategy" section above for useCourierToken.

export default function App() {
  const courier = useCourier();
  const courierToken = useCourierToken("user-123");

  useEffect(() => {
    if (!courierToken) return;
    courier.shared.signIn({ userId: "user-123", jwt: courierToken });
  }, [courierToken]);

  return <CourierInboxPopupMenu />;
}
```

### Feeds and Tabs (v8)

v8 introduces feeds and tabs for organizing messages into logical groups and filtered views:

```tsx
import { CourierInbox, type CourierInboxFeed } from "@trycourier/courier-react";

const feeds: CourierInboxFeed[] = [
  {
    feedId: "notifications",
    title: "Notifications",
    tabs: [
      { datasetId: "all", title: "All", filter: {} },
      { datasetId: "unread", title: "Unread", filter: { status: "unread" } },
      { datasetId: "important", title: "Important", filter: { tags: ["important"] } },
      { datasetId: "archived", title: "Archived", filter: { archived: true } },
    ],
  },
];

<CourierInbox feeds={feeds} />;
```

Filter options per tab:

| Filter Property | Type | Description |
|----------------|------|-------------|
| `tags` | `string[]` | Messages with any of the specified tags |
| `archived` | `boolean` | Include archived messages (defaults to `false`) |
| `status` | `'read' \| 'unread'` | Filter by read/unread status |

### Customized Inbox (v8)

v8 uses native theming via `lightTheme`/`darkTheme` props — no styled-components dependency:

```tsx
import { CourierInbox, type CourierInboxTheme } from "@trycourier/courier-react";

const theme: CourierInboxTheme = {
  inbox: {
    header: {
      filters: { unreadIndicator: { backgroundColor: "#9121C2" } },
    },
    list: {
      item: { unreadIndicatorColor: "#9121C2" },
    },
  },
};

<CourierInbox lightTheme={theme} darkTheme={theme} mode="light" />;
```

Dark mode switches automatically with `mode="system"`, or force with `mode="light"` / `mode="dark"`.

### Custom Components (v8)

Override individual parts with render props:

| Render Prop | Type Signature |
|-------------|----------------|
| `renderListItem` | `(props: CourierInboxListItemFactoryProps) => ReactNode` |
| `renderHeader` | `(props: CourierInboxHeaderFactoryProps) => ReactNode` |
| `renderMenuButton` | `(props: CourierInboxMenuButtonFactoryProps) => ReactNode` |
| `renderLoadingState` | `(props: CourierInboxStateLoadingFactoryProps) => ReactNode` |
| `renderEmptyState` | `(props: CourierInboxStateEmptyFactoryProps) => ReactNode` |
| `renderErrorState` | `(props: CourierInboxStateErrorFactoryProps) => ReactNode` |
| `renderPaginationItem` | `(props: CourierInboxPaginationItemFactoryProps) => ReactNode` |

```tsx
import { CourierInbox, type CourierInboxListItemFactoryProps } from "@trycourier/courier-react";

<CourierInbox
  renderListItem={({ message, index }: CourierInboxListItemFactoryProps) => (
    <div className="custom-message">
      <strong>{message.title}</strong>
      <p>{message.body}</p>
      <time>{message.created}</time>
    </div>
  )}
/>;
```

### Click Handlers (v8)

| Callback Prop | Type Signature |
|---------------|----------------|
| `onMessageClick` | `(props: CourierInboxListItemFactoryProps) => void` |
| `onMessageActionClick` | `(props: CourierInboxListItemActionFactoryProps) => void` |
| `onMessageLongPress` | `(props: CourierInboxListItemFactoryProps) => void` |

```tsx
<CourierInbox
  onMessageClick={({ message, index }) => {
    router.push(message.data?.deepLink);
  }}
  onMessageActionClick={({ message, action, index }) => {
    window.open(action.href);
  }}
/>
```

### Toast Notifications (v8)

Toasts are short-lived notifications connected to the Inbox message feed:

```tsx
import { useEffect } from "react";
import { CourierToast, useCourier } from "@trycourier/courier-react";
// useCourierToken defined earlier in the "JWT Refresh Strategy" section — fetches a
// short-lived JWT from your backend and refreshes before expiry.

function App() {
  const courier = useCourier();
  const courierToken = useCourierToken("user-123");

  useEffect(() => {
    if (!courierToken) return;
    courier.shared.signIn({ userId: "user-123", jwt: courierToken });
  }, [courierToken]);

  return (
    <>
      <CourierToast
        autoDismiss={true}
        autoDismissTimeoutMs={5000}
        onToastItemClick={({ message }) => navigation.navigate(message.data.screen)}
      />
      {/* Your app */}
    </>
  );
}
```

Use `lightTheme` and `darkTheme` props for styling. Use `renderToastItem` or `renderToastItemContent` for fully custom rendering.

#### Toast Best Practices

- **Auto-dismiss for non-critical**: Background task completions, status updates
- **Persistent for actions**: Require user to acknowledge or take action
- **Combine with Inbox**: Toasts alert, inbox provides persistent access
- **Keep content brief**: Title under 50 chars, body under 100 chars

### useCourier Hook (v8)

For custom UIs and programmatic control:

```tsx
import { useEffect } from "react";
import { useCourier, type InboxMessage, defaultFeeds } from "@trycourier/courier-react";

export default function App() {
  const { shared, inbox } = useCourier();

  useEffect(() => {
    shared.signIn({
      userId: "user-123",
      jwt: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    });
    loadInbox();
  }, []);

  async function loadInbox() {
    inbox.registerFeeds(defaultFeeds());
    await inbox.listenForUpdates();
    await inbox.load();
  }

  const unreadCount = inbox.totalUnreadCount ?? 0;
  const messages = inbox.feeds["all_messages"]?.messages ?? [];

  return (
    <div>
      <div>Unread: {unreadCount}</div>
      <ul>
        {messages.map((msg: InboxMessage) => (
          <li key={msg.messageId} style={{
            backgroundColor: msg.read ? "transparent" : "#fee2e2",
          }}>
            {msg.title}
          </li>
        ))}
      </ul>
    </div>
  );
}
```

You **must** call `inbox.listenForUpdates()` after authentication to enable real-time updates.

Key hook methods:

| Method | Description |
|--------|-------------|
| `inbox.registerFeeds(feeds)` | Register feeds and tabs with the datastore |
| `inbox.listenForUpdates()` | Start WebSocket connection for real-time updates |
| `inbox.load()` | Load messages (supports `{ canUseCache, datasetIds }`) |
| `inbox.readMessage(message)` | Mark as read |
| `inbox.unreadMessage(message)` | Mark as unread |
| `inbox.archiveMessage(message)` | Archive |
| `inbox.unarchiveMessage(message)` | Unarchive |
| `inbox.readAllMessages()` | Mark all as read |
| `inbox.fetchNextPageOfMessages({ datasetId })` | Fetch next page |

### Next.js / SSR (v8)

Courier components only render client-side. In Next.js 13+, add `'use client'`:

```tsx
"use client";

import { CourierInbox } from "@trycourier/courier-react";

export default function Page() {
  // Authentication code...
  return <CourierInbox />;
}
```

---

## v8 Web Components / Vanilla JS Integration

Web Components work with **any framework or no framework at all** — Vue, Angular, Svelte, vanilla JS, server-rendered HTML, WordPress, etc. They use the same v8 SDK and real-time infrastructure as the React components.

### Installation

**With a bundler (npm):**

```bash
npm install @trycourier/courier-ui-inbox @trycourier/courier-ui-toast
```

**Without a bundler (CDN script tag):**

```html
<script type="module" src="https://unpkg.com/@trycourier/courier-ui-inbox@latest/dist/courier-ui-inbox/courier-ui-inbox.esm.js"></script>
<script type="module" src="https://unpkg.com/@trycourier/courier-ui-toast@latest/dist/courier-ui-toast/courier-ui-toast.esm.js"></script>
```

The CDN approach requires no build step — add the script tags and use the custom elements immediately.

### Basic Setup

**With npm / bundler:**

```html
<body>
  <courier-inbox id="inbox"></courier-inbox>

  <script type="module">
    import { Courier } from '@trycourier/courier-ui-inbox';

    Courier.shared.signIn({
      userId: 'user-123',
      jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
    });
  </script>
</body>
```

**With CDN (no build step):**

```html
<!DOCTYPE html>
<html>
<head>
  <script type="module" src="https://unpkg.com/@trycourier/courier-ui-inbox@latest/dist/courier-ui-inbox/courier-ui-inbox.esm.js"></script>
</head>
<body>
  <courier-inbox id="inbox"></courier-inbox>

  <script type="module">
    const { Courier } = await import('https://unpkg.com/@trycourier/courier-ui-inbox@latest/dist/courier-ui-inbox/courier-ui-inbox.esm.js');

    const jwt = await fetch('/api/courier-token')
      .then(r => r.json())
      .then(d => d.token);

    Courier.shared.signIn({ userId: 'user-123', jwt });
  </script>
</body>
</html>
```

### Popup Menu

```html
<courier-inbox-popup-menu></courier-inbox-popup-menu>

<script type="module">
  import { Courier } from '@trycourier/courier-ui-inbox';
  Courier.shared.signIn({ userId: 'user-123', jwt: '...' });
</script>
```

### Toast Notifications

```html
<courier-toast auto-dismiss="true" auto-dismiss-timeout-ms="5000"></courier-toast>

<script type="module">
  import { Courier } from '@trycourier/courier-ui-toast';

  const toast = document.querySelector('courier-toast');

  toast.onToastItemClick(({ message }) => {
    window.location.href = message.data?.deepLink;
  });

  Courier.shared.signIn({ userId: 'user-123', jwt: '...' });
</script>
```

### Feeds, Tabs, and Theming

```html
<courier-inbox id="inbox"></courier-inbox>

<script type="module">
  import { Courier } from '@trycourier/courier-ui-inbox';

  const inbox = document.getElementById('inbox');

  inbox.setFeeds([
    {
      feedId: 'notifications',
      title: 'Notifications',
      tabs: [
        { datasetId: 'all', title: 'All', filter: {} },
        { datasetId: 'unread', title: 'Unread', filter: { status: 'unread' } }
      ]
    }
  ]);

  inbox.setLightTheme({
    inbox: {
      list: { item: { unreadIndicatorColor: "#9121C2" } }
    }
  });

  inbox.setDarkTheme({
    inbox: {
      list: { item: { unreadIndicatorColor: "#bb86fc" } }
    }
  });

  Courier.shared.signIn({ userId: 'user-123', jwt: '...' });
</script>
```

### Event Handling

All the same callbacks available in React are available on the Web Component elements:

```html
<courier-inbox id="inbox"></courier-inbox>

<script type="module">
  import { Courier } from '@trycourier/courier-ui-inbox';

  const inbox = document.getElementById('inbox');

  inbox.onMessageClick(({ message, index }) => {
    window.location.href = message.data?.deepLink;
  });

  inbox.onMessageActionClick(({ message, action, index }) => {
    window.open(action.href);
  });

  Courier.shared.signIn({ userId: 'user-123', jwt: '...' });
</script>
```

### Unread Badge (Vanilla JS)

Build a custom notification bell with unread count without any framework:

```html
<button id="notif-bell">
  🔔 <span id="badge" style="display:none;"></span>
</button>
<courier-inbox id="inbox" style="display:none;"></courier-inbox>

<script type="module">
  import { Courier } from '@trycourier/courier-ui-inbox';

  const inbox = document.getElementById('inbox');
  const badge = document.getElementById('badge');
  const bell = document.getElementById('notif-bell');

  // Toggle inbox visibility
  bell.addEventListener('click', () => {
    inbox.style.display = inbox.style.display === 'none' ? 'block' : 'none';
  });

  // Poll for unread count updates
  function updateBadge() {
    const count = inbox.unreadMessageCount ?? 0;
    badge.textContent = count > 99 ? '99+' : count;
    badge.style.display = count > 0 ? 'inline' : 'none';
  }

  // Check periodically (WebSocket handles real-time, this catches edge cases)
  setInterval(updateBadge, 2000);

  Courier.shared.signIn({ userId: 'user-123', jwt: '...' });
</script>
```

### Web Components API Reference

| Element | Description |
|---------|-------------|
| `<courier-inbox>` | Full inbox list with feeds, tabs, and theming |
| `<courier-inbox-popup-menu>` | Bell icon with dropdown popup |
| `<courier-toast>` | Toast notification overlay |

| Method / Property | Available On | Description |
|-------------------|-------------|-------------|
| `setFeeds(feeds)` | `courier-inbox` | Configure feeds and tabs |
| `setLightTheme(theme)` | `courier-inbox`, `courier-toast` | Set light mode theme |
| `setDarkTheme(theme)` | `courier-inbox`, `courier-toast` | Set dark mode theme |
| `onMessageClick(cb)` | `courier-inbox` | Handle message click |
| `onMessageActionClick(cb)` | `courier-inbox` | Handle action button click |
| `onToastItemClick(cb)` | `courier-toast` | Handle toast click |
| `unreadMessageCount` | `courier-inbox` | Current unread count (read-only) |

---

## React Native Integration

### Installation

```bash
npm install @trycourier/courier-react-native
```

### Setup

```tsx
import { useEffect, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { Courier, CourierInboxView } from "@trycourier/courier-react-native";

// Fetches a short-lived Courier JWT from your backend. The React Native
// signIn API uses the parameter name `accessToken` (it's the same JWT as
// the w
...<truncated>
````


### `resources/channels/ms-teams.md`

````markdown
# Microsoft Teams Channel

## Quick Reference

### Rules
- Incoming Webhook (via Workflows): simplest setup, channel-only, no interaction
- Bot Framework: required for DMs and interactive messages
- Office 365 Connector webhooks are deprecated — use Workflows webhooks instead
- Adaptive Card version: use 1.4 or lower for compatibility
- Card JSON must be valid - test in Adaptive Card Designer first
- Required Azure AD permissions: `User.Read.All`, `Chat.Create`, `ChatMessage.Send`
- Webhook URLs expire - must be regenerated periodically
- Use FactSet for key-value data display
- Limit to 2-3 action buttons maximum

### Common Mistakes
- Using Adaptive Card version > 1.4 (won't render)
- Invalid card JSON (returns 400 error)
- Webhook URL expired (message not delivered)
- Missing Azure AD permissions for bot DMs
- Not testing cards in Adaptive Card Designer first
- Too many buttons (cluttered, confusing)
- Not handling webhook expiration

### Templates

**Send via Webhook (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { ms_teams: { webhook_url: process.env.TEAMS_WEBHOOK_URL } },
    content: { title: "Build Complete", body: "Build #456 succeeded." }
  }
});
```

**Send via Webhook (Python):**
```python
import os

client.send.message(
    message={
        "to": {"ms_teams": {"webhook_url": os.environ["TEAMS_WEBHOOK_URL"]}},
        "content": {"title": "Build Complete", "body": "Build #456 succeeded."},
    }
)
```

**With Adaptive Card** (fragment — pass as `message.channels` on `client.send.message`):
```jsonc
{
  "ms_teams": {
    "override": {
      "body": {
        "type": "AdaptiveCard",
        "version": "1.4",
        "body": [
          { "type": "TextBlock", "text": "Deploy Complete", "weight": "bolder", "size": "large" },
          {
            "type": "FactSet",
            "facts": [
              { "title": "Env", "value": "production" },
              { "title": "Version", "value": "v2.3.1" }
            ]
          }
        ]
      }
    }
  }
}
```

---

Best practices for sending Microsoft Teams notifications with Adaptive Cards.

## Integration Methods

### 1. Incoming Webhook (Simplest)

Best for: Channel notifications, alerts, no user interaction needed.

> **Note:** Microsoft has retired Office 365 connectors (the old "Incoming Webhook" integration inside a Teams channel). Creation of new connectors has been disabled since early 2025, and Microsoft has been pushing a final cutoff for existing connector URLs through multiple extensions in 2025–2026 — treat any existing connector URL as **unreliable** and migrate now. The supported replacement is the **Workflows** app in Teams (Power Automate) with the "Post to a channel when a webhook request is received" template, which produces a new webhook URL. For net-new integrations, use Workflows webhooks or the Bot Framework; do not create new connectors. Check Microsoft's current Teams connectors retirement doc for the latest exact dates before architecting anything long-lived.

**Setup:**
1. In Teams, go to channel → Manage channel → Workflows → "Post to a channel when a webhook request is received"
2. Configure and copy the workflow webhook URL
3. POST messages to webhook URL

### 2. Bot Framework (Full Featured)

Best for: DMs, interactive messages, user-specific notifications.

**Setup:**
1. Register app in Azure AD
2. Create Bot Channel Registration
3. Configure in Courier

### 3. Courier Integration

Courier supports both methods through configuration.

## Azure AD App Registration

### Create App Registration

1. Go to [Azure Portal](https://portal.azure.com) → Azure Active Directory
2. App registrations → New registration
3. Name: "Acme Notifications"
4. Supported account types: "Accounts in any organizational directory"
5. Redirect URI: Leave blank for now

### Add API Permissions

Required permissions:
- `User.Read.All` (Application)
- `TeamsAppInstallation.ReadWriteForUser.All` (Application)
- `Chat.Create` (Application)
- `ChatMessage.Send` (Application)

Grant admin consent for your organization.

### Create Client Secret

1. Certificates & secrets → New client secret
2. Copy the secret value immediately

## Adaptive Cards

Adaptive Cards are Microsoft's cross-platform card format.

### Basic Structure

```json
{
  "type": "AdaptiveCard",
  "version": "1.4",
  "body": [
    {
      "type": "TextBlock",
      "text": "Notification Title",
      "weight": "bolder",
      "size": "large"
    },
    {
      "type": "TextBlock",
      "text": "Notification body text"
    }
  ],
  "actions": [
    {
      "type": "Action.OpenUrl",
      "title": "View Details",
      "url": "https://acme.com/details"
    }
  ]
}
```

### Common Elements

**TextBlock** - Display text

```json
{
  "type": "TextBlock",
  "text": "Hello, **World**!",
  "wrap": true,
  "weight": "bolder",
  "size": "medium",
  "color": "accent"
}
```

**FactSet** - Key-value pairs

```json
{
  "type": "FactSet",
  "facts": [
    { "title": "Status:", "value": "Complete" },
    { "title": "Duration:", "value": "2m 34s" },
    { "title": "Environment:", "value": "Production" }
  ]
}
```

**ColumnSet** - Multi-column layout

```json
{
  "type": "ColumnSet",
  "columns": [
    {
      "type": "Column",
      "width": "auto",
      "items": [
        {
          "type": "Image",
          "url": "https://acme.com/avatar.png",
          "size": "small",
          "style": "person"
        }
      ]
    },
    {
      "type": "Column",
      "width": "stretch",
      "items": [
        {
          "type": "TextBlock",
          "text": "Jane Doe",
          "weight": "bolder"
        },
        {
          "type": "TextBlock",
          "text": "Deployed to production",
          "spacing": "none"
        }
      ]
    }
  ]
}
```

### Actions

**OpenUrl** - Link button

```json
{
  "type": "Action.OpenUrl",
  "title": "View Details",
  "url": "https://acme.com/details/123"
}
```

**Submit** - Interactive button (requires bot)

```json
{
  "type": "Action.Submit",
  "title": "Approve",
  "data": {
    "action": "approve",
    "requestId": "req-123"
  }
}
```

## Courier Integration

### Via Incoming Webhook

```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

await client.send.message({
  message: {
    to: {
      ms_teams: {
        webhook_url: "https://outlook.office.com/webhook/..."
      }
    },
    content: {
      title: "Build Complete",
      body: "Build #456 completed successfully."
    }
  }
});
```

### With Adaptive Card Override

```typescript
await client.send.message({
  message: {
    to: {
      ms_teams: {
        webhook_url: process.env.TEAMS_WEBHOOK_URL
      }
    },
    template: "nt_01kmrby3q6x9v2d5c8n1w4ht",
    channels: {
      ms_teams: {
        override: {
          body: {
            type: "AdaptiveCard",
            version: "1.4",
            body: [
              {
                type: "TextBlock",
                text: "🚀 Deployment Complete",
                weight: "bolder",
                size: "large"
              },
              {
                type: "FactSet",
                facts: [
                  { title: "Environment", value: "Production" },
                  { title: "Version", value: "v2.3.1" },
                  { title: "Deployed by", value: "Jane Doe" }
                ]
              }
            ],
            actions: [
              {
                type: "Action.OpenUrl",
                title: "View Deployment",
                url: "https://acme.com/deploys/123"
              }
            ]
          }
        }
      }
    }
  }
});
```

### Via Bot (User DM)

```typescript
await client.send.message({
  message: {
    to: {
      ms_teams: {
        tenant_id: process.env.AZURE_TENANT_ID,
        service_url: "https://smba.trafficmanager.net/...",
        user_id: "29:1abc..." // Teams user ID
      }
    },
    template: "nt_01kmrbyt6x9q3v7d1c5n8w2hj",
    data: {
      taskName: "Review PR #123",
      assignedBy: "Bob"
    }
  }
});
```

## Message Formatting

### Markdown Support

Teams supports a subset of markdown:

| Format | Syntax |
|--------|--------|
| Bold | `**text**` |
| Italic | `_text_` |
| Link | `[text](url)` |
| Code | `` `code` `` |
| List | `- item` or `1. item` |

### Mentions

To mention users in Adaptive Cards:

```json
{
  "type": "TextBlock",
  "text": "Hey <at>Jane</at>, please review this."
}
```

With entities:

```json
{
  "msteams": {
    "entities": [
      {
        "type": "mention",
        "text": "<at>Jane</at>",
        "mentioned": {
          "id": "29:1abc...",
          "name": "Jane Doe"
        }
      }
    ]
  }
}
```

## Common Use Cases

### Incident Alert

```typescript
const incidentCard = {
  type: "AdaptiveCard",
  version: "1.4",
  body: [
    {
      type: "TextBlock",
      text: "🔴 P1 Incident: API Latency Spike",
      weight: "bolder",
      size: "large",
      color: "attention"
    },
    {
      type: "TextBlock",
      text: "High latency detected in api-gateway affecting customer-facing services.",
      wrap: true
    },
    {
      type: "FactSet",
      facts: [
        { title: "Started", value: "2 minutes ago" },
        { title: "Severity", value: "P1 - Critical" },
        { title: "On-call", value: "Jane Doe" }
      ]
    }
  ],
  actions: [
    {
      type: "Action.OpenUrl",
      title: "View Dashboard",
      url: "https://monitoring.acme.com/incidents/123"
    },
    {
      type: "Action.OpenUrl",
      title: "Runbook",
      url: "https://wiki.acme.com/runbooks/latency"
    }
  ]
};
```

### Approval Request

```typescript
const approvalCard = {
  type: "AdaptiveCard",
  version: "1.4",
  body: [
    {
      type: "TextBlock",
      text: "📋 Approval Required",
      weight: "bolder",
      size: "large"
    },
    {
      type: "TextBlock",
      text: "Bob Smith is requesting access to production database.",
      wrap: true
    },
    {
      type: "FactSet",
      facts: [
        { title: "Requester", value: "Bob Smith" },
        { title: "Resource", value: "prod-db-001" },
        { title: "Access Level", value: "Read-only" },
        { title: "Duration", value: "4 hours" },
        { title: "Reason", value: "Investigating customer issue #4567" }
      ]
    }
  ],
  actions: [
    {
      type: "Action.Submit",
      title: "Approve",
      style: "positive",
      data: { action: "approve", requestId: "req-123" }
    },
    {
      type: "Action.Submit",
      title: "Deny",
      style: "destructive", 
      data: { action: "deny", requestId: "req-123" }
    },
    {
      type: "Action.OpenUrl",
      title: "View Details",
      url: "https://acme.com/access-requests/123"
    }
  ]
};
```

## Handling Action.Submit Responses

When a user clicks an `Action.Submit` button in an Adaptive Card, Teams sends the data to your bot's messaging endpoint. This requires a Bot Framework registration.

### How It Works

1. User clicks "Approve" or "Deny" on an Adaptive Card
2. Teams sends a POST to your bot's messaging endpoint with `type: "invoke"` and `name: "adaptiveCard/action"`
3. Your bot processes the action and returns an updated card

### Receiving Submit Payloads

**TypeScript (Express with Bot Framework):**
```typescript
import express from "express";

const app = express();
app.use(express.json());

app.post("/api/messages", async (req, res) => {
  const activity = req.body;

  if (activity.type === "invoke" && activity.name === "adaptiveCard/action") {
    const actionData = activity.value.action.data;
    const userId = activity.from.id;

    let responseCard;

    switch (actionData.action) {
      case "approve":
        await processApproval(actionData.requestId, userId);
        responseCard = buildConfirmationCard(
          `Approved by ${activity.from.name}`,
          actionData.requestId
        );
        break;
      case "deny":
        await processDenial(actionData.requestId, userId);
        responseCard = buildConfirmationCard(
          `Denied by ${activity.from.name}`,
          actionData.requestId
        );
        break;
    }

    // Return updated card to replace the original
    res.json({
      statusCode: 200,
      type: "application/vnd.microsoft.card.adaptive",
      value: responseCard,
    });
    return;
  }

  res.status(200).send();
});

function buildConfirmationCard(status: string, requestId: string) {
  return {
    type: "AdaptiveCard",
    version: "1.4",
    body: [
      {
        type: "TextBlock",
        text: `✅ ${status}`,
        weight: "bolder",
        size: "large",
      },
      {
        type: "TextBlock",
        text: `Request ${requestId} has been processed.`,
        wrap: true,
      },
    ],
  };
}
```

**Python (Flask):**
```python
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/api/messages", methods=["POST"])
def messages():
    activity = request.json

    if activity.get("type") == "invoke" and activity.get("name") == "adaptiveCard/action":
        action_data = activity["value"]["action"]["data"]
        user_name = activity["from"]["name"]

        if action_data["action"] == "approve":
            process_approval(action_data["requestId"], activity["from"]["id"])
            status = f"Approved by {user_name}"
        elif action_data["action"] == "deny":
            process_denial(action_data["requestId"], activity["from"]["id"])
            status = f"Denied by {user_name}"

        return jsonify({
            "statusCode": 200,
            "type": "application/vnd.microsoft.card.adaptive",
            "value": {
                "type": "AdaptiveCard",
                "version": "1.4",
                "body": [
                    {"type": "TextBlock", "text": status, "weight": "bolder", "size": "large"},
                    {"type": "TextBlock", "text": f"Request {action_data['requestId']} processed.", "wrap": True},
                ],
            },
        })

    return "", 200
```

### Bot Registration for Action.Submit

`Action.Submit` requires a Bot Framework bot — webhooks cannot receive interactive responses. Ensure:

1. Bot is registered in Azure Bot Service
2. Messaging endpoint is set to your server URL (e.g., `https://api.acme.com/api/messages`)
3. App ID and password are configured in your server
4. The bot is installed in the user's Teams client or the target team

---

## Proactive Messaging

### Send to New Users

To send proactive messages (not in response to user), you need the conversation reference:

```typescript
// Store when user installs app or messages bot
const conversationReference = {
  serviceUrl: "https://smba.trafficmanager.net/...",
  conversation: { id: "19:abc..." },
  user: { id: "29:xyz..." }
};

// Later, send proactively
await client.send.message({
  message: {
    to: {
      ms_teams: {
        tenant_id: process.env.AZURE_TENANT_ID,
        service_url: conversationReference.serviceUrl,
        conversation_id: conversationReference.conversation.id
      }
    },
    template: "nt_01kmrbtw1v4q8x2c6d9n5j7h"
  }
});
```

## Best Practices

### Card Design

- **Keep it scannable** - Use FactSets for key data
- **Limit actions** - 2-3 buttons maximum
- **Use color sparingly** - Rely on Teams theme
- **Test on mobile** - Cards render differently
- **Use icons/emoji** - Help visual scanning

### Content Guidelines

- **Be concise** - Teams is fast-paced
- **Include context** - Who, what, when
- **Make actionable** - Clear next steps
- **Respect focus time** - Don't over-notify
- **Consider Do Not Disturb** - Teams honors user status

### Channel vs Chat

| Use Channel for | Use Chat (DM) for |
|-----------------|-------------------|
| Team announcements | Personal notifications |
| System alerts | Task assignments |
| Deployment updates | Approval requests |
| Incident notifications | Direct mentions |
| Standup reminders | Performance feedback |

## Adaptive Card Designer

Use Microsoft's [Adaptive Card Designer](https://adaptivecards.io/designer/) to:
- Visually design cards
- Preview across platforms
- Export JSON
- Test interactions

## Troubleshooting

| Issue | Cause | Solution |
|-------|-------|----------|
| Webhook returns 400 | Invalid card JSON | Validate in Card Designer |
| Message not delivered | Webhook expired | Regenerate webhook |
| Bot can't send DM | Missing permissions | Check Azure AD permissions |
| Card not rendering | Unsupported version | Use version 1.4 or lower |

## Related

- [Slack](./slack.md) - Alternative workplace chat channel
- [Multi-Channel](../guides/multi-channel.md) - Teams in routing strategies
- [Reliability](../guides/reliability.md) - Webhook error handling
- [Batching](../guides/batching.md) - Batch notifications to channels
- [Throttling](../guides/throttling.md) - Rate limit management

````


### `resources/channels/push.md`

````markdown
# Push Notifications

## Quick Reference

### Rules
- iOS: Permission prompt shown ONCE per app install - don't waste it
- Android 13+: Requires explicit permission (like iOS)
- Android 12 and below: Opt-out model (enabled by default)
- Title: under 50 characters
- Body: keep under ~100 characters for the lock-screen/banner preview (hard OS truncation is closer to 150 on both iOS and Android — see [Notification Design](#notification-design))
- Always include deep link for navigation
- Respect quiet hours (10pm-8am local time) for non-urgent
- Batch similar notifications ("3 new messages" not 3 separate)
- Push tokens can change - always handle token refresh
- Store tokens with device/platform info for proper routing

### Common Mistakes
- Requesting iOS permission immediately on first launch (low opt-in)
- Not using pre-permission prompt to explain value first
- Sending vague titles like "New notification"
- Not handling token refresh (leads to invalid tokens)
- Storing duplicate tokens for same device
- Over-notifying (users will disable notifications)
- Ignoring quiet hours for non-urgent notifications
- Missing deep links (user taps and nothing happens)
- Not creating Android notification channels (users can't control categories)

### Templates

**Register Push Token (TypeScript):**
```typescript
await client.users.tokens.addSingle("device-token-abc", {
  user_id: "user-123",
  provider_key: "apn", // or "firebase-fcm"
  device: {
    app_id: "com.acme.app",
    platform: "ios" // or "android"
  }
});
```

**Register Push Token (Python):**
```python
client.users.tokens.add_single("device-token-abc",
    user_id="user-123",
    provider_key="apn",  # or "firebase-fcm"
    device={"app_id": "com.acme.app", "platform": "ios"},  # or "android"
)
```

**Basic Push (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Your order shipped",
      body: "Arriving Thursday. Tap to track."
    },
    routing: {
      method: "single",
      channels: ["push"]
    }
  }
});
```

**Basic Push (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "content": {
            "title": "Your order shipped",
            "body": "Arriving Thursday. Tap to track.",
        },
        "routing": {"method": "single", "channels": ["push"]},
    }
)
```

**With Deep Link:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    channels: {
      push: {
        override: {
          data: {
            deepLink: "acme://orders/12345"
          }
        }
      }
    }
  }
});
```

---

Best practices for sending push notifications that users actually want.

## Platform Overview

| Platform | Service | Permission Model | Rich Media |
|----------|---------|-----------------|------------|
| iOS | APNs (Apple Push Notification service) | Opt-in required | Images, video, custom UI |
| Android | FCM (Firebase Cloud Messaging) | Opt-out (pre-13), Opt-in (13+) | Images, actions, styles |
| Web | Web Push (VAPID) | Opt-in required | Limited images, actions |

## iOS (APNs)

### Permission Model

- **One-time prompt:** The standard `.alert` authorization prompt is shown once per app install. If denied, the app cannot re-prompt — the user must re-enable in Settings > Notifications > YourApp.
- **Provisional authorization (iOS 12+):** Request `[.provisional]` *alone* for quiet delivery to Notification Center without a system prompt. The user sees notifications arrive silently and can tap "Keep" (promotes to full authorization) or "Turn Off" from any notification. Provisional and the standard alert prompt are mutually exclusive — mixing `.provisional` with `.alert` asks for the full prompt immediately and defeats the point of provisional.
- **Recovering from denial:** Deep-link to `UIApplication.openSettingsURLString` in your app's in-app settings so users can flip the toggle themselves; do not assume this is rare — after any denial it's the only path.

### Notification Types

| Type | Visibility | Use Case |
|------|------------|----------|
| Alert | Banner, sound, badge | User-facing notifications |
| Silent | Background only | Data sync, content refresh |
| Provisional | Notification Center only | Build trust before full permission |

### APNs Payload

```json
{
  "aps": {
    "alert": {
      "title": "Your order shipped",
      "body": "Arriving Thursday. Tap to track.",
      "subtitle": "Order #12345"
    },
    "badge": 1,
    "sound": "default",
    "mutable-content": 1,
    "category": "ORDER_UPDATE"
  },
  "data": {
    "orderId": "12345",
    "deepLink": "acme://orders/12345"
  }
}
```

## Android (FCM)

### Permission Model

- **Android 12 and below:** Opt-out (notifications enabled by default)
- **Android 13+:** Opt-in required (like iOS)
- **Notification Channels:** Users can control categories independently

### Notification Channels

```kotlin
// Create channels in your Android app
val orderChannel = NotificationChannel(
    "orders",
    "Order Updates",
    NotificationManager.IMPORTANCE_HIGH
).apply {
    description = "Shipping and delivery notifications"
}

val socialChannel = NotificationChannel(
    "social",
    "Social Activity",
    NotificationManager.IMPORTANCE_DEFAULT
).apply {
    description = "Comments, likes, and mentions"
}

notificationManager.createNotificationChannels(listOf(orderChannel, socialChannel))
```

### FCM Payload

```json
{
  "notification": {
    "title": "Your order shipped",
    "body": "Arriving Thursday. Tap to track.",
    "image": "https://acme.com/images/package.png",
    "click_action": "OPEN_ORDER"
  },
  "data": {
    "orderId": "12345",
    "type": "shipping"
  },
  "android": {
    "notification": {
      "channel_id": "orders",
      "priority": "high"
    }
  }
}
```

## Web Push

Web Push uses the Firebase Cloud Messaging (FCM) provider in Courier. Register the token the same way as Android:

```typescript
// After obtaining FCM token in the browser via Firebase SDK
await client.users.tokens.addSingle(fcmToken, {
  user_id: userId,
  provider_key: "firebase-fcm",
  device: {
    app_id: "com.acme.web",
    platform: "web",
  },
});
```

Web Push requires HTTPS and a service worker. Configure FCM credentials in the Courier dashboard under **Channels > Push > Firebase Cloud Messaging**.

## Expo Push Notifications

For React Native apps using [Expo](https://docs.expo.dev/push-notifications/overview/), register the Expo push token:

```typescript
await client.users.tokens.addSingle(expoPushToken, {
  user_id: userId,
  provider_key: "expo",
  device: {
    app_id: "com.acme.app",
    platform: "expo",
  },
});
```

Configure your Expo access token in the Courier dashboard under **Channels > Push > Expo**. Courier handles the translation from Expo tokens to APNs/FCM delivery.

## Permission Priming

### The Problem

iOS gives you **one chance**. Don't waste it.

| Approach | Opt-in Rate |
|----------|-------------|
| Immediate prompt on launch | 30-40% |
| Pre-permission screen | 50-60% |
| Contextual prompt after value | 60-70%+ |

### Pre-Permission Strategy

```
┌─────────────────────────────────────┐
│                                     │
│      🔔 Stay in the Loop            │
│                                     │
│   Get notified when:                │
│   ✓ Your order ships                │
│   ✓ Someone replies to you          │
│   ✓ Important account updates       │
│                                     │
│   [ Enable Notifications ]          │
│                                     │
│        Maybe Later                  │
│                                     │
└─────────────────────────────────────┘
```

**If user taps "Enable"** → Show system prompt
**If user taps "Maybe Later"** → Ask again after they experience value

### Best Timing

| Good | Bad |
|------|-----|
| After first purchase | Immediately on first launch |
| After account setup | Before they understand your app |
| When they enable a feature that needs it | Random prompt mid-flow |
| After they've used the app for a week | During checkout |

### Provisional Notifications (iOS)

Request provisional authorization *alone* for quiet delivery — do NOT combine with `.alert`, which would request the standard prompt immediately and nullify the provisional behavior:

```swift
UNUserNotificationCenter.current().requestAuthorization(
    options: [.provisional]
) { granted, error in
    // Notifications delivered quietly to Notification Center
    // User can "Keep" or "Turn Off" from the notification
    // "Keep" promotes to full authorization (alert, sound, badge)
}
```

## Courier Integration

### Register Device Token

```typescript
// When your app receives a push token
async function registerPushToken(userId: string, token: string, platform: 'ios' | 'android' | 'web') {
  const providerMap = {
    ios: 'apn',
    android: 'firebase-fcm',
    web: 'firebase-fcm',  // Web Push via FCM
  } as const;

  await client.users.tokens.addSingle(token, {
    user_id: userId,
    provider_key: providerMap[platform],
    device: {
      app_id: 'com.acme.app',
      platform: platform
    }
  });
}
```

### Send Push Notification

```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

// Basic push
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Your order shipped",
      body: "Arriving Thursday. Tap to track."
    },
    routing: {
      method: "single",
      channels: ["push"]
    }
  }
});
```

### With Deep Link

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: {
      orderId: "12345",
      deliveryDate: "Thursday"
    },
    channels: {
      push: {
        override: {
          data: {
            deepLink: "acme://orders/12345"
          }
        }
      }
    }
  }
});
```

### With Image (Rich Push)

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "New comment on your photo",
      body: "Jane: Great shot! 📸"
    },
    channels: {
      push: {
        override: {
          data: {
            image: "https://acme.com/photos/12345.jpg",
            deepLink: "acme://photos/12345"
          }
        }
      }
    }
  }
});
```

### With Action Buttons

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Delivery arriving soon",
      body: "Your package will arrive in 30 minutes"
    },
    channels: {
      push: {
        override: {
          data: {
            category: "DELIVERY", // Maps to registered category in app
            actions: [
              { id: "track", title: "Track" },
              { id: "delay", title: "Delay 1 Hour" }
            ]
          }
        }
      }
    }
  }
});
```

### Silent Push (Data Only)

```typescript
// Update app data without showing notification
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    channels: {
      push: {
        override: {
          data: {
            type: "sync",
            resource: "messages",
            silent: true
          },
          notification: null // No visible notification
        }
      }
    }
  }
});
```

## Token Management

### Lifecycle

```
App Launch → Check Token → Compare with Stored → Update if Changed
              ↓
Token Changes (OS update, reinstall, etc.)
              ↓
Update Courier Profile
```

### Handle Token Refresh

```swift
// iOS - AppDelegate
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
    sendTokenToBackend(token, platform: "ios")
}
```

```kotlin
// Android - FirebaseMessagingService
override fun onNewToken(token: String) {
    sendTokenToBackend(token, platform = "android")
}
```

### Multi-Device Support

Users may have multiple devices. Store all tokens:

```typescript
// User profile with multiple tokens
{
  user_id: "user-123",
  tokens: [
    { token: "abc...", provider: "apn", device: "iPhone" },
    { token: "xyz...", provider: "firebase-fcm", device: "Pixel" },
    { token: "123...", provider: "firebase-fcm", device: "Samsung" }
  ]
}

// Courier sends to all registered tokens
await client.send.message({
  message: {
    to: { user_id: "user-123" }, // All devices receive
    template: "nt_01kmrbvk2q5x9v1d4c7n8w6hj"
  }
});
```

### Remove Invalid Tokens

```typescript
// Handle feedback from APNs/FCM about invalid tokens
app.post('/webhooks/courier', async (req, res) => {
  const { type, data } = req.body;

  if (type === 'message:updated' && data.status === 'UNDELIVERABLE') {
    const providerError = data.providers?.[0]?.error;
    if (providerError?.code === 'token_expired') {
      await client.users.tokens.delete(providerError.token, { user_id: data.recipient });
    }
  }

  res.sendStatus(200);
});
```

## Notification Design

### Content Guidelines

| Element | iOS hard limit | Android hard limit | Best practice |
|---------|----------------|--------------------|---------------|
| Title | ~50 chars | ~65 chars | Keep under 50 — front-load the subject |
| Body | ~150 chars | ~150 chars | Keep under ~100 so the lock-screen/banner preview isn't truncated |
| Image | 1024×1024 max | 1:1 or 2:1 ratio | Add value, not decoration |

> The Quick Reference "under 100 characters" target matches the **preview-safe** length on both platforms — the `~150` number here is the OS truncation ceiling, not a recommendation. If your body exceeds ~100 chars, assume the tail will be cut off on the lock screen.

### Examples

**Good:**
```
Title: Jane commented on your post
Body: "Great point about the API design!"
```

```
Title: Your order shipped
Body: Arriving Thursday. Tap to track.
```

```
Title: Security alert
Body: New login from Chrome on Windows
```

**Bad:**
```
Title: New notification
Body: Something happened in the app
```

```
Title: Hey there!
Body: We have some exciting news for you that we think you might like...
```

## Frequency & Timing

### Don't Over-Notify

| Type | Max Frequency |
|------|---------------|
| Activity (likes, comments) | Batch after 5min, max 10/hour |
| Transactional | As needed (but consider batching) |
| Marketing | 2-3 per week maximum |

### Quiet Hours

Don't send non-urgent push between 10 PM - 8 AM local time. See [Patterns > Quiet Hours](../guides/patterns.md#quiet-hours) for the implementation.

### Batching

```typescript
// Instead of 5 separate notifications:
// "Jane liked your post"
// "Bob liked your post"
// "Mike liked your post"
// "Sarah liked your post"
// "Tom liked your post"

// Send one batched notification:
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Your post is popular!",
      body: "Jane, Bob, and 3 others liked your post"
    }
  }
});
```

## Troubleshooting

| Issue | Cause | Solution |
|-------|-------|----------|
| Not delivered | Invalid/expired token | Re-register token on app launch |
| Delayed | Low priority | Use high priority for time-sensitive |
| Not shown (iOS) | User disabled | Check permission status, re-request |
| Not shown (Android) | Channel disabled | Guide user to notification settings |
| Duplicates | Multiple token registrations | Dedupe tokens by device ID |
| No sound | Silent mode or channel settings | Respect user preference |

### Debug Checklist

1. Token registered correctly in Courier?
2. User has push permission enabled?
3. App in foreground? (may need handling)
4. Device connected to internet?
5. APNs/FCM credentials configured correctly?
6. Payload format valid?

## Related

- [Inbox](./inbox.md) - In-app notification center as push complement
- [Multi-Channel](../guides/multi-channel.md) - Push in escalation chains
- [Batching](../guides/batching.md) - Notification batching strategies
- [Throttling](../guides/throttling.md) - Push frequency limits
- [Engagement](../growth/engagement.md) - Activity notification patterns

````


### `resources/channels/slack.md`

````markdown
# Slack Channel

## Quick Reference

### Rules
- Bot token starts with `xoxb-` (not user token `xoxp-`)
- Required scopes: `chat:write`, `chat:write.public`, `users:read.email`
- Use Channel ID (starts with `C`), not channel name
- Rate limit: 1 message per second per channel
- Burst limit: 30-50 messages
- Use Block Kit for rich formatting (not attachments)
- Text formatting: `*bold*`, `_italic_`, `~strike~`, `` `code` ``
- User mention: `<@U0123ABC>`
- Channel mention: `<#C0123ABC>`

### Common Mistakes
- Using channel name instead of channel ID
- Using user token instead of bot token
- Missing `chat:write.public` scope (can't post to channels bot isn't in)
- Exceeding rate limits without queuing
- Using deprecated attachments instead of Block Kit
- Not handling "channel_not_found" or "not_in_channel" errors
- Sending walls of text instead of scannable Block Kit layouts

### Templates

**Send DM by Email (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        email: "jane@acme.com"
      }
    },
    content: { title: "Build Complete", body: "Your build finished." }
  }
});
```

**Send DM by Email (Python):**
```python
import os

client.send.message(
    message={
        "to": {
            "slack": {
                "access_token": os.environ["SLACK_BOT_TOKEN"],
                "email": "jane@acme.com",
            }
        },
        "content": {"title": "Build Complete", "body": "Your build finished."},
    }
)
```

**Send to Channel (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        channel: "C0123ABCDEF"
      }
    },
    template: "nt_01kmrby3q6x9v2d5c8n1w4ht"
  }
});
```

**Block Kit Override:**
```typescript
channels: {
  slack: {
    override: {
      body: {
        blocks: [
          { type: "header", text: { type: "plain_text", text: "Title" } },
          { type: "section", text: { type: "mrkdwn", text: "*Bold* text" } }
        ]
      }
    }
  }
}
```

---

Best practices for sending Slack notifications with Block Kit formatting.

## Slack App Setup

### Create a Slack App

1. Go to [api.slack.com/apps](https://api.slack.com/apps)
2. Click "Create New App" → "From scratch"
3. Name your app and select workspace
4. Navigate to "OAuth & Permissions"

### Required Scopes

**Bot Token Scopes** (recommended):

| Scope | Purpose |
|-------|---------|
| `chat:write` | Send messages |
| `chat:write.public` | Send to channels without joining |
| `users:read` | Look up users |
| `users:read.email` | Look up users by email |

### Install to Workspace

1. Go to "Install App" in your Slack app settings
2. Click "Install to Workspace"
3. Copy the "Bot User OAuth Token" (`xoxb-...`)
4. Add to Courier as a Slack integration

## Message Types

### Direct Messages (DMs)

Send to individual users:

```typescript
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: "xoxb-...",
        email: "jane@acme.com" // or user_id
      }
    },
    template: "nt_01kmrbyb7x1q5v8d2c6n4w9hj"
  }
});
```

### Channel Messages

Send to a channel:

```typescript
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: "xoxb-...",
        channel: "C0123ABCDEF" // Channel ID
      }
    },
    template: "nt_01kmrbyk2q5x9v1d4c7n8w6hj"
  }
});
```

## Block Kit Formatting

Block Kit is Slack's UI framework for rich messages.

### Basic Structure

```json
{
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Title*\nDescription text"
      }
    }
  ]
}
```

### Common Block Types

**Section Block** - Text with optional accessory

```json
{
  "type": "section",
  "text": {
    "type": "mrkdwn",
    "text": "Your deployment to *production* completed successfully."
  },
  "accessory": {
    "type": "button",
    "text": { "type": "plain_text", "text": "View Logs" },
    "url": "https://acme.com/logs/123"
  }
}
```

**Header Block** - Large text header

```json
{
  "type": "header",
  "text": {
    "type": "plain_text",
    "text": "🚀 Deployment Complete"
  }
}
```

**Context Block** - Secondary information

```json
{
  "type": "context",
  "elements": [
    {
      "type": "mrkdwn",
      "text": "Deployed by <@U0123ABC> • 2 minutes ago"
    }
  ]
}
```

**Actions Block** - Interactive buttons

```json
{
  "type": "actions",
  "elements": [
    {
      "type": "button",
      "text": { "type": "plain_text", "text": "Approve" },
      "style": "primary",
      "action_id": "approve_request"
    },
    {
      "type": "button",
      "text": { "type": "plain_text", "text": "Deny" },
      "style": "danger",
      "action_id": "deny_request"
    }
  ]
}
```

## Handling Interactive Actions

When you include `action_id` buttons in Block Kit, Slack sends a POST to your app when users click them. This requires setting up an Interactivity URL.

### Setup

1. In your Slack app settings, go to **Interactivity & Shortcuts**
2. Toggle **Interactivity** on
3. Set **Request URL** to your endpoint (e.g., `https://api.acme.com/slack/actions`)

### Receiving Action Payloads

Slack sends a JSON payload with `type: "block_actions"` when a user clicks a button. The payload includes which action was clicked and who clicked it.

**TypeScript (Express):**
```typescript
import express from "express";

const app = express();
app.use(
  "/slack/actions",
  express.urlencoded({
    extended: true,
    verify: (req, _res, buf) => {
      // Slack signature verification requires the exact raw body bytes.
      (req as { rawBody?: string }).rawBody = buf.toString("utf8");
    },
  })
);

app.post("/slack/actions", async (req, res) => {
  if (!verifySlackRequest(req as { headers: Record<string, string | string[] | undefined>; rawBody?: string })) {
    return res.status(401).send("Invalid Slack signature");
  }

  // Respond within 3 seconds to avoid timeout
  res.status(200).send();

  const payload = JSON.parse(req.body.payload);

  if (payload.type !== "block_actions") return;

  for (const action of payload.actions) {
    const userId = payload.user.id;
    const actionId = action.action_id;
    const responseUrl = payload.response_url;

    switch (actionId) {
      case "approve_request":
        await handleApproval(action.value, userId, responseUrl);
        break;
      case "deny_request":
        await handleDenial(action.value, userId, responseUrl);
        break;
      case "rollback_deploy":
        await handleRollback(action.value, userId, responseUrl);
        break;
    }
  }
});
```

**Python (Flask):**
```python
import json
import hmac
import time
import hashlib
import os
from flask import Flask, request

app = Flask(__name__)

def verify_slack_request() -> bool:
    timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
    signature = request.headers.get("X-Slack-Signature", "")
    raw_body = request.get_data(as_text=True)

    if not timestamp or not signature or not raw_body:
        return False

    # Reject replayed requests older than 5 minutes.
    try:
        request_ts = int(timestamp)
    except ValueError:
        return False

    if abs(time.time() - request_ts) > 300:
        return False

    sig_base = f"v0:{timestamp}:{raw_body}"
    secret = os.environ["SLACK_SIGNING_SECRET"].encode("utf-8")
    computed = "v0=" + hmac.new(
        secret,
        sig_base.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(computed, signature)

@app.route("/slack/actions", methods=["POST"])
def slack_actions():
    if not verify_slack_request():
        return "Invalid Slack signature", 401

    payload = json.loads(request.form["payload"])

    if payload["type"] != "block_actions":
        return "", 200

    for action in payload["actions"]:
        user_id = payload["user"]["id"]
        action_id = action["action_id"]

        if action_id == "approve_request":
            handle_approval(action.get("value"), user_id)
        elif action_id == "deny_request":
            handle_denial(action.get("value"), user_id)

    return "", 200
```

### Updating the Message After Action

After processing an action, update the original message to reflect the new state:

```typescript
import { WebClient } from "@slack/web-api";

const slack = new WebClient(process.env.SLACK_BOT_TOKEN);

async function handleApproval(
  requestId: string,
  approverSlackId: string,
  responseUrl: string
) {
  await processApproval(requestId);

  // Update the original message via response_url from the payload
  await fetch(responseUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      replace_original: true,
      blocks: [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: `✅ Approved by <@${approverSlackId}>`,
          },
        },
      ],
    }),
  });
}
```

### Verifying Slack Requests

Validate that requests actually come from Slack using the signing secret:

```typescript
import crypto from "crypto";

function verifySlackRequest(req: {
  headers: Record<string, string | string[] | undefined>;
  rawBody?: string;
}): boolean {
  const tsHeader = req.headers["x-slack-request-timestamp"];
  const sigHeader = req.headers["x-slack-signature"];
  const timestamp = Array.isArray(tsHeader) ? tsHeader[0] : tsHeader;
  const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
  const rawBody = req.rawBody ?? "";

  if (!timestamp || !signature || !rawBody) return false;

  // Reject requests older than 5 minutes
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const sigBaseString = `v0:${timestamp}:${rawBody}`;
  const hmac = crypto
    .createHmac("sha256", process.env.SLACK_SIGNING_SECRET!)
    .update(sigBaseString)
    .digest("hex");

  const expectedSignature = `v0=${hmac}`;
  if (expectedSignature.length !== signature.length) return false;

  return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature));
}
```

---

## Courier Integration

### Basic Slack Message

```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        email: "jane@acme.com"
      }
    },
    content: {
      title: "Build Complete",
      body: "Your build #456 completed successfully."
    }
  }
});
```

### With Block Kit Override

```typescript
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        channel: "C0123ABCDEF"
      }
    },
    template: "nt_01kmrby3q6x9v2d5c8n1w4ht",
    data: {
      environment: "production",
      version: "v2.3.1",
      deployedBy: "jane"
    },
    channels: {
      slack: {
        override: {
          body: {
            blocks: [
              {
                type: "header",
                text: {
                  type: "plain_text",
                  text: "🚀 Deployment Complete"
                }
              },
              {
                type: "section",
                fields: [
                  {
                    type: "mrkdwn",
                    text: "*Environment:*\nproduction"
                  },
                  {
                    type: "mrkdwn",
                    text: "*Version:*\nv2.3.1"
                  }
                ]
              }
            ]
          }
        }
      }
    }
  }
});
```

### Using User Profile Slack Token

```typescript
// If user has connected Slack via OAuth
await client.send.message({
  message: {
    to: { 
      user_id: "user-123" // Must have slack token in profile
    },
    template: "nt_01kmrbyt6x9q3v7d1c5n8w2hj",
    routing: {
      method: "single",
      channels: ["slack"]
    }
  }
});
```

## Formatting Reference

| Format | Syntax | Result |
|--------|--------|--------|
| Bold | `*text*` | **bold** |
| Italic | `_text_` | *italic* |
| Strike | `~text~` | ~~strikethrough~~ |
| Code | `` `code` `` | inline code |
| Link | `<url\|text>` | clickable link |
| User mention | `<@U0123ABC>` | @username |
| Channel | `<#C0123ABC>` | #channel |

### Emoji

Use standard emoji codes: `:rocket:`, `:white_check_mark:`, `:warning:`

## Threading

### Reply to Thread

Keep conversations organized by replying in threads:

```typescript
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        channel: "C0123ABCDEF"
      }
    },
    content: {
      body: "Build failed. See details above."
    },
    channels: {
      slack: {
        override: {
          body: {
            thread_ts: "1234567890.123456" // Parent message timestamp
          }
        }
      }
    }
  }
});
```

### Update Existing Message

```typescript
// Update a previously sent message
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        channel: "C0123ABCDEF"
      }
    },
    channels: {
      slack: {
        override: {
          body: {
            ts: "1234567890.123456", // Message to update
            blocks: [
              {
                type: "section",
                text: {
                  type: "mrkdwn",
                  text: "✅ Deployment *complete* to production"
                }
              }
            ]
          }
        }
      }
    }
  }
});
```

## Scheduled Messages

```typescript
// Schedule a message for later
const sendAt = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now

await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        channel: "C0123ABCDEF"
      }
    },
    content: {
      body: "Daily standup reminder!"
    },
    channels: {
      slack: {
        override: {
          body: {
            post_at: sendAt
          }
        }
      }
    }
  }
});
```

## Common Use Cases

### CI/CD Notifications

```typescript
// Build status notification
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        channel: process.env.SLACK_DEPLOYS_CHANNEL
      }
    },
    channels: {
      slack: {
        override: {
          body: {
            blocks: [
              {
                type: "header",
                text: { type: "plain_text", text: "🚀 Deployment Complete" }
              },
              {
                type: "section",
                fields: [
                  { type: "mrkdwn", text: "*Service:*\napi-gateway" },
                  { type: "mrkdwn", text: "*Environment:*\nproduction" },
                  { type: "mrkdwn", text: "*Version:*\nv2.3.1" },
                  { type: "mrkdwn", text: "*Deployed by:*\n<@U0123ABC>" }
                ]
              },
              {
                type: "actions",
                elements: [
                  {
                    type: "button",
                    text: { type: "plain_text", text: "View Logs" },
                    url: "https://logs.acme.com/deploy/123"
                  },
                  {
                    type: "button",
                    text: { type: "plain_text", text: "Rollback" },
                    style: "danger",
                    action_id: "rollback_deploy"
                  }
                ]
              }
            ]
          }
        }
      }
    }
  }
});
```

### Incident Alert

```typescript
await client.send.message({
  message: {
    to: {
      slack: {
        access_token: process.env.SLACK_BOT_TOKEN,
        channel: process.env.SLACK_INCIDENTS_CHANNEL
      }
    },
    channels: {
      slack: {
        override: {
          body: {
            blocks: [
              {
                type: "header",
                text: { type: "plain_text", text: "🔴 Incident: API Latency Spike" }
              },
              {
                type: "section",
                text: {
                  type: "mrkdwn",
                  text: "*Severity:* P1\n*Started:* 2 minutes ago\n*Affected:* api-gateway, payments-service"
                }
              },
              {
                type: "context",
                elements: [
                  { type: "mrkdwn", text: "On-call: <@U0123ABC> | <https://status.acme.com|Status Page>" }
                ]
              }
            ]
          }
        }
      }
    }
  }
});
```

## Best Practices

### Message Design

- **Keep it scannable** - Use headers, bullet points
- **Be actionable** - Include clear next steps or buttons
- **Add context** - Who, what, when, where
- **Use Section fields** - For structured key-value data
- **Use attachments sparingly** - Prefer Block Kit

### Channel vs DM

| Use DM for | Use Channel for |
|------------|-----------------|
| Personal notifications | Team-wide alerts |
| Task assignments | System status updates |
| Approval requests | Deployment notifications |
| Direct mentions | Incident alerts |
| Performance reviews | Standup reminders |

### Rate Limits

| Limit Type | Value |
|------------|-------|
| Messages per second per channel | 1 |
| Burst limit | 30-50 messages |
| Web API rate limits | Tier-based (check Slack docs) |

For high volume, use Courier's built-in queuing and batching.

## Troubleshooting

| Issue | Cause | Solution |
|-------|-------|----------|
| "channel_not_found" | Invalid channel ID | Use channel ID (C...), not name |
| "not_in_channel" | Bot not in channel | Add `chat:write.public` scope or invite bot |
| "user_not_found" | Invalid email | Verify email matches Slack account |
| "invalid_auth" | Bad token | Regenerate bot token |

## Related

- [MS Teams](./ms-teams.md) - Alternative workplace chat channel
- [Multi-Channel](../guides/multi-channel.md) - Slack in routing strategies
- [Batching](../guides/batching.md) - Batch notifications to channels
- [Throttling](../guides/throttling.md) - Respect Slack rate limits
- [Engagement](../growth/engagement.md) - Alert notification patterns

````


### `resources/channels/sms.md`

````markdown
# SMS Channel

## Quick Reference

### Rules
- Message length: 160 chars (GSM-7) or 70 chars (Unicode/emoji)
- MUST include sender ID at start: "Acme: Your message..."
- Phone numbers MUST be E.164 format: +15551234567
- US A2P messaging REQUIRES 10DLC registration (allow 2-4 weeks)
- Marketing SMS requires explicit opt-in
- MUST support STOP keyword for opt-out
- Honor opt-outs immediately - no exceptions
- Transactional SMS (no explicit opt-in needed): OTP, order updates, appointments
- Marketing SMS (explicit opt-in required): promotions, sales, re-engagement
- SMS requires a configured provider — add one in [Integrations](https://app.courier.com/integrations) before sending (email works in test mode without this, SMS does not)

### Common Mistakes
- Using emojis without accounting for 70-char Unicode limit
- Forgetting sender identification at start of message
- Not validating phone numbers before storing
- Storing phone numbers without E.164 format
- Sending marketing SMS without consent record
- Missing 10DLC registration (messages filtered/blocked, <10% delivery)
- Including URL shorteners like bit.ly (triggers carrier spam filters)
- Sending during quiet hours (10pm-8am local time)
- Not including opt-out instructions in marketing messages

### Templates

**OTP Code (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    content: {
      body: "Your Acme code is 847293. Expires in 10 min. Never share this code."
    }
  }
});
```

**OTP Code (Python):**
```python
client.send.message(
    message={
        "to": {"phone_number": "+15551234567"},
        "content": {
            "body": "Your Acme code is 847293. Expires in 10 min. Never share this code."
        },
    }
)
```

**Order Shipped (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    content: {
      body: "Acme: Order #12345 shipped! Arrives Thu. Track: acme.co/t/12345"
    }
  }
});
```

**With Fallback to Email:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbr0s4y7qv2n8c5d1xj9k",
    routing: {
      method: "single",
      channels: ["sms", "email"]
    }
  }
});
```

---

Best practices for sending SMS that delivers and engages users.

## Setup Requirements

### 10DLC Registration (US)

**Required** for A2P (Application-to-Person) SMS in the US since 2023.

| Step | Timeline |
|------|----------|
| 1. Register brand with TCR | 1-2 days |
| 2. Register campaign use case | 1-3 days |
| 3. Carrier vetting | 1-3 weeks |
| 4. Connect to Courier | Same day |

**Without 10DLC:** Messages filtered or blocked by carriers (delivery rates can drop to <10%)

**Use Cases (TCR categories):**
- 2FA/OTP Authentication
- Account Notifications
- Customer Care
- Delivery Notifications
- Marketing (requires additional vetting)

### Opt-In and Opt-Out

- Marketing SMS should be opt-in only — record when and how the user opted in
- Every marketing message should include `Reply STOP to unsubscribe`
- STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT must all suppress future sends immediately
- HELP / INFO should return sender identification and support contact
- Transactional SMS (OTP, order updates, appointment reminders) does not require opt-in if the user provided the number for that purpose

## Message Design

### Character Limits

| Encoding | Single-segment limit | Per-segment when concatenated | Characters |
|----------|----------------------|-------------------------------|------------|
| GSM-7 | 160 chars | **153 chars** per segment | A-Z, 0-9, basic punctuation, common symbols |
| UCS-2 (Unicode) | 70 chars | **67 chars** per segment | Emojis, non-Latin characters, special symbols |

**Concatenation:** Messages over the single-segment limit are split and reassembled by the handset using a 6- or 7-byte User Data Header (UDH). The UDH consumes 7 GSM-7 characters or 3 UCS-2 code units per segment, which is why concatenated payload drops from 160→153 (GSM-7) and 70→67 (UCS-2). Carriers bill per segment, so a 161-character GSM-7 message costs 2 segments (153 + 8), and a 71-character Unicode message costs 2 segments (67 + 4).

> **Watch out for accidental Unicode:** a single emoji, curly quote, or non-breaking space forces the entire message into UCS-2, dropping your budget from 160/153 to 70/67. Normalize strings server-side if you care about cost.

```
Message: "Your verification code is 847293. Valid for 10 min." 
Length: 51 characters (GSM-7)
Segments: 1
```

### Message Structure

```
[Brand]: [Message]. [CTA]. [Opt-out]

Example:
Acme: Your order #12345 shipped! Track: acme.co/t/12345. Reply STOP to unsubscribe
```

| Component | Purpose |
|-----------|---------|
| Brand identifier | Required for recognition |
| Core message | What you're telling them |
| CTA/Link | What they should do (use branded short domain, not bit.ly) |
| Opt-out | Required for marketing, good practice for all |

### Examples

**Good:**
```
Acme: Your verification code is 847293. Expires in 10 min. Never share this code.
```

```
Acme: Order #12345 shipped! Arrives Thu Jan 30. Track: acme.co/t/12345
```

```
Acme: Your appt with Dr. Smith is tomorrow at 2pm. Reply Y to confirm, N to cancel.
```

**Bad:**
```
Hey! Your awesome order is on the way!!! 🎉🎉🎉 Can't wait for you to get it!!!
```
(Too long, wastes characters on enthusiasm, uses Unicode reducing limit)

## Courier Integration

### Basic SMS Send

```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

// Direct send with phone number
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    content: {
      body: "Your Acme verification code is: 847293. Expires in 10 min."
    }
  }
});
```

### Using Templates

```typescript
// Send via user profile (phone must be in profile)
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: {
      orderNumber: "12345",
      trackingUrl: "https://acme.co/t/12345"
    },
    routing: {
      method: "single",
      channels: ["sms"]
    }
  }
});
```

### OTP Pattern

```typescript
// generateSecureCode and storeOTP are your own helpers — tie them into
// your database / Redis / whatever you use to track OTP attempts.
declare function generateSecureCode(digits: number): string;
declare function storeOTP(phone: string, code: string, expiresAt: number): Promise<void>;

async function sendOTP(phoneNumber: string, requestId: string) {
  const code = generateSecureCode(6);
  const expiresAt = Date.now() + 10 * 60 * 1000;

  await storeOTP(phoneNumber, code, expiresAt);

  await client.send.message({
    message: {
      to: { phone_number: phoneNumber },
      content: {
        body: `Your Acme code is ${code}. Expires in 10 min. Never share this code.`,
      },
    },
  }, {
    // requestId is your application-side identifier for this OTP attempt —
    // typically a row ID from your OTP table so retries of the same attempt
    // collapse, while "resend code" produces a new requestId and a new send.
    headers: { "Idempotency-Key": `otp-${phoneNumber}-${requestId}` },
  });
}

// Example caller:
await sendOTP("+15551234567", crypto.randomUUID());
```

### SMS with Fallback to Email

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbxu8q2x6v1d4c7n5j9ht",
    data: { code: "847293" },
    routing: {
      method: "single",
      channels: ["sms", "email"] // Try SMS first, email if fails
    }
  }
});
```

## Phone Number Management

### Validation

```typescript
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js';

function validateAndFormat(phone: string, defaultCountry = 'US'): string | null {
  try {
    if (!isValidPhoneNumber(phone, defaultCountry)) {
      return null;
    }
    const parsed = parsePhoneNumber(phone, defaultCountry);
    return parsed.format('E.164'); // +15551234567
  } catch {
    return null;
  }
}

// Always store in E.164 format
const formatted = validateAndFormat('555-123-4567', 'US');
// Result: "+15551234567"
```

### Store in User Profile

```typescript
await client.profiles.create("user-123", {
  profile: {
    phone_number: "+15551234567" // E.164 format
  }
});
```

## Opt-In Collection

### Consent Language Example

```html
<label>
  <input type="checkbox" name="sms_consent" required>
  I agree to receive order updates and notifications via SMS to the phone number 
  provided. Message frequency varies. Message and data rates may apply. 
  Reply STOP to unsubscribe, HELP for help. 
  <a href="/privacy">Privacy Policy</a> | <a href="/sms-terms">SMS Terms</a>
</label>
```

### Store Consent Record

```typescript
interface SMSConsent {
  phoneNumber: string;
  consentedAt: Date;
  method: 'web_form' | 'sms_keyword' | 'verbal';
  ipAddress?: string;
  consentText: string;
  categories: string[]; // ['transactional', 'marketing']
}

await storeConsent({
  phoneNumber: "+15551234567",
  consentedAt: new Date(),
  method: 'web_form',
  ipAddress: req.ip,
  consentText: "I agree to receive order updates via SMS...",
  categories: ['transactional']
});
```

## Opt-Out Handling

### Standard Keywords

| Keyword | Action | Response |
|---------|--------|----------|
| STOP, UNSUBSCRIBE, CANCEL, END, QUIT | Unsubscribe | "You've been unsubscribed. Reply START to resubscribe." |
| HELP, INFO | Help message | "Acme SMS. Msg freq varies. Msg&data rates apply. Reply STOP to cancel." |
| START, YES, UNSTOP | Resubscribe | "You've been resubscribed. Reply STOP to unsubscribe." |

Most SMS providers handle these automatically. Courier processes opt-outs and updates user preferences.

### Handle in Your App

```typescript
// Webhook from Courier for opt-out events
app.post('/webhooks/sms-optout', async (req, res) => {
  const { phone_number, action } = req.body;
  
  if (action === 'unsubscribe') {
    await updateUserPreference(phone_number, 'sms', false);
  } else if (action === 'resubscribe') {
    await updateUserPreference(phone_number, 'sms', true);
  }
  
  res.sendStatus(200);
});
```

## Number Types

| Type | Throughput | Best For | Notes |
|------|------------|----------|-------|
| **10DLC** | 15-75 msg/sec | Standard A2P messaging | Required for US business SMS |
| **Toll-Free** | 3-10 msg/sec | Customer support | Must be verified |
| **Short Code** | 100+ msg/sec | High-volume campaigns | $500-1500/month, 8-12 week setup |
| **Alphanumeric Sender** | Varies | Branded sender ID | Not supported in US/Canada |

### When to Use Short Codes

- Volume exceeds 100,000 messages/day
- Marketing campaigns with opt-in
- Time-sensitive mass notifications
- Need vanity number (e.g., 12345, ACME)

## Deliverability

### Common Issues

| Issue | Cause | Solution |
|-------|-------|----------|
| Messages blocked | No 10DLC registration | Complete registration |
| Low delivery rate | Carrier filtering | Review content, avoid spam patterns |
| Delayed delivery | Carrier congestion | Use short code for time-sensitive |
| Number blacklisted | Complaints | Review consent practices |

### Carrier Filtering Triggers

Avoid these patterns:
- URL shorteners (bit.ly, etc.) - use branded short domains
- All caps or excessive punctuation
- Spam keywords ("FREE", "ACT NOW", "LIMITED TIME")
- High-frequency sends to same number
- Messages without brand identification

### Monitor Delivery

```typescript
// Track delivery via webhooks
app.post('/webhooks/courier', async (req, res) => {
  const { type, data } = req.body;

  if (type === 'message:updated' && data.channel === 'sms') {
    if (data.status === 'DELIVERED') {
      await recordDelivery(data.id, 'delivered');
    } else if (data.status === 'UNDELIVERABLE') {
      await recordDelivery(data.id, 'failed', data.reason);
    }
  }

  res.sendStatus(200);
});
```

## Timing Considerations

### Quiet Hours

Don't send SMS between 10 PM - 8 AM local time (unless urgent/transactional). See [Patterns > Quiet Hours](../guides/patterns.md#quiet-hours) for the implementation.

## Related

- [Authentication](../transactional/authentication.md) - OTP and 2FA patterns
- [Multi-Channel](../guides/multi-channel.md) - SMS in fallback chains
- [Throttling](../guides/throttling.md) - SMS rate limiting
- [WhatsApp](./whatsapp.md) - Alternative mobile channel

````


### `resources/channels/whatsapp.md`

````markdown
# WhatsApp Channel

## Quick Reference

### Rules
- Business-initiated messages REQUIRE pre-approved templates
- Templates must be approved by Meta (24-72 hour review)
- 24-hour session window: after user messages, can send free-form for 24hrs
- After 24hrs with no user message: MUST use template
- Phone numbers MUST be E.164 format: +15551234567
- Explicit opt-in required before messaging users
- Template categories: UTILITY (fastest approval), AUTHENTICATION, MARKETING (strictest)
- Variables use positional format: `{{1}}`, `{{2}}`, `{{3}}`
- Quick reply buttons: maximum 3
- Template variable count must match exactly

### Common Mistakes
- Sending without approved template (outside 24hr window)
- Variable count mismatch (template has 3, you send 2)
- Phone number not in E.164 format
- Not recording opt-in consent
- Using MARKETING category for transactional messages (slower approval)
- Too many template variables (looks spammy, may be rejected)
- Promotional content in UTILITY templates (will be rejected)
- Not handling template rejection gracefully

### Templates

**Send Template Message (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: {
      customerName: "Jane",      // {{1}}
      orderNumber: "12345",      // {{2}}
      deliveryDate: "Jan 30"     // {{3}}
    },
    routing: { method: "single", channels: ["whatsapp"] }
  }
});
```

**Send Template Message (Python):**
```python
client.send.message(
    message={
        "to": {"phone_number": "+15551234567"},
        "template": "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
        "data": {
            "customerName": "Jane",
            "orderNumber": "12345",
            "deliveryDate": "Jan 30",
        },
        "routing": {"method": "single", "channels": ["whatsapp"]},
    }
)
```

**With Media Header:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbxd9q3x7v1d5c8n2w6hj",
    data: { orderNumber: "12345" },
    channels: {
      whatsapp: {
        override: {
          body: {
            header: {
              type: "image",
              image: { link: "https://acme.com/proof.jpg" }
            }
          }
        }
      }
    },
    routing: { method: "single", channels: ["whatsapp"] }
  }
});
```

**WhatsApp with SMS Fallback:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbr0s4y7qv2n8c5d1xj9k",
    routing: {
      method: "single",
      channels: ["whatsapp", "sms"]
    }
  }
});
```

---

Best practices for sending WhatsApp Business messages with templates and session messaging.

## WhatsApp Business API Overview

WhatsApp has strict rules to prevent spam and maintain user trust:

| Message Type | When to Use | Approval Required |
|--------------|-------------|-------------------|
| Template messages | Business-initiated contact | Yes (by Meta) |
| Session messages | Response within 24hr window | No |

## Message Types

### Template Messages (Business-Initiated)

Required when:
- Starting a new conversation
- User hasn't messaged in 24+ hours
- Sending proactive notifications

**Must be pre-approved by Meta** (24-72 hour review)

### Session Messages (User-Initiated)

Available when:
- User messaged within last 24 hours
- Responding to user inquiry
- Continuing active conversation

**No template required** - send free-form text, images, documents, etc.

### 24-Hour Window

```
User sends message
        │
        ▼
┌───────────────────────────────────────┐
│       24-Hour Session Window          │
│                                       │
│  ✓ Free-form messages allowed         │
│  ✓ Any media type                     │
│  ✓ No template required               │
│                                       │
└───────────────────────────────────────┘
        │
        ▼ (24 hours pass)
        
Must use approved template to re-initiate
```

## Template Setup

### Template Categories

| Category | Use Case | Approval Time | Cost |
|----------|----------|---------------|------|
| **UTILITY** | Order updates, account alerts | ~24 hours | Lower |
| **AUTHENTICATION** | OTP, verification codes | ~24 hours | Lower |
| **MARKETING** | Promotions, offers | 24-72 hours | Higher |

**Start with UTILITY templates** - highest approval rates.

### Template Structure

```
┌─────────────────────────────────────┐
│ HEADER (optional)                   │
│ Text, Image, Video, or Document     │
├─────────────────────────────────────┤
│ BODY (required)                     │
│ Main message with {{variables}}     │
├─────────────────────────────────────┤
│ FOOTER (optional)                   │
│ Small disclaimer text               │
├─────────────────────────────────────┤
│ BUTTONS (optional)                  │
│ Quick Reply or URL buttons          │
└─────────────────────────────────────┘
```

### Template Examples

**Order Shipped (UTILITY):**
```
Name: order_shipped
Category: UTILITY
Language: en

Header: 📦 Your order is on the way!
Body: Hi {{1}}, your order #{{2}} has shipped and will arrive by {{3}}.
Footer: Track at acme.com/track
Buttons: [Track Order] → https://acme.com/track/{{2}}
```

**OTP Code (AUTHENTICATION):**
```
Name: otp_verification
Category: AUTHENTICATION
Language: en

Body: Your Acme verification code is {{1}}. Valid for 10 minutes. Do not share this code.
```

**Appointment Reminder (UTILITY):**
```
Name: appointment_reminder
Category: UTILITY
Language: en

Header: 📅 Appointment Reminder
Body: Hi {{1}}, this is a reminder for your appointment:

📍 {{2}}
📆 {{3}}
⏰ {{4}}

Reply YES to confirm or RESCHEDULE to change.
Buttons: [Confirm] [Reschedule]
```

## Courier Integration

### Setup

1. Create Meta Business account at [business.facebook.com](https://business.facebook.com)
2. Set up WhatsApp Business API (via Meta or BSP)
3. Get Phone Number ID and Access Token
4. Configure in Courier dashboard: **Integrations → WhatsApp**

### Send Template Message

```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

// Basic template send
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht", // Your Courier template
    data: {
      customerName: "Jane",
      orderNumber: "12345",
      deliveryDate: "January 30, 2026"
    },
    routing: {
      method: "single",
      channels: ["whatsapp"]
    }
  }
});
```

### Template Variable Mapping

Configure in your Courier template to map data to WhatsApp variable positions:

```jsonc
// message.data passed on your send call:
{
  "customerName": "Jane",    // Maps to {{1}}
  "orderNumber":  "12345",   // Maps to {{2}}
  "deliveryDate": "Jan 30"   // Maps to {{3}}
}
```

```text
WhatsApp template body:
  Hi {{1}}, your order #{{2}} has shipped and will arrive by {{3}}.

Rendered message:
  Hi Jane, your order #12345 has shipped and will arrive by Jan 30.
```

### With Media Header

```typescript
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    template: "nt_01kmrbxm4x7q1v5d8c2n6w9hj",
    data: {
      customerName: "Jane",
      orderNumber: "12345"
    },
    channels: {
      whatsapp: {
        override: {
          body: {
            header: {
              type: "image",
              image: {
                link: "https://acme.com/delivery-proof/12345.jpg"
              }
            }
          }
        }
      }
    }
  }
});
```

### OTP Template

```typescript
async function sendWhatsAppOTP(
  phoneNumber: string,
  code: string,
  requestId: string,
) {
  await client.send.message({
    message: {
      to: { phone_number: phoneNumber },
      template: "nt_01kmrbr0s4y7qv2n8c5d1xj9k",
      data: { code },
      routing: {
        method: "single",
        channels: ["whatsapp", "sms"], // Fallback to SMS
      },
    },
  }, {
    // requestId identifies this OTP attempt (typically a row ID in your
    // OTP table); a "resend code" produces a new requestId and thus a
    // new send, while retries of the same attempt collapse.
    headers: { "Idempotency-Key": `otp-wa-${phoneNumber}-${requestId}` },
  });
}

await sendWhatsAppOTP("+15551234567", "847293", crypto.randomUUID());
```

### With User Profile

```typescript
// Store WhatsApp number in user profile
await client.profiles.create("user-123", {
  profile: {
    phone_number: "+15551234567" // E.164 format
  }
});

// Send using user_id
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbx5q8x2v6d1c4n7w9hj",
    data: {
      patientName: "Jane",
      location: "123 Medical Center",
      date: "January 30, 2026",
      time: "2:00 PM"
    },
    routing: {
      method: "single",
      channels: ["whatsapp"]
    }
  }
});
```

## Interactive Messages

### Quick Reply Buttons

Up to 3 buttons for quick responses:

```typescript
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    channels: {
      whatsapp: {
        override: {
          body: {
            type: "interactive",
            interactive: {
              type: "button",
              body: {
                text: "Your appointment is tomorrow at 2pm. Can you make it?"
              },
              action: {
                buttons: [
                  { type: "reply", reply: { id: "confirm", title: "Yes, I'll be there" } },
                  { type: "reply", reply: { id: "reschedule", title: "Need to reschedule" } },
                  { type: "reply", reply: { id: "cancel", title: "Cancel" } }
                ]
              }
            }
          }
        }
      }
    }
  }
});
```

### List Messages

For menus with multiple options:

```typescript
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    channels: {
      whatsapp: {
        override: {
          body: {
            type: "interactive",
            interactive: {
              type: "list",
              body: {
                text: "What would you like help with?"
              },
              action: {
                button: "View Options",
                sections: [
                  {
                    title: "Orders",
                    rows: [
                      { id: "track", title: "Track my order", description: "Check delivery status" },
                      { id: "return", title: "Return an item", description: "Start a return" }
                    ]
                  },
                  {
                    title: "Account",
                    rows: [
                      { id: "billing", title: "Billing question", description: "Invoices and payments" },
                      { id: "password", title: "Reset password", description: "Change your password" }
                    ]
                  }
                ]
              }
            }
          }
        }
      }
    }
  }
});
```

## Opt-In Requirements

### User Consent Required

WhatsApp requires explicit opt-in before messaging users:

| Requirement | Details |
|-------------|---------|
| Clear disclosure | What messages they'll receive |
| Active opt-in | User must take action (not pre-checked) |
| Easy opt-out | Reply STOP or similar |

### Valid Opt-In Methods

```html
<!-- Web form -->
<label>
  <input type="checkbox" name="whatsapp_consent">
  Receive order updates via WhatsApp to {{phone_number}}
</label>
```

- SMS keyword: "Text START to receive WhatsApp updates"
- Click-to-chat: `https://wa.me/15551234567?text=Subscribe`
- In-app toggle with clear description

### Record Consent

```typescript
interface WhatsAppConsent {
  phoneNumber: string;
  consentedAt: Date;
  method: 'web_form' | 'sms_keyword' | 'click_to_chat';
  categories: string[]; // ['orders', 'appointments']
}

await storeConsent({
  phoneNumber: "+15551234567",
  consentedAt: new Date(),
  method: 'web_form',
  categories: ['orders']
});
```

## Template Best Practices

### Approval Tips

1. **Start with UTILITY** - Highest approval rates
2. **Be clear and specific** - Avoid vague language
3. **Include sample values** - Show Meta what it will look like
4. **No promotional content in UTILITY** - Or it will be rejected
5. **Follow Meta's policies** - No prohibited content

### Variable Guidelines

**Good:**
```
Hi {{1}}, your order #{{2}} shipped!
```

**Bad:**
```
Hi {{1}}, {{2}} {{3}} {{4}} {{5}} {{6}}...
```
(Too many variables, looks like spam)

### Content Guidelines

| Do | Don't |
|----|-------|
| Be clear and specific | Use vague, clickbait language |
| Personalize with name | Send identical mass messages |
| Provide value | Be overly promotional (in UTILITY) |
| Include business name | Forget to identify yourself |

## Pricing

WhatsApp charges per conversation (24-hour window from first message).

> **Last verified: 2026-04.** Meta updates WhatsApp Business pricing and free-tier rules frequently. If this file is older than **3 months**, re-verify against https://developers.facebook.com/docs/whatsapp/pricing before quoting these numbers to a user. Do **not** reason from memory — fetch the live page.

| Category | Cost Range (varies by country, as of 2026-04) |
|----------|-----------------------------------------------|
| Utility | $0.005 - $0.015 |
| Authentication | $0.005 - $0.015 |
| Marketing | $0.01 - $0.05 |
| Service (user-initiated) | Free (first 1,000/month) |

**Tip:** Use UTILITY for order updates to minimize costs.

## Troubleshooting

| Issue | Cause | Solution |
|-------|-------|----------|
| Template rejected | Policy violation | Review Meta guidelines, revise |
| Message not delivered | Outside 24hr window | Use approved template |
| "Invalid parameter" | Variable count mismatch | Check template has matching params |
| Phone not found | Invalid format | Use E.164 format (+1...) |
| Rate limited | Too many messages | Implement queuing |

### Debug Checklist

1. WhatsApp configured correctly in Courier?
2. Phone number in E.164 format?
3. Template approved in Meta Business Suite?
4. Variable count matches template?
5. User has opted in?
6. Within 24-hour window (for session messages)?

## Multi-Channel with WhatsApp

### WhatsApp with SMS Fallback

```typescript
// Try WhatsApp, fall back to SMS
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbr0s4y7qv2n8c5d1xj9k",
    data: { code: "847293" },
    routing: {
      method: "single",
      channels: ["whatsapp", "sms"]
    }
  }
});
```

### When to Use Each

| Use WhatsApp | Use SMS |
|--------------|---------|
| Rich media needed | Simple text only |
| International messaging (cheaper) | US/Canada domestic |
| User prefers WhatsApp | No WhatsApp account |
| Conversation-style support | One-way notifications |

## Related

- [SMS](./sms.md) - Alternative mobile channel
- [Multi-Channel](../guides/multi-channel.md) - WhatsApp in routing strategies
- [Orders](../transactional/orders.md) - Order notification patterns
- [Appointments](../transactional/appointments.md) - Appointment reminders

````


### `resources/growth/adoption.md`

````markdown
# Feature Adoption Notifications

## Quick Reference

### Rules
- In-app first (user already engaged), email second
- Don't announce to users already using the feature
- Target by relevance (API feature → developers only)
- Three phases: Awareness → Education → Reinforcement
- Major features: 1-2 emails max **per feature**, across the whole launch cycle (awareness + education); stay under the 2-3/week cross-campaign cap in [growth/index.md](./index.md)
- Minor features: In-app only
- Consider monthly "What's New" digest instead of per-feature

### Three-Phase Approach
| Phase | Goal | Channels |
|-------|------|----------|
| Awareness | "New feature!" | In-app, Email, Push |
| Education | "Here's how" | In-app guide, Email w/video |
| Reinforcement | "You're using it!" | In-app, Push |

### Targeting Rules
| Feature Type | Target Users |
|--------------|--------------|
| API/Webhooks | Developers |
| Templates | Designers |
| Permissions | Admins/Managers |
| Reports | Managers |
| General | Everyone |

### Common Mistakes
- Announcing to users already using feature
- Announcing to wrong user segment (API to designers)
- Too many feature emails (use "What's New" digest)
- No in-app announcement (highest conversion)
- No education phase (just "it's here!")
- Missing first-use celebration

### Templates

**Feature Announcement Email (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbs3q6w9x2c5v8n1d4tjh",
    data: {
      featureName: "Dark Mode",
      headline: "Easy on the eyes",
      benefits: ["Reduce eye strain", "Save battery", "Look cool"],
      ctaUrl: "https://app.acme.com/settings/appearance"
    }
  }
});
```

**Feature Announcement Email (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbs3q6w9x2c5v8n1d4tjh",
        "data": {
            "featureName": "Dark Mode",
            "headline": "Easy on the eyes",
            "benefits": ["Reduce eye strain", "Save battery", "Look cool"],
            "ctaUrl": "https://app.acme.com/settings/appearance",
        },
    }
)
```

**First-Use Celebration:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "You discovered Dark Mode! 🌙",
      body: "You just saved your eyes. Enable keyboard shortcuts next?"
    },
    routing: { method: "single", channels: ["inbox", "push"] }
  }
});
```

---

Drive discovery and usage of product features.

## When to Use

- New feature launches
- Underutilized features
- Features relevant to user's workflow
- Milestone celebrations

## Feature Announcement Framework

Follow the three-phase approach outlined in [Quick Reference > Three-Phase Approach](#three-phase-approach) above.

## Phase 1: Awareness

### In-App Announcement

Most effective - catch users in context. Show a banner or modal when user is in the app.

### Email Announcement

For major features, follow up with email.

Subject: New: [Feature Name] is here

Include:
- Feature name and headline
- Brief description of value
- 2-3 key benefits
- CTA button
- GIF or image showing feature
- Note that they can ignore if not interested

### Target the Right Users

Don't announce to everyone - target based on relevance:
- Skip users already using the feature
- Only announce to users who would benefit
- Example: Announce API feature only to developers

## Phase 2: Education

### Tutorial Sequence

Send 2 days after awareness.

Subject: How to use [Feature] (3 simple steps)

Include:
- Video walkthrough link
- Step-by-step instructions
- Template or quick-start option
- CTA to try the feature

### In-App Tooltips

Use progressive disclosure. Show tooltips when user visits relevant page. Make them dismissable.

## Phase 3: Reinforcement

### First-Use Celebration

When user first uses the feature, celebrate.

Channel: In-app + Push

Include:
- Encouragement (e.g., "You just saved yourself hours!")
- Tip for getting more out of the feature

### Usage Milestone

Track feature usage and celebrate milestones.

Include:
- What milestone was reached
- Impact (e.g., "You've saved approximately 5 hours")
- What's next (e.g., "Power user: 50 automations")

## Targeting Strategies

### Feature Usage Check

Before announcing a feature, verify the user isn't already using it:

```typescript
interface FeatureUsage {
  featureId: string;
  userId: string;
  firstUsed?: Date;
  usageCount: number;
}

async function shouldAnnounce(
  userId: string,
  featureId: string
): Promise<boolean> {
  const usage = await getFeatureUsage(userId, featureId);
  // Don't announce to users already using the feature
  return !usage || usage.usageCount === 0;
}

// Only send if user hasn't used the feature
if (await shouldAnnounce(userId, "dark-mode")) {
  await client.send.message({
    message: {
      to: { user_id: userId },
      template: "nt_01kmrbs3q6w9x2c5v8n1d4tjh",
      data: {
        featureName: "Dark Mode",
        benefits: ["Reduce eye strain", "Save battery"],
        ctaUrl: "https://app.acme.com/settings/appearance",
      },
    },
  });
}
```

### By User Segment

Map features to user roles and only announce to the right segment:

```typescript
type UserRole = "developer" | "designer" | "manager" | "general";

const featureTargets: Record<string, UserRole[]> = {
  "api-webhooks": ["developer"],
  "template-builder": ["designer"],
  "team-permissions": ["manager"],
  "dark-mode": ["developer", "designer", "manager", "general"],
};

function shouldTarget(userRole: UserRole, featureId: string): boolean {
  const targets = featureTargets[featureId] ?? ["general"];
  return targets.includes(userRole) || targets.includes("general");
}
```

### By Usage Pattern

If user does X frequently, suggest related feature Y:
- Frequent manual task creation → Templates
- Frequent exports → Scheduled reports
- Many team mentions → Channel notifications

## Power User Guidance

### Advanced Feature Unlock

When user reaches high usage threshold, unlock advanced features and notify them.

Include:
- Acknowledgment of their power user status
- List of advanced features they can now access
- Brief description of each

## Channel Strategy

| Phase | Primary Channel | Secondary |
|-------|----------------|-----------|
| Awareness | In-app banner | Email, Push |
| Education | In-app guide | Email (video) |
| Reinforcement | In-app message | Push |

### Why In-App First

- User is already engaged
- Feels like product guidance, not marketing
- Can be contextual (show when relevant)
- Higher conversion than email

## Frequency Guidelines

- **Major features:** 1-2 emails max
- **Minor features:** In-app only
- **Don't:** Announce every small update
- **Do:** Batch related updates

Consider a monthly "What's New" digest instead of per-feature announcements.

## Metrics

### Track Per Feature

- **Awareness:** % of target users who saw announcement
- **Click-through:** % who clicked to learn more
- **Adoption:** % who used feature after notification
- **Retention:** % still using after 30 days

### A/B Test Elements

- Subject lines
- CTA button text
- With/without video
- In-app vs email first

## Related

- [Onboarding](./onboarding.md) - First-time user flows
- [Engagement](./engagement.md) - Ongoing usage patterns
- [Inbox](../channels/inbox.md) - In-app notifications

````


### `resources/growth/campaigns.md`

````markdown
# Campaign Notifications

## Quick Reference

### Rules
- Marketing notifications should be opt-in only; record when and how the user opted in
- MUST include physical address in marketing email
- MUST include unsubscribe link (functional for 30 days after send)
- Honor opt-out immediately in-loop; suppress on the next send, don't wait for a batch job
- Max frequency: 1-2 promotional emails per week
- SMS campaigns require opt-in always, not opt-out
- Push campaigns: Only if user opted into promotional push

### Valid vs Invalid Opt-In
| Valid | Invalid |
|-------|---------|
| User checks unchecked box | Pre-checked boxes |
| "Subscribe to newsletter" click | Assumed from purchase |
| Explicit request for updates | Bundled consent |

### Required Email Elements
- Physical mailing address
- Unsubscribe link
- Accurate subject line
- Sender identification

### Best Send Times
| Audience | Best Time |
|----------|-----------|
| B2B | Tue-Thu, 9-11 AM |
| B2C | Test for your audience |
| Avoid | Monday AM, Friday PM |

### Common Mistakes
- Pre-checked opt-in boxes (invalid consent)
- Missing physical address in email
- Hidden unsubscribe link
- Misleading subject lines
- SMS promotions without written consent
- Too frequent (> 2/week = high unsubscribe)
- Emailing unsubscribed users

### Templates

**Check Opt-In Before Send (TypeScript):**
```typescript
const prefs = await client.users.preferences.retrieve(userId);
const marketingPref = prefs.items.find(p => p.topic_id === "marketing");

if (marketingPref?.status !== "OPTED_IN") {
  return; // Skip - not opted in
}

await client.send.message({
  message: {
    to: { user_id: userId },
    template: "nt_01kmrbt4n7q1x5c8v2d6w9hf",
    data: { discount: "30%", code: "SUMMER30", expiresAt: "Aug 31" }
  }
});
```

**Check Opt-In Before Send (Python):**
```python
prefs = client.users.preferences.retrieve(user_id)
marketing_pref = next((t for t in prefs.items if t.topic_id == "marketing"), None)

if not marketing_pref or marketing_pref.status != "OPTED_IN":
    return

client.send.message(
    message={
        "to": {"user_id": user_id},
        "template": "nt_01kmrbt4n7q1x5c8v2d6w9hf",
        "data": {"discount": "30%", "code": "SUMMER30", "expiresAt": "Aug 31"},
    }
)
```

**Promotional Email:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbtc8x2q6v1d5n9w4j7h",
    data: {
      discount: "50%",
      code: "UPGRADE50",
      expiresAt: "January 31, 2026",
      currentPlan: "Free",
      upgradePlan: "Pro"
    }
  }
});
```

**Unsubscribe Handler:**
```typescript
app.get('/unsubscribe', async (req, res) => {
  const { token, userId, category } = req.query;

  if (!token || !userId || !category) {
    return res.status(400).send('Missing parameters');
  }

  if (!verifyUnsubscribeToken(String(token), String(userId), String(category))) {
    return res.status(400).send('Invalid link');
  }

  await client.users.preferences.updateOrCreateTopic(String(category), {
    user_id: String(userId),
    topic: { status: "OPTED_OUT" }
  });

  res.send('You have been unsubscribed.');
});
```

---

Promotional messages for sales, upgrades, and marketing campaigns.

## Key Distinction

**Campaign notifications are opt-in only.** Unlike transactional or product notifications, these are promotional messages. Only send them to users who explicitly opted in, record how and when they opted in, and honor opt-outs immediately. Opt-in everywhere also protects list health and deliverability.

## Opt-In Requirements

### What Counts as Opt-In

**Valid:**
- User checks unchecked box: "Send me promotional emails"
- User clicks "Subscribe to newsletter"
- User explicitly requests updates

**Invalid:**
- Pre-checked boxes
- Assumed consent from purchase
- Bundled consent ("By signing up you agree to receive...")

### Implementation

Always check opt-in status before sending campaigns. Skip users who are not opted in.

## Campaign Types

### Sales & Promotions

Subject: Summer Sale - 30% off everything

Include:
- Sale name and discount
- Promo code
- Expiration date
- Featured products
- Required footer (unsubscribe, address)

### Upgrade Promotions

Subject: Upgrade to Pro - 50% off for 3 months

Include:
- Current plan and what they're missing
- Discount offer with code
- Benefits of upgrading (list 3-4)
- Usage-based hook (e.g., "you've used 3 of 3 projects")
- Expiration date

### Event Invitations

Subject: You're invited: Product Launch Webinar

Include:
- Event name, date, time with timezone
- What they'll learn
- Speakers
- Register button
- Note about recording availability

### Seasonal Campaigns

Subject: Black Friday: 40% off Pro

Include:
- Headline with discount
- Code
- Start and end dates
- Countdown urgency

## A/B Testing

### Test Elements

- Subject lines
- Send time
- CTA button text
- Offer amount
- Email design

### Implementation

Assign variants deterministically (e.g., based on user ID) and track which variant was sent for analytics.

Example test:
- Variant A: "30% off - Today only"
- Variant B: "Your exclusive discount inside"

Track: Open rate, click rate, conversion rate

## Segmentation

### By User Type

- Free users: Upgrade to paid
- Pro users: Upgrade to Enterprise
- Churned users: Win-back campaign

### By Behavior

- Users who viewed pricing but didn't upgrade
- Users with high engagement
- Users approaching usage limits

## Frequency & Timing

### Frequency Guidelines

| Campaign Type | Max Frequency |
|---------------|---------------|
| Sales/promotions | 1-2/week |
| Newsletter | 1/week |
| Product updates | 2-4/month |
| Events | As needed |

### Best Send Times

Research varies, but generally:
- **B2B:** Tuesday-Thursday, 9-11 AM
- **B2C:** Varies by audience, test
- **Avoid:** Monday morning, Friday afternoon

## Required Elements

### Marketing Email Requirements

Every marketing email must include:

1. **Physical mailing address**
2. **Unsubscribe link** (functional for at least 30 days after send)
3. **Clear identification as advertisement** (if applicable)
4. **Accurate subject line and sender identification**
5. **Link to your privacy policy**

Example footer:
"You're receiving this because you opted in to marketing emails from Acme. Unsubscribe | Manage preferences. Acme, Inc., 123 Main Street, San Francisco, CA 94102"

## Unsubscribe Handling

### One-Click Unsubscribe

Make unsubscribe simple. Verify token and update user preferences immediately.

### Preference Center

Better than all-or-nothing unsubscribe. Let users choose:
- Product updates (new features, improvements)
- Promotions and offers
- Weekly newsletter
- Event invitations

## Channel Strategy

| Campaign Type | Email | Push | SMS |
|---------------|-------|------|-----|
| Sales/promotions | Yes | Yes* | Yes* |
| Newsletter | Yes | - | - |
| Event invitations | Yes | Yes* | - |
| Product updates | Yes | - | - |

*Only if explicitly opted in for push/SMS marketing

### SMS Campaigns (Extra Caution)

SMS marketing has stricter rules:
- Requires explicit opt-in — never opt-out
- Higher unsubscribe risk
- Reserve for high-value, time-sensitive offers

Only send SMS campaigns to users who explicitly opted in with the date recorded.

## Metrics

### Track

- **Open rate** (email)
- **Click-through rate**
- **Conversion rate**
- **Unsubscribe rate**
- **Revenue attributed**

### Benchmarks

| Metric | Average | Good |
|--------|---------|------|
| Open rate | 20% | 30%+ |
| Click rate | 2.5% | 5%+ |
| Unsubscribe | 0.5% | < 0.3% |
| Conversion | Varies | Track trend |

### If Unsubscribes Are High

- Review frequency (too many emails?)
- Check relevance (right audience?)
- Evaluate value (worth opening?)
- Consider preference center

## Anti-Spam Best Practices

### Do

- Only email opted-in users
- Provide clear unsubscribe
- Honor opt-outs immediately
- Send relevant content
- Monitor engagement metrics

### Don't

- Buy email lists
- Hide unsubscribe links
- Use misleading subject lines
- Email unsubscribed users
- Send from no-reply addresses

## Related

- [Email](../channels/email.md) - Email deliverability
- [Preferences](../guides/preferences.md) - Preference management
- [Re-engagement](./reengagement.md) - Win-back campaigns

````


### `resources/growth/engagement.md`

````markdown
# Engagement Notifications

## Quick Reference

### Rules
- Activity notifications: Send to affected user, not actor
- Batch likes/follows: "Jane and 5 others" not 6 separate
- Direct mentions: Higher priority than likes
- Streaks: Celebrate at 3, 7, 14, 30, 60, 90, 365 days
- Daily digest: Only send if there's content (never empty)
- Respect user preference for realtime vs digest

### Activity Priority
| Type | Priority | Channels |
|------|----------|----------|
| Direct mention | High | Push + In-app + Email |
| Comment/reply | High | Push + In-app |
| Like (single) | Low | In-app only |
| Likes (batch) | Low | In-app only |
| New follower | Low | In-app only |
| Streak at risk | High | Push |

### Frequency Limits
| Type | Max |
|------|-----|
| Activity | 10/hour |
| Milestone | 1/hour |
| Streak | 1/day |
| Digest | 1/period |

### Common Mistakes
- 10 separate "liked your post" notifications (batch them!)
- Sending activity to wrong user (actor vs affected)
- No batching window (send after 5-10 min delay)
- Empty digests ("Nothing happened this week")
- Streak notifications for low-value streaks (< 3 days)
- Same urgency for likes vs direct mentions

### Templates

**Batched Activity (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Your post is getting attention!",
      body: "Jane, Bob, and 8 others liked your post"
    },
    routing: { method: "all", channels: ["push", "inbox"] }
  }
});
```

**Batched Activity (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "content": {
            "title": "Your post is getting attention!",
            "body": "Jane, Bob, and 8 others liked your post",
        },
        "routing": {"method": "all", "channels": ["push", "inbox"]},
    }
)
```

**Streak Celebration:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "7-day streak! 🔥",
      body: "You're building a habit. Keep it going!"
    },
    routing: { method: "single", channels: ["push", "inbox"] }
  }
});
```

**Streak at Risk:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Your streak is at risk!",
      body: "Log in within 4 hours to keep your 14-day streak alive."
    },
    routing: { method: "single", channels: ["push"] }
  }
});
```

---

Build habits, drive retention, and keep users active with activity notifications.

## Types of Engagement Notifications

| Type | Trigger | Example |
|------|---------|---------|
| Activity | User action affects another user | "Jane commented on your post" |
| Content | New relevant content available | "5 new posts in your feed" |
| Milestone | User achieves something | "You've completed 100 tasks!" |
| Streak | Maintaining consecutive activity | "7-day streak! Keep it going" |
| Digest | Aggregated updates | "Your weekly summary" |

## Activity Notifications

### Social Notifications

The core engagement loop for social/collaborative products:

1. User A takes action
2. Send notification to User B (affected user)
3. User B returns to respond
4. User A gets notified
5. Loop continues

### Common Activity Types

- **Comment:** "Jane commented on your post"
- **Mention:** "Mike mentioned you in a comment"
- **Like:** "Sarah liked your post"
- **Follow:** "Bob started following you"
- **Share:** "Lisa shared your article"

Channels: In-app + Push for real-time, Email for important mentions

## Batching & Aggregation

### Why Batch

- Reduce notification fatigue
- "5 people liked your post" > 5 separate notifications
- Higher engagement with aggregated content

### Batch Summary Formats

- 1 actor: "Jane liked your post"
- 2 actors: "Jane and Bob liked your post"
- 3+ actors: "Jane, Bob, and 3 others liked your post"

### Implementation

Queue events and batch after a delay window. Group by type and target, then summarize. Use `formatActors()` from [Patterns](../guides/patterns.md#actor-aggregation) for actor name formatting. See [Batching](../guides/batching.md) for time-window and count-based batching strategies.

## Digests

### Daily Digest

Send if there's content worth sharing. Don't send empty digests.

Subject: Your daily digest - [Date]

Include:
- Stats summary (new followers, likes, comments)
- Top performing content
- Action items (comments to respond to)
- CTA to view all activity

### Weekly Digest

Subject: Your week in review - [Date Range]

Include:
- Week summary (posts created, views, engagement trend)
- Top post
- Insights and suggestions
- Suggested actions

## Milestone Notifications

### Achievement Celebrations

Define meaningful milestones: 1, 10, 100, 1000.

When user hits milestone, send celebration:
- Channel: In-app + Push
- Include: Achievement, encouragement, what's next

Example: "100 tasks completed! Your productivity is on fire. Keep up the great work."

### Progress Notifications

For goals, show progress updates.

Include:
- Current progress vs target
- Percentage complete
- Encouragement message

## Streak Notifications

### Streak Calculation

```typescript
interface StreakData {
  userId: string;
  currentStreak: number;
  lastActiveDate: Date;
  longestStreak: number;
}

const STREAK_MILESTONES = [3, 7, 14, 30, 60, 90, 365];

function checkStreakStatus(streak: StreakData): "active" | "at_risk" | "broken" {
  const hoursSinceActive =
    (Date.now() - streak.lastActiveDate.getTime()) / (1000 * 60 * 60);

  if (hoursSinceActive < 20) return "active";
  if (hoursSinceActive < 28) return "at_risk"; // 4-hour warning window
  return "broken";
}

async function handleStreakCheck(streak: StreakData): Promise<void> {
  const status = checkStreakStatus(streak);

  if (status === "at_risk" && streak.currentStreak >= 3) {
    const hoursLeft = Math.floor(
      28 - (Date.now() - streak.lastActiveDate.getTime()) / (1000 * 60 * 60)
    );
    await client.send.message({
      message: {
        to: { user_id: streak.userId },
        content: {
          title: "Your streak is at risk!",
          body: `Log in within ${hoursLeft} hours to keep your ${streak.currentStreak}-day streak alive.`,
        },
        routing: { method: "single", channels: ["push"] },
      },
    });
  }
}
```

### Milestone Celebrations

Celebrate streak milestones with increasing intensity:
- 7 days: "One week! You're building a habit."
- 30 days: "One month! You're unstoppable."
- 365 days: "ONE YEAR! You're a legend."

Channel: In-app + Push

## Channel Strategy

| Notification Type | Push | In-App | Email |
|-------------------|------|--------|-------|
| Direct mention | Yes | Yes | Yes |
| Comment/reply | Yes | Yes | - |
| Like (single) | - | Yes | - |
| Likes (batch) | - | Yes | - |
| New follower | - | Yes | - |
| Daily digest | - | - | Yes |
| Weekly digest | - | - | Yes |
| Milestone | Yes | Yes | - |
| Streak at risk | Yes | - | - |

## Frequency Management

### Don't Over-Notify

Track notification frequency per user and enforce limits:
- Activity: Max 10/hour
- Milestone: Max 1/hour
- Streak: Max 1/day
- Digest: Max 1/period

### User Preference Integration

Respect user preferences for notification channels and frequency. Courier can automatically check preferences before sending.

## Related

- [Inbox](../channels/inbox.md) - In-app notification center
- [Push](../channels/push.md) - Push notification best practices
- [Preferences](../guides/preferences.md) - User notification preferences
- [Batching](../guides/batching.md) - Combining notifications into digests
- [Throttling](../guides/throttling.md) - Controlling notification frequency
- [Re-engagement](./reengagement.md) - When engagement drops

````


### `resources/growth/index.md`

````markdown
# Growth Notifications

## Quick Reference

### Rules
- Growth = help users succeed with product (not promotions)
- Onboarding: Usually no opt-in required (part of service)
- Activity notifications: Usually no opt-in required (expected)
- Re-engagement: Usually requires opt-in (not directly tied to action)
- Promotional: ALWAYS requires explicit opt-in
- When in doubt, check user preferences before sending

### Consent Requirements
| Type | Opt-in Required? |
|------|------------------|
| Onboarding (product guidance) | Usually no |
| Activity (mentions, comments) | Usually no |
| Feature announcements | Depends (if affects usage, no) |
| Re-engagement | Usually yes |
| Promotional/Marketing | Always yes |

### Frequency Limits
| Category | Max Frequency |
|----------|---------------|
| Onboarding | 1/day |
| Feature adoption — total across campaigns | 2-3/week |
| Feature adoption — per individual feature | 1-2 emails max over the whole launch cycle (see [adoption.md](./adoption.md)) |
| Activity | As occurs (batch if many) |
| Re-engagement | 1/week |
| Promotional | 1-2/week |

### Common Mistakes
- Treating growth as marketing (different consent rules)
- Same onboarding for all users (personalize by type/goal)
- Feature dump ("here are 50 features!") vs. one CTA
- Continuing onboarding after user activated
- Over-notifying (causes users to disable)
- No exit conditions (user activated, stop onboarding)

### Template

**Check Preferences Before Growth Send:**
```typescript
const prefs = await client.users.preferences.retrieve(userId);
const topic = prefs.items.find(p => p.topic_id === "growth-notifications");

if (topic?.status === "OPTED_IN") {
  await client.send.message({
    message: {
      to: { user_id: userId },
      template: "nt_01kmrbs3q6w9x2c5v8n1d4tjh"
    }
  });
}
```

---

Lifecycle notifications that drive user activation, engagement, retention, and expansion.

## Growth vs Marketing

Growth notifications focus on **product value** rather than promotions:

| Growth Notifications | Marketing Notifications |
|---------------------|------------------------|
| Help users succeed with product | Promote offers and sales |
| Triggered by behavior | Campaign-based broadcasts |
| Personalized to user journey | Segmented by audience |
| Educational and helpful | Promotional and persuasive |
| Often no opt-in required | Always requires opt-in |

## Categories

| Category | Purpose | See |
|----------|---------|-----|
| Onboarding | Get users to first value | [Onboarding](./onboarding.md) |
| Adoption | Drive feature discovery | [Adoption](./adoption.md) |
| Engagement | Build habits and retention | [Engagement](./engagement.md) |
| Re-engagement | Recover inactive users | [Re-engagement](./reengagement.md) |
| Referral | Drive viral growth | [Referral](./referral.md) |
| Campaigns | Promotional messages | [Campaigns](./campaigns.md) |

## User Lifecycle

| Stage | Focus | Notifications |
|-------|-------|--------------|
| Sign Up | Welcome | Transactional welcome |
| Onboard | Setup guidance | Onboarding sequence |
| Activate | Aha moment | Feature guidance, first success |
| Engage | Activity notifications | Social updates, content alerts |
| Retain | Habit building | Digests, streaks |
| Expand | Upsell opportunities | Upgrade prompts |
| Advocate | Referral | Share prompts, rewards |

## Consent Requirements

### When Opt-In Is Required

| Notification Type | Opt-In Required? | Notes |
|-------------------|-----------------|-------|
| Transactional | No | Required for service |
| Onboarding (product) | Usually no | Part of service delivery |
| Activity notifications | Usually no | Expected behavior |
| Feature announcements | Depends | If affects current usage, no |
| Re-engagement | Usually yes | Not directly tied to action |
| Promotional | Yes | Always |
| Referral invites | Yes (recipient) | Sender consented by action |

### Best Practice

When in doubt, ask. Better to have explicit consent than risk complaints. Check user preferences before sending growth notifications — see the [Quick Reference template](#template) above for the pattern.

## Channel Selection by Lifecycle Stage

| Stage | Primary Channel | Secondary | Rationale |
|-------|----------------|-----------|-----------|
| Onboarding | Email + In-app | Push (if enabled) | Need detail, user is engaged |
| Activation | In-app | Email | Catch them in context |
| Engagement | Push + In-app | Email digest | Real-time relevance |
| Re-engagement | Email | Push, SMS | User may not be in app |
| Referral | Email + In-app | Push | Need detail for sharing |
| Campaigns | Email | Push (opt-in) | Best for promotional content |

## Measuring Growth Notifications

### Key Metrics

**Activation Rate**
- % of new users who complete key action
- Target: 40-60% in first week

**Feature Adoption**
- % of users who use announced feature
- Track before/after notification

**Engagement Retention**
- Daily/Weekly/Monthly active users
- Notification-driven sessions

**Re-engagement Rate**
- % of dormant users who return
- Compare with control group

### A/B Testing

Split test notification variants using deterministic assignment (e.g., based on user ID). Track which variant was sent for analytics.

## Anti-Patterns

### Over-Notification

**Don't:** Send notification for every small event
**Do:** Batch, digest, and prioritize

### Ignoring Context

**Don't:** Send feature announcement to users already using feature
**Do:** Target based on actual behavior

### One-Size-Fits-All

**Don't:** Same onboarding for all users
**Do:** Personalize based on user type, goals, behavior

### Notification Fatigue

**Don't:** Multiple notifications per day
**Do:** Respect frequency limits, consolidate related updates

## Frequency Guidelines

| Category | Max Frequency | Notes |
|----------|---------------|-------|
| Onboarding | 1/day | More is okay if user-triggered |
| Feature adoption | 2-3/week | Space out announcements |
| Activity notifications | As they occur | But batch if many |
| Re-engagement | 1/week | Less is more |
| Promotional | 1-2/week | Heavy opt-out risk if more |

## Related

- [Preferences](../guides/preferences.md) - Managing user notification preferences
- [Multi-Channel](../guides/multi-channel.md) - Channel routing strategies

````


### `resources/growth/onboarding.md`

````markdown
# Onboarding Notifications

## Quick Reference

### Rules
- Goal: Get user to "aha moment" as fast as possible
- Day 0: Welcome email + getting started guide (1 hour after signup)
- Day 1: Reminder if setup incomplete
- Day 2-3: Nudge toward core action
- Day 4-7: Offer help if not activated
- STOP onboarding when user activates (send success message instead)
- Max 3-4 emails in first week (not 7)

### Timing Schedule
| Day | Channel | Content |
|-----|---------|---------|
| 0 (immediate) | Email | Transactional welcome |
| 0 (+1 hour) | Email/In-app | Getting started guide |
| 1 | Email + Push | Setup reminder (if incomplete) |
| 2-3 | Email | Core action nudge |
| 4-7 | Email | Help offer |

### Exit Conditions
| Condition | Action |
|-----------|--------|
| User activated | Stop onboarding, send success |
| User completed setup | Move to activation phase |
| User unsubscribed | Stop immediately |

### Common Mistakes
- Too many emails (7 in first week = unsubscribe)
- Feature dumping ("here are 50 features!")
- No personalization (same flow for everyone)
- No exit condition (onboarding continues after activation)
- Asking for permission on first launch (wait for value)
- Immediate system prompt for push (use pre-permission)

### Templates

**Getting Started (Day 0 +1hr, TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbsk4x7v1q5c8d2n6w9hf",
    data: {
      userName: "Jane",
      steps: [
        { title: "Connect your data", time: "2 min" },
        { title: "Create first dashboard", time: "5 min" }
      ]
    }
  }
});
```

**Getting Started (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbsk4x7v1q5c8d2n6w9hf",
        "data": {
            "userName": "Jane",
            "steps": [
                {"title": "Connect your data", "time": "2 min"},
                {"title": "Create first dashboard", "time": "5 min"},
            ],
        },
    }
)
```

**Activation Success:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbsav5n8q2x6c1d4w7jth",
    data: {
      achievement: "First dashboard created!",
      nextStep: "Invite your team"
    },
    routing: { method: "all", channels: ["email", "push", "inbox"] }
  }
});
```

---

Get new users to their first value moment as quickly as possible.

## The Goal

Onboarding notifications exist to:
1. **Reduce time-to-value** - Help users succeed faster
2. **Drive activation** - Get users to the "aha moment"
3. **Reduce churn** - Users who don't activate, leave

## The "Aha Moment"

Every product has a moment when users "get it":

| Product Type | Aha Moment |
|--------------|------------|
| Project management | Create first task, see it complete |
| Analytics | See first dashboard with their data |
| Communication | Send first message, get response |
| E-commerce | Complete first purchase |
| SaaS tool | Complete core workflow |

**Your job:** Get users there as fast as possible.

## Onboarding Flow

| Day | Stage | Action |
|-----|-------|--------|
| Day 0 | Welcome | Welcome email + getting started guide |
| Day 1 | Setup | Reminder if setup incomplete |
| Day 2-3 | First Action | Nudge toward core action |
| Day 4-7 | Activation Help | Offer help if not activated |
| Day 7+ | Success or Re-engage | Celebrate or try re-engagement |

## Day 0: Welcome

### Transactional Welcome

Immediate confirmation (see [Account](../transactional/account.md)). Keep it simple: confirm account, provide login link.

### Getting Started Guide

Follow-up 1 hour after welcome with actionable guidance.

Subject: Get started with Acme in 3 steps

Include:
- User's name
- Checklist of 2-3 setup steps with time estimates
- Single CTA button: "Start Now"
- Offer to reply for help

## Day 1: Setup Reminder

If user hasn't completed setup, send a reminder.

Subject: Quick question: Ready to finish setup?

Include:
- What step they need to complete
- Time estimate
- Why it matters (e.g., "unlocks collaboration features")

Channels: Email + Push

## Day 2-3: First Action Nudge

Push toward the core action that leads to the aha moment.

Subject: Create your first project (5 min walkthrough)

Include:
- Template or quick-start option
- Video walkthrough link
- Encouragement: "Most users find their aha moment right here"

## Day 4-7: Activation Help

If user still hasn't activated, offer help.

Subject: Need a hand getting started?

Include:
- Acknowledgment that everyone's different
- Multiple help options (video, chat, call)
- Low-pressure tone

## Checklist Pattern

### In-App + Email Sync

Track onboarding progress and nudge via appropriate channel. For incomplete steps, prefer in-app first, then email.

### Completion Celebration

When onboarding is complete, celebrate the achievement. Channels: In-app + Push.

Include:
- Achievement badge or emoji
- What they can do next
- Link to explore more

## Personalized Onboarding

### By User Type

Customize onboarding based on user role (developer, designer, manager) and surface relevant features.

### By Goal

If user selected a goal during signup (increase productivity, team collaboration), customize the onboarding flow to match.

## Channel Strategy

| Day | Channel | Rationale |
|-----|---------|-----------|
| 0 (immediate) | Email | Confirmation, reference |
| 0 (+1hr) | In-app | They're likely still there |
| 1 | Email + Push | Bring them back |
| 2-3 | Email | Core guidance |
| 4-7 | Email | Help offer |
| 7+ | Email (less frequent) | Re-engagement territory |

## Orchestration

### Courier Journeys

Use [Journeys](../guides/journeys.md) to build onboarding sequences with delays, branching, and exit conditions — defined entirely via API.

**Journey DAG for onboarding:**

```json
{
  "name": "Onboarding Sequence",
  "nodes": [
    {
      "id": "trigger-1",
      "type": "trigger",
      "trigger_type": "api-invoke",
      "schema": {
        "type": "object",
        "properties": {
          "user_name": { "type": "string" },
          "signup_date": { "type": "string" },
          "timezone": { "type": "string" }
        },
        "required": ["user_name"]
      }
    },
    { "id": "send-welcome", "type": "send", "message": { "template": "<welcome-template-id>" } },
    { "id": "wait-1-day", "type": "delay", "mode": "duration", "duration": "P1D" },
    {
      "id": "check-setup",
      "type": "fetch",
      "method": "get",
      "url": "https://api.yourapp.com/users/{{user_id}}/setup-status",
      "merge_strategy": "overwrite"
    },
    {
      "id": "branch-setup",
      "type": "branch",
      "paths": [
        {
          "label": "Setup complete",
          "conditions": ["data.setup_complete", "is equal", "true"],
          "nodes": [
            { "id": "send-success", "type": "send", "message": { "template": "<success-template-id>" } },
            { "id": "exit-done", "type": "exit" }
          ]
        }
      ],
      "default": {
        "label": "Still onboarding",
        "nodes": [
          { "id": "send-reminder", "type": "send", "message": { "template": "<reminder-template-id>" } },
          { "id": "wait-2-days", "type": "delay", "mode": "duration", "duration": "P2D" },
          { "id": "send-nudge", "type": "send", "message": { "template": "<nudge-template-id>" } },
          { "id": "exit-default", "type": "exit" }
        ]
      }
    }
  ],
  "enabled": true
}
```

**Invoke when a user signs up:**

The `fetch` node above templates the URL with `{{user_id}}`, so include `user_id` inside `data` (the top-level `user_id` is used for recipient resolution but isn't guaranteed to be projected into `data` for variable interpolation).

**Node:**
```typescript
const { runId } = await client.journeys.invoke(JOURNEY_ID, {
  user_id: "user-123",
  profile: { email: "jane@example.com" },
  data: {
    user_id: "user-123",
    user_name: "Jane",
    signup_date: "2026-05-20T12:00:00Z",
    timezone: "America/Los_Angeles",
  },
});
```

**Python:**
```python
response = client.journeys.invoke(
    template_id=JOURNEY_ID,
    user_id="user-123",
    profile={"email": "jane@example.com"},
    data={
        "user_id": "user-123",
        "user_name": "Jane",
        "signup_date": "2026-05-20T12:00:00Z",
        "timezone": "America/Los_Angeles",
    },
)
```

**curl (ad-hoc / CI):**
```bash
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user-123",
    "profile": { "email": "jane@example.com" },
    "data": {
      "user_id": "user-123",
      "user_name": "Jane",
      "signup_date": "2026-05-20T12:00:00Z",
      "timezone": "America/Los_Angeles"
    }
  }'
```

### Exit Conditions

Exit conditions are built into the journey DAG as branch nodes. When the user activates, the journey checks their status via a fetch node and exits early instead of sending more reminders. No external cancel call is needed — the journey handles it.

To send a success message on activation, include a send node in the "Setup complete" branch path (as shown above).

### Legacy: Automations

If you have existing Automations for onboarding, they continue to work:

```typescript
await client.automations.invoke.invokeByTemplate("onboarding-sequence", {
  recipient: "user-123",
  data: {
    cancelation_token: `onboarding-${userId}`,
    userName: "Jane",
    signupDate: new Date().toISOString(),
    timezone: "America/Los_Angeles",
  },
});

// Cancel when user activates
await client.automations.invoke.invokeAdHoc({
  recipient: userId,
  automation: {
    steps: [{ action: "cancel", cancelation_token: `onboarding-${userId}` }],
  },
});
```

### Timezone-Aware Scheduling

Onboarding emails should arrive during business hours in the user's local timezone:

```typescript
// Requires: date-fns-tz (https://www.npmjs.com/package/date-fns-tz)
// Parsing Date.toLocaleString back into a Date is fragile across locales —
// use an IANA-aware library for reliable results.
import { formatInTimeZone, fromZonedTime } from "date-fns-tz";

function getNextDeliveryTime(
  timezone: string,
  targetHour = 9,
): Date {
  const now = new Date();
  const localDate = formatInTimeZone(now, timezone, "yyyy-MM-dd");
  const localHour = Number(formatInTimeZone(now, timezone, "H"));

  const baseDate =
    localHour >= targetHour
      ? new Date(new Date(localDate).getTime() + 24 * 60 * 60 * 1000)
          .toISOString()
          .slice(0, 10)
      : localDate;

  const hh = String(targetHour).padStart(2, "0");
  return fromZonedTime(`${baseDate}T${hh}:00:00`, timezone);
}

await startOnboarding({
  id: "user-123",
  name: "Jane",
  timezone: "America/Los_Angeles",
});
const deliverAt = getNextDeliveryTime("America/Los_Angeles", 9);
```

### Incomplete Step Detection

Track which onboarding steps are incomplete to send targeted reminders:

```typescript
interface OnboardingProgress {
  userId: string;
  steps: {
    id: string;
    title: string;
    completed: boolean;
    completedAt?: Date;
  }[];
}

async function sendStepReminder(progress: OnboardingProgress): Promise<void> {
  const nextStep = progress.steps.find((s) => !s.completed);
  if (!nextStep) return; // All steps complete

  await client.send.message({
    message: {
      to: { user_id: progress.userId },
      template: "nt_01kmrbsv9q2x6c5w1d8n4t7jh",
      data: {
        stepTitle: nextStep.title,
        completedCount: progress.steps.filter((s) => s.completed).length,
        totalSteps: progress.steps.length,
      },
    },
  });
}
```

## Metrics

### Track

- **Activation rate** - % who complete key action
- **Time to activation** - Days from signup to key action
- **Onboarding completion** - % who complete all steps
- **Email engagement** - Opens, clicks on onboarding emails
- **Drop-off points** - Where users abandon

### Benchmarks

| Metric | Good | Great |
|--------|------|-------|
| Day 1 return | 30% | 50%+ |
| Week 1 activation | 20% | 40%+ |
| Onboarding completion | 40% | 60%+ |
| Email open rate | 50% | 70%+ |

## Common Mistakes

### Too Many Emails

**Bad:** 7 emails in first week
**Good:** 3-4 emails, behavioral triggers

### Feature Dumping

**Bad:** "Here are 50 features!"
**Good:** "Do this one thing"

### No Personalization

**Bad:** Same flow for everyone
**Good:** Adapt to user type and behavior

### Missing Exit Conditions

**Bad:** Keep sending even after activation
**Good:** Stop and celebrate success

## Related

- [Journeys](../guides/journeys.md) - Build multi-step onboarding flows as code
- [Account](../transactional/account.md) - Welcome message (transactional)
- [Adoption](./adoption.md) - Feature discovery after onboarding
- [Re-engagement](./reengagement.md) - If onboarding fails
- [Multi-Channel](../guides/multi-channel.md) - Channel routing

````


### `resources/growth/reengagement.md`

````markdown
# Re-engagement Notifications

## Quick Reference

### Rules
- Define "inactive" based on your product's natural usage pattern
- Light nudge at 7 days, stronger at 14, win-back at 30+
- Stop after 2-3 attempts with no response
- Cancel re-engagement sequence when user returns
- Cart abandonment: Transactional at 1h/24h, Marketing (needs opt-in) at 48h+
- Don't use SMS for re-engagement (too intrusive)

### Inactivity Triggers
| User Type | Inactive Period | Action |
|-----------|----------------|--------|
| New (< 7 days) | 3+ days | Redirect to onboarding sequence (not re-engagement) |
| Active | 7 days | Light nudge |
| Active | 14 days | Stronger outreach |
| Active | 30+ days | Win-back campaign |

### Win-Back Sequence
| Day | Message | Focus |
|-----|---------|-------|
| 0 | "We miss you" | Emotional appeal |
| 3 | "Here's what's new" | Value reminder |
| 7 | "Special offer" (optional) | Incentive |
| 14 | "Last chance" | Urgency |
| 30+ | Stop or reduce | Don't burn relationship |

### Common Mistakes
- Too aggressive re-engagement (causes unsubscribes)
- No stop condition (keeps emailing forever)
- Not canceling when user returns
- SMS for re-engagement (high opt-out risk)
- Cart abandonment discount without opt-in (at 48h+)
- Same cadence for all user segments

### Templates

**Light Nudge (7 days, TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbun4x7q1v5d8c2n6w9hj",
    data: {
      lastActivity: "your dashboard",
      whatsNew: ["Dark mode", "API improvements"]
    }
  }
});
```

**Light Nudge (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbun4x7q1v5d8c2n6w9hj",
        "data": {
            "lastActivity": "your dashboard",
            "whatsNew": ["Dark mode", "API improvements"],
        },
    }
)
```

**Cart Abandonment (Transactional - 1hr):**
```typescript
// Use an idempotency key on retry-sensitive transactional paths like this one
// so a retried webhook from your cart-abandonment job cannot double-send.
// Python SDK: pass the header via `extra_headers={"Idempotency-Key": ...}`.
await client.send.message(
  {
    message: {
      to: { user_id: "user-123" },
      template: "nt_01kmrbuw8q2x6v1d4c7n5j9ht",
      data: {
        items: [{ name: "Widget", price: 29.99 }],
        cartUrl: "https://acme.com/cart",
      },
    },
  },
  { headers: { "Idempotency-Key": `cart-abandon-${cartId}` } },
);
```

**Welcome Back (when user returns):**
```typescript
// With Journeys, exit logic is built into the DAG — no external cancel needed.
// See the journey example in the "Journey with Exit Logic" section below.

// Send welcome back
await client.send.message({
  message: {
    to: { user_id: userId },
    content: { title: "Welcome back!", body: "Here's what's new since you left." },
    routing: { method: "single", channels: ["inbox"] }
  }
});
```

---

Win back inactive users and prevent churn.

## When to Re-engage

### Inactivity Detection

Define "inactive" based on your product's natural usage pattern:

| Product Type | Consider Inactive After |
|--------------|------------------------|
| Daily-use app | 3-5 days |
| Weekly tool | 2 weeks |
| Monthly service | 6 weeks |
| Seasonal product | Varies |

```typescript
interface UserActivity {
  userId: string;
  lastActiveAt: Date;
  accountCreatedAt: Date;
  reengagementAttempts: number;
}

type InactivityTier = "nudge" | "outreach" | "winback" | "dormant" | "none";

function getInactivityTier(user: UserActivity): InactivityTier {
  const daysSinceActive = Math.floor(
    (Date.now() - user.lastActiveAt.getTime()) / (1000 * 60 * 60 * 24)
  );
  const isNewUser =
    Date.now() - user.accountCreatedAt.getTime() < 7 * 24 * 60 * 60 * 1000;

  // New users get onboarding help, not re-engagement
  if (isNewUser) return "none";

  // Stop after 2-3 attempts
  if (user.reengagementAttempts >= 3) return "dormant";

  if (daysSinceActive >= 30) return "winback";
  if (daysSinceActive >= 14) return "outreach";
  if (daysSinceActive >= 7) return "nudge";
  return "none";
}
```

### Inactivity Triggers

| User Type | Inactive Period | Action |
|-----------|----------------|--------|
| New user (< 7 days) | 3 days | Onboarding help (not re-engagement) |
| Active user | 7 days | Light nudge |
| Active user | 14 days | Stronger outreach |
| Active user | 30+ days | Win-back campaign |

## Inactivity Nudges

### Light Touch (7 days)

Subject: Picking up where you left off

Include:
- What they were working on
- CTA to continue
- What's new (team updates, new content)

### Stronger Outreach (14 days)

Subject: We miss you, Jane

Include:
- Check-in message
- What happened while they were away (team activity, new features)
- Multiple help options

## Win-Back Campaigns

### When Incentives Make Sense

- User was previously engaged
- User is on free tier or canceled
- High customer lifetime value
- Product improvements address why they left

### Win-Back Sequence

| Day | Message | Focus |
|-----|---------|-------|
| Day 0 | "We miss you" | Emotional appeal |
| Day 3 | "Here's what's new" | Value reminder |
| Day 7 | "Special offer" (optional) | Incentive |
| Day 14 | "Last chance" (optional) | Urgency |
| Day 30 | Stop or reduce frequency | Don't burn relationship |

### Win-Back Email Examples

**Day 0: Emotional appeal**

Subject: We miss you, Jane

Include:
- Check-in message
- Request for feedback
- CTA to return

**Day 3: Value reminder**

Subject: 3 things you might have missed

Include:
- List of improvements/new features
- Most requested features that were added
- CTA to see what's new

**Day 7: Incentive (if appropriate)**

Subject: A little something to welcome you back

Include:
- Discount offer with code
- Expiration date
- CTA to claim discount

### Journey with Exit Logic

Build the win-back sequence as a [Journey](../guides/journeys.md) with branch nodes that check whether the user has returned before each step:

```json
{
  "name": "Win-Back Sequence",
  "nodes": [
    {
      "id": "trigger-1",
      "type": "trigger",
      "trigger_type": "api-invoke",
      "schema": {
        "type": "object",
        "properties": {
          "user_name": { "type": "string" },
          "last_activity": { "type": "string" }
        }
      }
    },
    {
      "id": "throttle-user",
      "type": "throttle",
      "scope": "user",
      "max_allowed": 1,
      "period": "P30D"
    },
    { "id": "send-miss-you", "type": "send", "message": { "template": "<miss-you-template-id>" } },
    { "id": "wait-3d", "type": "delay", "mode": "duration", "duration": "P3D" },
    {
      "id": "check-return",
      "type": "fetch",
      "method": "get",
      "url": "https://api.yourapp.com/users/{{user_id}}/activity",
      "merge_strategy": "overwrite"
    },
    {
      "id": "branch-returned",
      "type": "branch",
      "paths": [
        {
          "label": "User returned",
          "conditions": ["data.returned", "is equal", "true"],
          "nodes": [{ "id": "exit-returned", "type": "exit" }]
        }
      ],
      "default": {
        "nodes": [
          { "id": "send-whats-new", "type": "send", "message": { "template": "<whats-new-template-id>" } },
          { "id": "wait-7d", "type": "delay", "mode": "duration", "duration": "P7D" },
          { "id": "send-last-chance", "type": "send", "message": { "template": "<last-chance-template-id>" } },
          { "id": "exit-end", "type": "exit" }
        ]
      }
    }
  ],
  "enabled": true
}
```

**Invoke when a user becomes inactive:**

```bash
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user-123",
    "data": { "user_name": "Jane", "last_activity": "2026-05-10T12:00:00Z" }
  }'
```

The journey checks whether the user returned before sending additional messages — no external cancel call needed.

### Legacy: Automations with Cancellation

If you have existing Automations:

```typescript
await client.automations.invoke.invokeByTemplate("winback-sequence", {
  recipient: userId,
  data: {
    cancelation_token: `reengagement-${userId}`,
    userName: user.name,
    lastActivity: user.lastActiveAt.toISOString(),
  },
});

// When user returns
await client.automations.invoke.invokeAdHoc({
  recipient: userId,
  automation: {
    steps: [{ action: "cancel", cancelation_token: `reengagement-${userId}` }]
  }
});
```

## Cart Abandonment

### Legal Classification

**Important:** Cart abandonment emails can be transactional OR marketing:

| Content | Classification | Opt-in Required |
|---------|----------------|-----------------|
| "Your cart expires soon" | Transactional | No |
| "Complete your purchase for 10% off" | Marketing | Yes |

### Sequence

| Timing | Type | Content |
|--------|------|---------|
| 1 hour | Transactional | Reminder |
| 24 hours | Transactional | Still there? |
| 48 hours | Marketing (opt-in required) | Incentive |

### Transactional Cart Email

Subject: You left something in your cart

Include:
- Items in cart with images
- CTA to complete order
- Cart expiration time (if applicable)

## Feature Update for Dormant Users

Re-engage by highlighting improvements relevant to their past usage.

Subject: The feature you asked for is here

Include:
- Feature name and description
- How it benefits them specifically
- CTA to try it
- Link to see all updates

## Channel Strategy

| Notification Type | Email | Push | SMS |
|-------------------|-------|------|-----|
| Light nudge (7d) | Yes | Yes | - |
| Stronger outreach (14d) | Yes | - | - |
| Win-back (30d+) | Yes | - | - |
| Cart reminder (1h) | Yes | Yes | - |
| Cart incentive (48h) | Yes | - | - |
| Feature update | Yes | - | - |

**Why not SMS for re-engagement?**
- Higher opt-out risk
- Feels intrusive for non-urgent
- Save for high-value, time-sensitive

## Segmentation

### By Engagement Level

- **Light touch (7-14 days):** Recently engaged, need gentle reminder
- **Moderate (14-30 days):** Need stronger value proposition
- **Win-back (30-90 days):** Consider incentives
- **Dormant (90+ days):** Low-frequency, wait for major updates

### By User Value

Prioritize high-value users:
- High LTV: Personal outreach from team
- Medium LTV: Full win-back sequence
- Low LTV: Light nudge only

## Welcome Back

When a user returns after 14+ days, acknowledge it.

Channel: In-app

Include:
- What's new since they left
- Pending items they should know about

## Metrics

### Track

- **Open rate** by inactivity segment
- **Click-through rate** on CTAs
- **Return rate** - % who come back after notification
- **Retention** - % who stay active after returning
- **Unsubscribe rate** - Monitor carefully

### Benchmarks

| Metric | Target |
|--------|--------|
| Win-back email open rate | 20-30% |
| Return rate | 5-10% |
| Post-return 30-day retention | 30%+ |
| Unsubscribe rate | < 2% |

## Best Practices

### Don't Be Pushy

- Space messages appropriately
- Give genuine value, not just "come back"
- Respect unsubscribes

### Know When to Stop

After 2-3 attempts with no response:
- Reduce frequency significantly
- Wait for product improvements to share
- Don't burn the relationship

### Exit Gracefully

Subject: Is this goodbye?

Include:
- Acknowledgment this is last message for a while
- Option to stay connected
- Promise to only send important updates otherwise

## Related

- [Journeys](../guides/journeys.md) - Build multi-step win-back and cart abandonment flows as code
- [Onboarding](./onboarding.md) - Prevent churn early
- [Engagement](./engagement.md) - Keep users active
- [Campaigns](./campaigns.md) - Promotional win-back offers
- [Preferences](../guides/preferences.md) - Respect opt-outs

````


### `resources/growth/referral.md`

````markdown
# Referral Notifications

## Quick Reference

### Rules
- State rewards clearly: "You both get $10"
- Explain qualification: "When they make a purchase"
- Referrer consented by sharing; referee needs separate consent
- Require qualification action to prevent fraud (purchase, activity)
- Prompt sharing after positive moments (achievement, upgrade)
- Include easy sharing: pre-written messages, multiple channels

### Referral Flow
```
Referrer shares → Referee signs up → Referee qualifies → Both notified
```

### Notification Triggers
| Event | Notify | Channels |
|-------|--------|----------|
| Invite sent | Referrer (confirmation) | In-app only |
| Referee signed up | Referrer | Push + In-app |
| Referee qualified | Both | Email + Push + In-app |
| Milestone reached | Referrer | Email + Push + In-app |
| Leaderboard change | Referrer | Push |

### Common Mistakes
- Unclear reward structure ("refer a friend for rewards!")
- No qualification requirement (fraud abuse)
- Prompting share at wrong time (before user has value)
- Missing easy sharing options (copy link, social buttons)
- Not celebrating milestone achievements
- Reward notification without explaining how to use it

### Templates

**Referral Signed Up (to referrer, TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "referrer-123" },
    content: {
      title: "Bob signed up!",
      body: "They need to make a purchase for you both to earn $10."
    },
    routing: { method: "all", channels: ["push", "inbox"] }
  }
});
```

**Referral Signed Up (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "referrer-123"},
        "content": {
            "title": "Bob signed up!",
            "body": "They need to make a purchase for you both to earn $10.",
        },
        "routing": {"method": "all", "channels": ["push", "inbox"]},
    }
)
```

**Referral Qualified (to referrer):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "referrer-123" },
    template: "nt_01kmrbz4q7x1v5d8c2n6w9hj",
    data: {
      refereeName: "Bob",
      rewardAmount: "$10",
      totalEarned: "$50",
      referralLink: "https://acme.com/refer/abc123"
    },
    routing: { method: "all", channels: ["email", "push", "inbox"] }
  }
});
```

**Share Prompt (after positive moment):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Share the love!",
      body: "You just hit 100 tasks. Invite friends and you both get $10."
    },
    routing: { method: "single", channels: ["inbox"] }
  }
});
```

---

Drive viral growth through referral programs and social sharing.

## Referral Program Types

| Type | Mechanics | Best For |
|------|-----------|----------|
| Two-sided | Both referrer and referee get reward | Consumer apps |
| One-sided | Only referrer gets reward | B2B, high-value |
| Milestone | Rewards unlock at referral counts | Building advocates |
| Leaderboard | Top referrers get special rewards | Competitive users |

## Referral Flow

1. Referrer shares link/code
2. Referee clicks and signs up
3. Referee qualifies (completes action like purchase)
4. Both parties notified of rewards

### Qualification Validation

Always require a qualification action before issuing rewards to prevent fraud:

```typescript
interface Referral {
  referrerId: string;
  referrerName: string;
  refereeId: string;
  refereeEmail: string;
  signedUpAt: Date;
  qualifiedAt?: Date;
  rewardIssuedAt?: Date;
}

async function onRefereeAction(
  referral: Referral,
  action: "signup" | "purchase" | "activation"
): Promise<void> {
  if (action === "signup") {
    // Notify referrer their friend signed up (no reward yet)
    await client.send.message({
      message: {
        to: { user_id: referral.referrerId },
        content: {
          title: `${referral.refereeEmail} signed up!`,
          body: "They need to make a purchase for you both to earn $10.",
        },
        routing: { method: "all", channels: ["push", "inbox"] },
      },
    });
  }

  if (action === "purchase" && !referral.qualifiedAt) {
    // Validate against fraud rules before issuing reward
    const isValid = await validateReferral(referral);
    if (!isValid) return;

    referral.qualifiedAt = new Date();
    await issueReward(referral.referrerId, "$10");
    await issueReward(referral.refereeId, "$10");

    // Notify referrer
    await client.send.message({
      message: {
        to: { user_id: referral.referrerId },
        template: "nt_01kmrbz4q7x1v5d8c2n6w9hj",
        data: {
          refereeName: referral.refereeEmail,
          rewardAmount: "$10",
        },
        routing: { method: "all", channels: ["email", "push", "inbox"] },
      },
    });

    // Notify referee
    await client.send.message({
      message: {
        to: { user_id: referral.refereeId },
        template: "nt_01kmrbz4q7x1v5d8c2n6w9hj",
        data: {
          referrerName: referral.referrerName,
          rewardAmount: "$10",
        },
        routing: { method: "all", channels: ["email", "push", "inbox"] },
      },
    });
  }
}
```

### Basic Fraud Prevention

```typescript
async function validateReferral(referral: Referral): Promise<boolean> {
  // Same IP address as referrer
  const referrerIp = await getSignupIp(referral.referrerId);
  const refereeIp = await getSignupIp(referral.refereeId);
  if (referrerIp === refereeIp) return false;

  // Referrer hit daily/monthly limit
  const recentReferrals = await getReferralCount(referral.referrerId, "30d");
  if (recentReferrals >= 25) return false;

  // Referee signed up too quickly after link click (bot)
  const signupLatency = Date.now() - referral.signedUpAt.getTime();
  if (signupLatency < 5000) return false; // Under 5 seconds

  return true;
}
```

## Inviting / Sharing

### Invite Email

Subject: Jane thinks you'd love Acme

Include:
- Personal intro from referrer
- Quote or message from referrer
- Reward for signing up
- CTA button: "Accept [Name]'s Invite"

### Share Reminder

Prompt users to share after positive moments (milestone, achievement, upgrade).

Channel: In-app

Include:
- Achievement they just reached
- Referral link
- Reward reminder (e.g., "You both get $10")
- Share buttons (Twitter, LinkedIn, Copy Link)

## Reward Notifications

### Referral Signed Up

When referee signs up (but hasn't qualified yet).

Channel: In-app + Push

Include:
- Who signed up
- Status update
- What needs to happen for reward (e.g., "They need to complete a purchase")

Push example: "Bob signed up using your link! They need to make a purchase for you both to earn your $10 credit."

### Referral Qualified

When referee completes qualifying action.

Channel: Email + In-app + Push

**Email:**

Subject: You earned $10! Thanks for referring Bob

Include:
- Reward amount
- How it's applied
- Total earned from referrals
- Referral link to keep sharing

**Push:**

"You earned $10! Bob made a purchase. Your credit has been applied."

### Referee Welcome

Subject: Welcome! Your $10 credit is ready

Include:
- Welcome message mentioning referrer
- Reward amount and that it's applied
- CTA to start exploring
- Their own referral link to continue the loop

## Milestone Programs

### Milestone Achieved

Define milestones with increasing rewards:
- 1 referral: $10 credit
- 5 referrals: Free month
- 10 referrals: Lifetime discount
- 25 referrals: VIP status

Channel: Email + In-app + Push

Subject: 5 referrals! You earned a free month

Include:
- Achievement
- Reward earned
- Total rewards to date
- Next milestone and reward

### Progress Update

Subject: 2 more referrals until your free month!

Include:
- Current count vs target
- Progress bar visual
- CTA to share

## Leaderboard Notifications

### Weekly Leaderboard Update

Subject: You're #12 on the referral leaderboard

Include:
- Current position
- Top referrers
- Prize for top performers
- How many referrals to move up
- CTA to share

### Position Change

When user moves up in leaderboard.

Channel: Push

Example: "You moved up to #8! 2 more referrals and you're in the top 5."

## Social Proof / FOMO

### Others Are Sharing

Subject: Your friends are earning rewards

Include:
- Total rewards earned by referrers this month
- Examples of what others earned
- User's own referral link
- Reward reminder

## Channel Strategy

| Notification | Email | Push | In-App |
|--------------|-------|------|--------|
| Invite sent confirmation | - | - | Yes |
| Referral signed up | Yes | Yes | Yes |
| Reward earned | Yes | Yes | Yes |
| Milestone achieved | Yes | Yes | Yes |
| Leaderboard update | Yes | - | - |
| Position change | - | Yes | - |
| Share prompt | - | - | Yes |

## Best Practices

### Clear Value Proposition

- State rewards clearly: "You both get $10"
- Explain how to earn: "When they make a purchase"
- Show progress: "2 more until free month"

### Make Sharing Easy

- Pre-written share messages
- Multiple channels (email, Twitter, LinkedIn, copy link)
- Mobile-friendly sharing

### Timely Prompts

Share prompts work best after:
- User achieves something
- User has been active for a while
- User upgrades or makes purchase

### Prevent Fraud

- Require qualification action (purchase, activity threshold) before issuing rewards
- Limit referrals per user (e.g., 25/month)
- Detect suspicious patterns (same IP, rapid signups, disposable emails)
- See fraud validation code in [Basic Fraud Prevention](#basic-fraud-prevention) above

## Related

- [Onboarding](./onboarding.md) - Welcome referred users
- [Engagement](./engagement.md) - Activity after referral
- [Campaigns](./campaigns.md) - Promotional referral campaigns

````


### `resources/guides/batching.md`

````markdown
# Notification Batching

## Quick Reference

### Rules
- NEVER batch: OTP, password reset, security alerts, order confirmations
- DO batch: likes, comments, follows, team activity, low-priority alerts
- Time window batching: wait 5-10 minutes before sending
- Actor aggregation: "Jane and 5 others" (not 6 separate notifications)
- Never send empty digests
- Cancel batch if user engages before send

### Batch Decision Matrix
| Notification | Batch? | Strategy |
|--------------|--------|----------|
| OTP/2FA | NO | Immediate |
| Password reset | NO | Immediate |
| Security alert | NO | Immediate |
| Order confirmation | NO | Immediate |
| Likes | YES | 5-10 min window |
| Comments | YES | Group by thread |
| New followers | YES | Daily digest |
| Team activity | YES | Hourly summary |

### Common Mistakes
- Batching time-sensitive notifications (OTP, security)
- Batching too aggressively (users miss important info)
- Sending empty digests
- Not canceling batch when user already engaged
- Same batch window for all notification types
- No way for users to choose digest frequency

### Templates

**Actor Aggregation:** Use `formatActors()` from [Patterns](./patterns.md#actor-aggregation) — formats as "Jane, Bob, and 3 others".

**Batched Send:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Your post is popular!",
      body: "Jane, Bob, and 8 others liked your post"
    }
  }
});
```

---

Combine multiple notifications into single, digestible messages to reduce notification fatigue.

## Why Batch Notifications?

- **Reduce fatigue:** "Jane and 5 others liked your post" is better than 6 separate notifications
- **Improve engagement:** Batched notifications have higher open rates
- **Lower costs:** Fewer sends = lower provider costs
- **Better UX:** Users prefer summaries over interruption storms

## When to Batch

### Good Candidates for Batching

| Notification Type | Batch Strategy |
|-------------------|----------------|
| Social likes | Combine by target (post, comment) |
| Comments on same item | Group by thread |
| New followers | Daily/weekly digest |
| Team activity | Hourly or daily summary |
| Low-priority alerts | Scheduled digest |

### Don't Batch These

| Notification Type | Why Not |
|-------------------|---------|
| OTP/2FA codes | Time-sensitive, security |
| Password resets | Immediate action needed |
| Order confirmations | User expects immediate |
| Security alerts | Urgent, actionable |
| Direct messages | Real-time conversation |

## Batching Strategies

### 1. Time-Window Batching

Collect notifications over a time window, then send a summary.

```
[Event 1] → Queue
[Event 2] → Queue      → [5 min window closes] → Send batch
[Event 3] → Queue
```

**Best for:** Social activity, team updates, non-urgent alerts

**Implementation:**

```bash
# Invoke the batching journey for each event
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "'"$TARGET_USER_ID"'",
    "data": {
      "actorName": "Jane",
      "targetType": "post",
      "targetId": "post-123"
    }
  }'
```

In your [Journey](./journeys.md) DAG:
1. **Throttle node:** Limit to 1 run per user per 5-10 minutes
2. **Delay node:** Wait for the batch window to close
3. **Fetch node:** Pull queued events from your backend (your app queues and aggregates)
4. **Send node:** Send batched notification with aggregated data

### 2. Count-Based Batching

Send after N events accumulate, or after timeout (whichever comes first).

```
[Event 1] → Count: 1
[Event 2] → Count: 2
[Event 3] → Count: 3 → Threshold reached → Send batch
```

**Best for:** High-volume events like likes, views

### 3. Digest Batching

Scheduled summaries at fixed intervals.

| Digest Type | Frequency | Best For |
|-------------|-----------|----------|
| Real-time digest | Every 15-30 min | Active users, important updates |
| Daily digest | Once per day | Activity summaries, newsletters |
| Weekly digest | Once per week | Low-engagement users, recaps |

## Server-Side Batching

### Journeys + Fetch for Aggregation

[Journeys](./journeys.md) can implement batch/digest patterns by combining a **throttle** node (to limit how often a user receives a batch), a **delay** node (to wait for the batch window), and a **fetch** node (to pull aggregated events from your backend). The journey itself does not aggregate events — your application queues the events, and the journey's fetch node retrieves the aggregated data at send time.

```json
{
  "name": "Social Activity Batch",
  "nodes": [
    { "id": "trigger-1", "type": "trigger", "trigger_type": "api-invoke" },
    {
      "id": "throttle-per-user",
      "type": "throttle",
      "scope": "user",
      "max_allowed": 1,
      "period": "PT5M"
    },
    { "id": "wait-5m", "type": "delay", "mode": "duration", "duration": "PT5M" },
    {
      "id": "fetch-batched-events",
      "type": "fetch",
      "method": "get",
      "url": "https://api.yourapp.com/users/{{user_id}}/pending-notifications",
      "merge_strategy": "overwrite"
    },
    { "id": "send-batch", "type": "send", "message": { "template": "<batch-template-id>" } }
  ],
  "enabled": true
}
```

Your application invokes the journey on each event. The throttle node ensures only one run proceeds per user per window. That run waits, fetches aggregated data from your backend, and sends a single batched notification.

```bash
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user-123",
    "data": {
      "event_type": "like",
      "actor_name": "Jane",
      "target_id": "post-789"
    }
  }'
```

> **Note:** Journeys do not have a built-in batch/digest node that aggregates events automatically. The aggregation lives in your application; the journey orchestrates the timing and send. For built-in event aggregation, see the Automations batch step below.

### Automations Batch Step

Courier Automations have a built-in batch step that collects and aggregates events server-side. If you need Courier to handle the aggregation (not just the timing), use Automations:

```typescript
await client.automations.invoke.invokeByTemplate("social-activity-batch", {
  recipient: "user-123",
  data: {
    event_type: "like",
    actor_id: "user-456",
    actor_name: "Jane",
    target_id: "post-789"
  }
});
```

### Batch Data in Templates

Access batched data in your notification template:

```
{{#if batch.is_multiple}}
  {{batch.first.actor_name}} and {{batch.others_count}} others liked your post
{{else}}
  {{batch.first.actor_name}} liked your post
{{/if}}
```

Precompute `is_multiple` (boolean) and `others_count` (batch count minus 1) in your data or automation step, since Handlebars does not support comparison operators or arithmetic.

## Aggregation Patterns

### Actor Aggregation

Combine by who did the action. Use `formatActors()` from [Patterns](./patterns.md#actor-aggregation):

- 1 actor: "Jane liked your post"
- 2 actors: "Jane and Bob liked your post"
- 3+ actors: "Jane, Bob, and 3 others liked your post"

### Target Aggregation

Combine by what was affected:

- "3 comments on your post 'API Design Tips'"
- "New activity on 2 of your projects"

### Type Aggregation

Combine different event types:

- "5 likes and 2 comments on your post"
- "Jane liked your post and started following you"

## Digest Implementation

### Daily Digest Flow

```typescript
// Scheduled job runs daily at 9am user's local time
async function sendDailyDigest(userId: string) {
  // Fetch activity since last digest
  const activity = await getActivitySince(userId, lastDigestTime);
  
  if (activity.length === 0) return; // Don't send empty digests
  
  await client.send.message({
    message: {
      to: { user_id: userId },
      template: "nt_01kmrbtm6q9x3c7v1d5w2n8hj",
      data: {
        likes: activity.filter(a => a.type === 'like').length,
        comments: activity.filter(a => a.type === 'comment').length,
        followers: activity.filter(a => a.type === 'follow').length,
        topItems: getTopItems(activity, 3)
      }
    }
  });
}
```

### User Preference for Digest Frequency

Let users choose their batching preference:

```typescript
// Store digest frequency on profile custom data
const profile = await client.profiles.retrieve(userId);
const digestFrequency = profile.profile?.custom?.digest_frequency ?? "daily";

// Options: "realtime", "daily", "weekly", "off"
if (digestFrequency === "realtime") {
  // Send immediately
} else {
  // Queue for digest
}
```

## Best Practices

### Time Window Selection

| Event Volume | Recommended Window |
|--------------|-------------------|
| < 10/hour | 15-30 minutes |
| 10-50/hour | 5-10 minutes |
| 50+/hour | 2-5 minutes or count-based |

### Don't Batch Too Aggressively

- Users still want timely information
- Direct mentions/replies should be faster than likes
- Consider urgency when setting windows

### Empty Digest Handling

Never send empty digests:

```typescript
if (batchedEvents.length === 0) {
  return; // Skip this digest
}
```

### Batch Cancellation

If user engages before batch sends, consider canceling. With [Journeys](./journeys.md), build a branch node that checks engagement before the send node — the journey exits early if the user already saw the content. See [Patterns — Sequence Cancellation](./patterns.md#sequence-cancellation).

**Legacy: Automations cancellation** (if you have existing Automations):

```typescript
await client.automations.invoke.invokeAdHoc({
  recipient: userId,
  automation: {
    steps: [
      { action: "cancel", cancelation_token: `digest-${userId}-${date}` }
    ]
  }
});
```

## Channel Considerations

| Channel | Batching Approach |
|---------|-------------------|
| In-app | Batch aggressively, users check when ready |
| Push | Moderate batching, respect attention |
| Email | Daily/weekly digests work well |
| SMS | Rarely batch (high cost, high attention) |

## Related

- [Engagement](../growth/engagement.md) - Activity notification patterns
- [Throttling](./throttling.md) - Rate limiting notifications
- [Preferences](./preferences.md) - User frequency preferences
- [Inbox](../channels/inbox.md) - In-app notification batching
- [Journeys](./journeys.md) - Build multi-step batch/digest flows with throttle, delay, and send nodes
- [Automations](https://www.courier.com/docs/automations/overview) - Legacy dashboard-configured batch/digest steps

````


### `resources/guides/catalog.md`

````markdown
# Notification Catalog

> **New to Courier?** Start with [quickstart.md](./quickstart.md) to install the SDK, set your API key, and send your first notification. If you're moving from another notification system, see [migrate-general.md](./migrate-general.md). Use this catalog once you're set up to plan *which* notifications to build.

## Quick Reference

### Rules
- Start with essential transactional notifications first; that usually delivers the biggest reliability and trust gains.
- Add authentication/security notifications next, especially for account and payment-sensitive apps.
- Add engagement and growth notifications based on product stage and user behavior.
- Before adding any notification, ask: Does user need this? Is it actionable?
- Use companion guides based on risk: treat [preferences.md](./preferences.md) as required for consent-sensitive messaging and [reliability.md](./reliability.md) as required for failure-sensitive flows (OTP, billing, security, delivery guarantees).

### Universal Essential Notifications
Every app needs these:
- Email verification
- Password reset
- Security alerts (new login, password changed)
- Payment confirmation (if applicable)
- Payment failed (if applicable)

### Channel Selection by Category
| Category | Primary | Secondary |
|----------|---------|-----------|
| Transactional | Email | Push |
| Authentication | SMS | Email |
| Engagement | Push | In-app |
| Growth | Email | In-app |
| Marketing | Email | Push (opt-in only) |

### Common Mistakes
- Building all notifications at once (start small)
- Skipping essential transactional notifications
- Over-notifying (causes users to disable notifications)
- No security notifications (new login, password change)
- Same channel strategy for all notification types
- Not considering mobile-first (60%+ read on mobile)

### App Type Quick Guide
| App Type | Start With |
|----------|------------|
| SaaS | Welcome, verify, password reset, payment |
| E-commerce | Order confirm, shipped, delivered, payment |
| Social | Verify, password reset, DM, mention |
| Fintech | Transaction, security, password, OTP |
| Healthcare | Appointment confirm, reminders, check-in |

---

Notification recommendations by app type to help you plan what to build.

## How to Use This

1. Find your app type
2. Review essential vs optional notifications
3. Check channel recommendations
4. Use as a starting point - adapt to your needs

### Companion Guides by Situation

| Situation | Companion docs to open | Why |
|----------|-------------------------|-----|
| Security/auth flows (OTP, reset, suspicious activity) | [authentication.md](../transactional/authentication.md), [sms.md](../channels/sms.md), [reliability.md](./reliability.md) | Tightens expiry, channel choice, idempotency, and retry behavior |
| Growth/campaign messaging | [preferences.md](./preferences.md), [throttling.md](./throttling.md), [growth/index.md](../growth/index.md) | Helps prevent over-notification and consent issues |
| Multi-channel production templates | [multi-channel.md](./multi-channel.md), [templates.md](./templates.md), [routing-strategies.md](./routing-strategies.md), [providers.md](./providers.md) | Moves from planning into robust channel routing and provider fallback |
| Multi-step sequences (onboarding, reminders, cart abandonment, win-back) | [journeys.md](./journeys.md), [elemental.md](./elemental.md) | Defines the DAG (delays, branches, sends) as code via the Journeys API |

## Quick Implementation Examples

These examples show how catalog choices map into working notification calls.

### Example 1: SaaS starter set (TypeScript)

```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

// Prioritize essential flows first from this catalog.
// Replace these with your own workspace template IDs (look like `nt_01...`).
const starterNotifications = [
  { template: "nt_01kmrbq6ypf25tsge12qek41r0", idempotency: "verify-user-123" },           // Email verification
  { template: "nt_01kmrbzj3q6x9v2d5c8n1w4ht", idempotency: "password-reset-user-123-req-42" }, // Password reset
  { template: "nt_01kmrc06x1q5v8d2c6n4w9hj", idempotency: "payment-rcpt-inv-887" },         // Payment confirmation
];

for (const n of starterNotifications) {
  await client.send.message(
    {
      message: {
        to: { user_id: "user-123" },
        template: n.template,
        data: {},
      },
    },
    { headers: { "Idempotency-Key": n.idempotency } }
  );
}
```

### Example 2: E-commerce stage-to-channel map (Python)

```python
from courier import Courier

client = Courier()

ORDER_STAGE_CHANNELS = {
    "placed": ["email", "inbox"],
    "shipped": ["email", "push"],
    "out_for_delivery": ["push", "sms"],
}

def notify_order_stage(user_id: str, order_id: str, stage: str):
    channels = ORDER_STAGE_CHANNELS.get(stage, ["email"])
    client.send.message(
        message={
            "to": {"user_id": user_id},
            # Replace with your own workspace template ID (looks like `nt_01...`).
            "template": "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
            "data": {"order_id": order_id, "stage": stage},
            "routing": {"method": "all", "channels": channels},
        },
        extra_headers={"Idempotency-Key": f"order-{order_id}-{stage}"},
    )
```

## SaaS / Subscription Apps

### Essential

| Notification | Category | Channels |
|--------------|----------|----------|
| Welcome email | Account | Email |
| Email verification | Auth | Email |
| Password reset | Auth | Email |
| Payment confirmation | Billing | Email |
| Payment failed | Billing | Email → Push → SMS |
| Subscription renewal reminder | Billing | Email |
| Trial ending | Billing | Email + Push |
| Plan changed | Billing | Email |

### Recommended

| Notification | Category | Channels |
|--------------|----------|----------|
| Onboarding sequence | Growth | Email |
| Feature announcement | Adoption | Email + In-app |
| Usage warning (approaching limit) | Account | Email + In-app |
| Security alert (new login) | Auth | Email + Push |
| Team member invited | Account | Email |
| Weekly digest | Engagement | Email |

### Optional

| Notification | Category | Channels |
|--------------|----------|----------|
| NPS survey | Engagement | Email |
| Referral prompt | Referral | In-app + Email |
| Upgrade promotion | Campaign | Email |

---

## E-Commerce / Marketplace

### Essential

| Notification | Category | Channels |
|--------------|----------|----------|
| Order confirmation | Orders | Email |
| Shipping notification | Orders | Email + Push |
| Delivery update | Orders | Push + SMS |
| Delivery confirmation | Orders | Email + Push |
| Payment receipt | Billing | Email |
| Password reset | Auth | Email |

### Recommended

| Notification | Category | Channels |
|--------------|----------|----------|
| Out for delivery | Orders | Push + SMS |
| Return initiated | Orders | Email |
| Refund processed | Billing | Email |
| Back in stock | Orders | Email + Push |
| Price drop alert | Growth | Email + Push |
| Review request | Engagement | Email |

### Optional

| Notification | Category | Channels |
|--------------|----------|----------|
| Cart abandonment | Re-engagement | Email |
| Wishlist sale | Growth | Email + Push |
| Loyalty points update | Engagement | Email |
| Seasonal sale | Campaign | Email |

---

## Fintech / Banking

### Essential (Higher Security)

| Notification | Category | Channels |
|--------------|----------|----------|
| Transaction confirmation | Billing | Push + Email |
| Large transaction alert | Auth | Push + SMS |
| Password/PIN changed | Auth | Email + Push + SMS |
| New device login | Auth | Email + Push + SMS |
| Account locked | Auth | Email + SMS |
| OTP / 2FA | Auth | SMS |
| Payment failed | Billing | Email + Push |
| Low balance alert | Account | Push + Email |

### Recommended

| Notification | Category | Channels |
|--------------|----------|----------|
| Direct deposit received | Billing | Push |
| Bill due reminder | Billing | Email + Push |
| Card expiring | Account | Email |
| Suspicious activity | Auth | Push + SMS |
| Statement ready | Billing | Email |

### Optional

| Notification | Category | Channels |
|--------------|----------|----------|
| Spending insights | Engagement | Email |
| Budget exceeded | Account | Push |
| New feature (security) | Adoption | Email |

---

## Social / Community Platform

### Essential

| Notification | Category | Channels |
|--------------|----------|----------|
| Email verification | Auth | Email |
| Password reset | Auth | Email |
| Direct message | Engagement | Push + In-app |
| Mention | Engagement | Push + In-app |
| Comment on your post | Engagement | Push + In-app |

### Recommended

| Notification | Category | Channels |
|--------------|----------|----------|
| New follower | Engagement | In-app |
| Post liked (batched) | Engagement | In-app |
| Friend request | Engagement | Push + In-app |
| Friend joined | Engagement | Push + In-app |
| Your post is trending | Engagement | Push |
| Weekly activity digest | Engagement | Email |

### Optional

| Notification | Category | Channels |
|--------------|----------|----------|
| Birthday reminder | Engagement | Push |
| Suggested connections | Growth | In-app |
| Invite friends | Referral | In-app |

---

## Healthcare / Appointments

### Essential

| Notification | Category | Channels |
|--------------|----------|----------|
| Appointment confirmation | Appointments | Email + SMS |
| Appointment reminder (24hr) | Appointments | Email + SMS |
| Appointment reminder (2hr) | Appointments | SMS + Push |
| Appointment rescheduled | Appointments | Email + SMS |
| Appointment cancelled | Appointments | Email + SMS |

### Recommended

| Notification | Category | Channels |
|--------------|----------|----------|
| Appointment reminder (7 day) | Appointments | Email |
| Pre-appointment checklist | Appointments | Email |
| Check-in available | Appointments | SMS + Push |
| Lab results ready | Account | Email + Push |
| Prescription ready | Account | SMS + Push |
| Waitlist availability | Appointments | SMS + Push |

### Optional

| Notification | Category | Channels |
|--------------|----------|----------|
| Health tips | Engagement | Email |
| Annual checkup reminder | Appointments | Email |

**Note:** Healthcare apps should minimize PHI in notifications. Link to secure portal for details.

---

## Developer Tools / API Platform

### Essential

| Notification | Category | Channels |
|--------------|----------|----------|
| Email verification | Auth | Email |
| Password reset | Auth | Email |
| API key created | Auth | Email |
| Payment confirmation | Billing | Email |
| Payment failed | Billing | Email + Push |
| Usage limit warning | Account | Email + In-app |
| Usage limit reached | Account | Email + Push |

### Recommended

| Notification | Category | Channels |
|--------------|----------|----------|
| API key expiring | Auth | Email |
| Deprecation warning | Adoption | Email |
| New API version | Adoption | Email |
| Incident notification | Account | Email + Push |
| Status page updates | Account | Email |
| Build/deploy status | Account | Push + Slack |

### Optional

| Notification | Category | Channels |
|--------------|----------|----------|
| Onboarding tips | Growth | Email |
| Documentation updates | Adoption | Email |
| Community digest | Engagement | Email |

---

## Productivity / Project Management

### Essential

| Notification | Category | Channels |
|--------------|----------|----------|
| Mentioned in task/comment | Engagement | Push + In-app |
| Task assigned to you | Engagement | Push + In-app + Email |
| Task due soon | Engagement | Push + In-app |
| Project shared with you | Account | Email |

### Recommended

| Notification | Category | Channels |
|--------------|----------|----------|
| Task completed | Engagement | In-app |
| Comment added | Engagement | In-app |
| Weekly summary | Engagement | Email |
| Deadline changed | Engagement | Push + In-app |
| Daily agenda | Engagement | Email + Push |

### Optional

| Notification | Category | Channels |
|--------------|----------|----------|
| Productivity insights | Engagement | Email |
| Team activity digest | Engagement | Email |
| Invite team members | Referral | In-app |

---

## Gaming / Entertainment

### Essential

| Notification | Category | Channels |
|--------------|----------|----------|
| Account verification | Auth | Email |
| Password reset | Auth | Email |
| Purchase confirmation | Billing | Email |

### Recommended

| Notification | Category | Channels |
|--------------|----------|----------|
| Friend request | Engagement | Push |
| Game invite | Engagement | Push |
| Achievement unlocked | Engagement | Push + In-app |
| Event starting soon | Engagement | Push |
| Daily reward available | Engagement | Push |
| Streak at risk | Engagement | Push |

### Optional

| Notification | Category | Channels |
|--------------|----------|----------|
| New content available | Adoption | Push |
| Leaderboard update | Engagement | Push |
| Friend activity | Engagement | Push |
| Special offer | Campaign | Push |

---

## General Best Practices

### Start Small

Don't build all notifications at once:
1. Start with essential transactional
2. Add authentication/security
3. Add engagement based on product needs
4. Add growth notifications as you scale

### Channel Selection Summary

| Category | Primary | Secondary |
|----------|---------|-----------|
| Transactional (orders, billing) | Email | Push |
| Authentication (OTP, security) | SMS | Email |
| Engagement (social, activity) | Push | In-app |
| Growth (onboarding, adoption) | Email | In-app |
| Campaigns (marketing) | Email | Push (opt-in) |

### Don't Over-Notify

Before adding a notification, ask:
1. Does the user need to know this?
2. Is it actionable?
3. What's the right urgency?
4. Will it cause fatigue?

## Related

- [Journeys](./journeys.md) - Build multi-step notification sequences (onboarding, reminders, win-back)
- [Multi-Channel](./multi-channel.md) - Channel routing strategies
- [Preferences](./preferences.md) - Let users control what they receive
- [Transactional](../transactional/index.md) - Transactional patterns
- [Growth](../growth/index.md) - Growth notification patterns

````


### `resources/guides/cli.md`

````markdown
# Courier CLI

## Quick Reference

### Rules
- CLI is for ad-hoc operations, debugging, and agent workflows; use SDKs for production app code
- Auth via `COURIER_API_KEY` env var; `--api-key` flag overrides per command
- All commands support `--format json` for machine-readable output
- Resource-based command structure: `courier [resource] <command> [flags]`
- One level of dot-notation for top-level nested args with inline JSON values: `--message.to '{"user_id":"abc"}'`. Two-level dots like `--message.to.user_id "abc"` are **not** supported; pass the full sub-object as a JSON string instead.
- Native binary (Go); no runtime dependencies

### When to Use CLI vs SDK

| Use CLI | Use SDK |
|---------|---------|
| Ad-hoc sends and testing | Production application code |
| Debugging delivery issues | Automated workflows in your app |
| Inspecting messages, profiles, logs | Sending from your backend |
| CI/CD smoke tests | Anything that needs error handling, retries, types |
| Agent workflows (Cursor, Claude Code) | Scheduled or event-driven sends |

### Common Commands

| Task | Command |
|------|---------|
| Send with a template | `courier send message --message.to '{"user_id":"user-123"}' --message.template "nt_01kmrbq6ypf25tsge12qek41r0"` |
| Send inline (no template) | `courier send message --message.to '{"email":"a@b.com"}' --message.content '{"title":"Title","body":"Body"}'` |
| Send to a list | `courier send message --message.to '{"list_id":"beta-testers"}' --message.template "nt_01kmrbq6ypf25tsge12qek41r0"` |
| List recent messages | `courier messages list` |
| Inspect a message | `courier messages retrieve --message-id "1-abc123"` |
| View delivery history | `courier messages history --message-id "1-abc123"` |
| View rendered content | `courier messages content --message-id "1-abc123"` |
| Create a user profile | `courier profiles create --user-id "user-123" --profile '{"email": "a@b.com"}'` |
| Get a user profile | `courier profiles retrieve --user-id "user-123"` |
| Check user preferences | `courier users:preferences retrieve --user-id "user-123"` |
| Invoke a journey (recommended for multi-step flows) | `courier journeys invoke --template-id "$JOURNEY_ID" --user-id "user-123" --data '{"plan":"pro"}'` |
| Create a bulk job | `courier bulk create-job --message '{"event":"monthly-digest","template":"monthly-digest"}'` (`event` is required) |
| List templates | `courier notifications list` |
| Trigger a legacy automation | `courier automations:invoke invoke-by-template --template-id "onboarding-sequence" --recipient "user-123" --data '{}'` (legacy — prefer journeys for new flows) |

### Output Formats

| Format | Description |
|--------|-------------|
| `auto` | Human-readable for terminals, JSON for pipes |
| `json` | JSON output |
| `yaml` | YAML output |
| `pretty` | Colorized, indented JSON |
| `raw` | Raw response body |
| `jsonl` | Newline-delimited JSON (streaming) |

Filter with GJSON: `--transform "results.#.id"`

---

## Installation

**npm** (recommended):

```bash
npm install -g @trycourier/cli
```

Downloads a platform-specific binary via postinstall. No Node.js runtime needed after installation.

**Direct download**:

Download from [GitHub Releases](https://github.com/trycourier/courier-cli/releases) and add to your `PATH`.

Verify:

```bash
courier --version
```

## Authentication

Set the `COURIER_API_KEY` environment variable:

```bash
export COURIER_API_KEY="your-api-key"
```

Override per command with `--api-key`:

```bash
courier messages list --api-key "different-key"
```

## Inline Sends

Send without a pre-built template by passing content directly:

```bash
courier send message \
  --message.to '{"email":"alex@example.com"}' \
  --message.content '{"title":"New comment on your design file","body":"Sara left a comment on Homepage Redesign: '\''Love the new hero section.'\''"}'
```

Route across multiple channels with fallback:

```bash
courier send message \
  --message.to '{"email":"alex@example.com","phone_number":"+15551234567"}' \
  --message.content '{"title":"New login detected","body":"New login from {{city}}, {{country}}. If this wasn'\''t you, reset your password."}' \
  --message.data '{"city": "San Francisco", "country": "US"}' \
  --message.routing '{"method":"single","channels":["email","sms"]}'
```

`method: "single"` tries channels in order and stops at the first successful delivery. Use `"all"` only for critical notifications that must reach every channel.

## User Profiles

Create a profile so you can send by `user_id` instead of passing contact info every time:

```bash
courier profiles create \
  --user-id "user-123" \
  --profile '{"email": "alex@example.com", "phone_number": "+15551234567", "name": "Alex Chen"}'
```

Add custom attributes (merges with existing profile, won't overwrite contact info):

```bash
courier profiles create \
  --user-id "user-123" \
  --profile '{"custom": {"plan": "pro", "company": "Acme Corp", "locale": "en-US"}}'
```

Look up a profile:

```bash
courier profiles retrieve --user-id "user-123" --format json
```

## Lists and Bulk

**Send to a group via lists:**

```bash
courier lists update --list-id "beta-testers" --name "Beta Testers"
courier lists:subscriptions subscribe-user --list-id "beta-testers" --user-id "user-123"

courier send message \
  --message.to '{"list_id":"beta-testers"}' \
  --message.template "feature-announcement" \
  --message.data '{"feature": "Design Studio"}'
```

Target multiple lists with a pattern (e.g., all `eng.*` lists):

```bash
courier send message \
  --message.to '{"list_pattern":"eng.*"}' \
  --message.template "engineering-update"
```

**Bulk sends** for large audiences (product launches, digests):

```bash
courier bulk create-job \
  --message '{"event":"monthly-digest","template":"monthly-digest"}'

courier bulk add-users --job-id "job-abc" \
  --user '{"to":{"user_id":"user-1"},"data":{"highlights":12}}' \
  --user '{"to":{"user_id":"user-2"},"data":{"highlights":7}}'

courier bulk run-job --job-id "job-abc"
courier bulk retrieve-job --job-id "job-abc"
```

> The `event` field is **required** when creating a bulk job (same as the `event` field on `client.bulk.createJob`). Omitting it returns a 400. `event` can be a template alias/slug that also matches your `template`, or any string you use to identify the job. The `--user` flag is singular and repeatable; each value is a full `InboundBulkMessageUser` object (`{"to": { "user_id": ... }, "data": ...}`), not a bare `{"user_id": ...}`.
>
> **Email bulk jobs:** Each user **must include `profile.email`** for email provider routing — `to.email` alone is not sufficient and the message will not deliver. Example user: `--user '{"profile":{"email":"jane@example.com"},"to":{"user_id":"user-1"},"data":{"highlights":12}}'`. SMS/push jobs are similar — put the contact info on `profile`, not `to`.

## Tenants, Journeys, Automations, and Preferences

**Tenants** scope branding and preferences per customer organization (B2B):

```bash
courier tenants update \
  --tenant-id "acme-corp" \
  --name "Acme Corp" \
  --properties '{"brandId": "brand-acme"}'

courier users:tenants add-single --user-id "user-123" --tenant-id "acme-corp"

courier send message \
  --message.to '{"user_id":"user-123","tenant_id":"acme-corp"}' \
  --message.template "welcome-email"
```

> `courier tenants update` is an **upsert** — use it to both create and update a tenant. There is no separate `courier tenants create` command.

**Journeys** are the recommended way to build multi-step notification sequences (delays, branches, throttling). The CLI supports the full journey lifecycle. **Journey-scoped templates** (the templates referenced inside `send` nodes) are not yet in the CLI — use curl/REST for those. See [Journeys](./journeys.md) for the full workflow.

```bash
# Create a journey shell (node ids are server-generated — don't supply your own)
courier journeys create \
  --name "Onboarding" \
  --node '{"type":"trigger","trigger_type":"api-invoke"}'

# Publish the current draft
courier journeys publish --template-id "$JOURNEY_ID"

# Invoke a journey (mirror user_id into data so fetch URL templating works)
courier journeys invoke \
  --template-id "$JOURNEY_ID" \
  --user-id "user-123" \
  --data '{"user_id":"user-123","plan":"pro"}'
```

**Automations** (legacy) — if you have existing Automations, the CLI still supports them:

```bash
courier automations:invoke invoke-by-template \
  --template-id "onboarding-sequence" \
  --recipient "user-123" \
  --data '{"userId": "user-123", "plan": "pro"}'

courier automations list
```

**Preferences** — check what a user has opted into or out of:

```bash
courier users:preferences retrieve --user-id "user-123" --format pretty

courier users:preferences retrieve-topic \
  --user-id "user-123" \
  --topic-id "marketing-updates"
```

## Debugging Workflow

Trace why a notification failed in four steps:

**1. Find the message:**

```bash
courier messages list --format json --transform "results.#(status=UNDELIVERABLE)"
```

`messages list` accepts the filter flags `--status`, `--recipient`, `--notification`, `--event`, `--tag`, `--tenant-id`, `--enqueued-after`, `--cursor`, and `--trace-id`. Note: the flag for looking up messages by a send's `requestId` is `--trace-id`, not `--request-id`. See the [next section](#debugging-list-bulk-sends-requestid-vs-message-id).

**2. Inspect the message:**

```bash
courier messages retrieve --message-id "1-abc123" --format json
```

**3. View delivery history:**

```bash
courier messages history --message-id "1-abc123" --format json
```

The history shows each routing step, provider attempt, and delivery status with timestamps.

**4. View rendered content:**

```bash
courier messages content --message-id "1-abc123" --format json
```

Shows the final content sent to the provider, after template variables and routing logic were applied. Useful for verifying what the recipient actually received.

> There is no `courier messages output` command; the rendered-content command is `courier messages content` (which maps to `client.messages.content(id)` in the SDK).

### Debugging list / bulk sends: requestId vs message id

<a id="debugging-list-bulk-sends-requestid-vs-message-id"></a>

For a single send (`to: { email }` / `to: { user_id }`), the `requestId` returned by `client.send.message` *is* the message ID — pass it straight to `courier messages retrieve --message-id "<requestId>"` and `client.messages.retrieve(requestId)`.

For **list**, **list-pattern**, **audience**, or **bulk** sends, the `requestId` is the *job* ID, which fans out to one message per recipient. Passing the job's requestId to `messages retrieve` returns `404 Message not found`. Instead:

```bash
# 1. From a list/bulk send, you have the job requestId (e.g. 1-69e16217-36dbd96d7abbe2ccc14cb989).
# 2. Find every per-recipient message the job generated:
courier messages list --trace-id "1-69e16217-36dbd96d7abbe2ccc14cb989" --format json

# 3. For each row, the `id` is the per-channel message ID:
courier messages list --trace-id "1-69e16217-36dbd96d7abbe2ccc14cb989" \
  --format json --transform "results.#.id"

# 4. Use those IDs with retrieve / history / content:
courier messages retrieve --message-id "1-abc123" --format json
courier messages history  --message-id "1-abc123" --format json
courier messages content  --message-id "1-abc123" --format json
```

In the SDKs the same distinction applies: `client.messages.list({ traceId: "<requestId>" })` (Node) / `client.messages.list(trace_id="<requestId>")` (Python) returns the per-recipient messages; each has an `id` you can pass to `client.messages.retrieve`.

For bulk jobs specifically, the job-level aggregates (`enqueued`, `received`, `failures`, `status`) are on `GET /bulk/{jobId}` (`client.bulk.retrieveJob` / `retrieve_job`), not on `messages.retrieve`.

## Machine-Readable Output

Every command supports `--format json`. Combine with `--transform` (GJSON syntax) to extract specific fields:

```bash
# Get just message IDs
courier messages list --format json --transform "results.#.id"

# Get the status of a specific message
courier messages retrieve --message-id "1-abc123" --format json --transform "status"

# List template names. V2 templates expose `name`; legacy templates expose `title`.
# Pull both and use whichever is populated.
courier notifications list --format json --transform "results.#.{id:id,name:name,title:title}"
```

## Zero-Config Agent Pattern

AI agents in Cursor, Claude Code, Codex, and similar environments can invoke the CLI directly via shell. No MCP server, API keys in code, or SDK setup required.

**Setup** (once per machine):

```bash
npm install -g @trycourier/cli
export COURIER_API_KEY="your-api-key"
```

**Then agents can run:**

```bash
courier send message \
  --message.to '{"user_id":"user-123"}' \
  --message.template "nt_01kmrbq6ypf25tsge12qek41r0" \
  --message.data '{"orderId": "12345"}'

courier messages list --format json
courier profiles retrieve --user-id "user-123" --format json
```

The `--format json` flag makes output parseable by agents without additional processing.

## CI/CD Integration

Use the CLI in automated pipelines for smoke tests during deployment:

```bash
export COURIER_API_KEY="$COURIER_TEST_KEY"

courier send message \
  --message.to '{"user_id":"smoke-test-user"}' \
  --message.template "welcome"
```

Store API keys as secrets in your CI provider (GitHub Actions secrets, GitLab CI variables, etc.).

## Global Flags

| Flag | Description |
|------|-------------|
| `--api-key` | Override `COURIER_API_KEY` for this command |
| `--base-url` | Use a custom API URL |
| `--format` | Output format (`auto`, `json`, `yaml`, `pretty`, `raw`, `jsonl`) |
| `--transform` | Filter output with GJSON syntax |
| `--debug` | Show HTTP request/response details |
| `--version`, `-v` | Print CLI version |
| `--help` | Show command usage |

## Related

- [Quickstart](./quickstart.md) - Send your first notification
- [Patterns](./patterns.md) - Reusable code patterns (includes CLI examples)
- [Reliability](./reliability.md) - Idempotency and retry logic
- [Multi-Channel](./multi-channel.md) - Routing strategies

Source code: [trycourier/courier-cli](https://github.com/trycourier/courier-cli)
Documentation: [courier.com/docs/tools/cli](https://www.courier.com/docs/tools/cli)

````


### `resources/guides/elemental.md`

````markdown
# Elemental Content Format

Elemental is Courier's JSON-based templating language. It defines the `content` payload used in both inline sends and stored templates.

> **Where to read what:** This file is the element-by-element reference. For the **template lifecycle** (create, publish, version, archive via `/notifications`) and inline-vs-templated decision, see [Templates](./templates.md). For a full end-to-end example combining both, see the "Full Lifecycle Example" section in [Templates](./templates.md).

## Quick Reference

### Rules
- Every Elemental payload has exactly two required top-level fields: `version` and `elements`.
- `version` is always `"2022-01-01"` (the only supported version).
- The shorthand `{ title, body }` (ElementalContentSugar) only works for **inline sends** — never for template creation via the API.
- When `channel` elements appear at the top level, **every** top-level sibling must also be a `channel` element.
- Control flow (`if`, `loop`, `ref`, `channels`) works on any element type.

### Common Mistakes
- Nesting `channel` elements inside other `channel` elements (they must be top-level siblings).
- Using Sugar `{ title, body }` inside `POST /notifications` payloads (the API expects the full `version` + `elements` form).
- The `text` element supports an `align` property (`"left"`, `"center"`, `"right"`). It defaults to `"left"` when omitted, but including it explicitly is recommended to avoid ambiguity across renderers.
- Using `loop` without a `group` wrapper when you need to repeat multiple elements per item.
- Placing `raw` provider payloads outside a `channel` element.

---

## Structure

Every Elemental template has two required fields:

```json
{
  "version": "2022-01-01",
  "elements": []
}
```

- `version` — always `"2022-01-01"` (the only supported version)
- `elements` — array of element objects

### ElementalContentSugar (Inline Sends Only)

For simple inline sends, use the shorthand:

```json
{
  "title": "Welcome!",
  "body": "Thanks for signing up, {{name}}."
}
```

Courier auto-converts this to a `meta` element (title) and a `text` element (body). This format does **not** work when creating templates via `POST /notifications` — use the full `version` + `elements` structure.

### Base Element Properties

All element types share these optional properties:

| Property | Type | Description |
|----------|------|-------------|
| `channels` | `string[]` | Restrict this element to specific channels (e.g., `["email", "push"]`) |
| `ref` | `string` | Tag the element with a name for cross-element references |
| `if` | `string` | Conditional expression — element renders only when truthy |
| `loop` | `string` | Path to an array — element renders once per item |

---

## Element Types

### meta

Sets the notification title (email subject line, push notification title).

```json
{ "type": "meta", "title": "Order #{{order_id}} Confirmed" }
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `title` | `string` | No | Title displayed by channels that support it |

### text

Body text content with optional formatting.

```json
{ "type": "text", "content": "Hi {{name}}, welcome to the platform.", "align": "left" }
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `content` | `string` | Yes | Text content (supports `{{variables}}`) |
| `align` | `"left"` \| `"center"` \| `"right"` | Yes | Text alignment |
| `text_style` | `"text"` \| `"h1"` \| `"h2"` \| `"subtext"` | No | Heading level or subtext |
| `format` | `"markdown"` | No | Enable markdown rendering (`**bold**`, `*italic*`, links) |
| `color` | `string` | No | CSS color value |
| `bold` | `string` | No | Apply bold |
| `italic` | `string` | No | Apply italic |
| `strikethrough` | `string` | No | Apply strikethrough |
| `underline` | `string` | No | Apply underline |

**Heading example:**
```json
{ "type": "text", "content": "Order Summary", "text_style": "h1", "align": "left" }
```

**Markdown example:**
```json
{ "type": "text", "content": "**Important:** Your trial ends in {{days}} days.", "format": "markdown", "align": "left" }
```

### action

Clickable button or link.

```json
{
  "type": "action",
  "content": "Reset Password",
  "href": "https://example.com/reset?token={{token}}",
  "style": "button",
  "align": "center",
  "background_color": "#1a73e8"
}
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `content` | `string` | Yes | Button/link label |
| `href` | `string` | Yes | Target URL |
| `action_id` | `string` | No | Unique ID for tracking clicks |
| `style` | `"button"` \| `"link"` | No | Render as button (default) or text link |
| `align` | `"center"` \| `"left"` \| `"right"` \| `"full"` | No | Alignment (default: `"center"`) |
| `background_color` | `string` | No | Button background CSS color |

### image

Embedded image with optional link.

```json
{
  "type": "image",
  "src": "https://example.com/product.jpg",
  "altText": "Product photo",
  "width": "300px",
  "href": "https://example.com/products/123",
  "align": "center"
}
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `src` | `string` | Yes | Image URL |
| `href` | `string` | No | Link URL when image is clicked |
| `altText` | `string` | No | Alt text for accessibility |
| `width` | `string` | No | CSS width (e.g., `"300px"`, `"50%"`) |
| `align` | `"center"` \| `"left"` \| `"right"` \| `"full"` | No | Image alignment |

### channel

Channel-specific content branches. When present at the top level, **all** sibling elements must also be `channel` elements.

```json
{
  "type": "channel",
  "channel": "email",
  "elements": [
    { "type": "meta", "title": "Order #{{order_id}} Confirmed" },
    { "type": "text", "content": "Full order details with images and tracking.", "align": "left" },
    { "type": "action", "content": "Track Order", "href": "{{tracking_url}}" }
  ]
}
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `channel` | `string` | Yes | Channel name: `"email"`, `"push"`, `"sms"`, `"direct_message"`, or a provider like `"slack"` |
| `elements` | `array` | No | Nested elements for this channel |
| `raw` | `object` | No | Raw provider-specific payload (required if `elements` is omitted) |

**Multi-channel example:**
```json
{
  "version": "2022-01-01",
  "elements": [
    {
      "type": "channel",
      "channel": "email",
      "elements": [
        { "type": "meta", "title": "Order #{{order_id}} Confirmed" },
        { "type": "text", "content": "Hi {{name}}, here are your full order details...", "align": "left" },
        { "type": "image", "src": "{{product_image}}", "altText": "{{product_name}}" },
        { "type": "action", "content": "Track Order", "href": "{{tracking_url}}" }
      ]
    },
    {
      "type": "channel",
      "channel": "sms",
      "elements": [
        { "type": "text", "content": "Order #{{order_id}} confirmed! Track: {{tracking_url}}", "align": "left" }
      ]
    },
    {
      "type": "channel",
      "channel": "push",
      "elements": [
        { "type": "meta", "title": "Order Confirmed" },
        { "type": "text", "content": "Your order #{{order_id}} is confirmed.", "align": "left" }
      ]
    }
  ]
}
```

### divider

Visual separator between content sections.

```json
{ "type": "divider" }
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `color` | `string` | No | CSS color for the line (e.g., `"#eee"`) |

### quote

Blockquote for highlighted text or testimonials.

```json
{
  "type": "quote",
  "content": "The best notification platform we've used.",
  "borderColor": "#1a73e8",
  "text_style": "text"
}
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `content` | `string` | Yes | Quote text |
| `align` | `"center"` \| `"left"` \| `"right"` \| `"full"` | No | Alignment |
| `borderColor` | `string` | No | CSS border color |
| `text_style` | `"text"` \| `"h1"` \| `"h2"` \| `"subtext"` | Yes | Text styling |

### group

Container element for applying control flow (`if`, `loop`) to multiple elements at once.

```json
{
  "type": "group",
  "if": "data.items.length > 0",
  "elements": [
    { "type": "text", "content": "Your items:", "text_style": "h2", "align": "left" },
    { "type": "divider" }
  ]
}
```

A `group` renders its `elements` array but adds no visual output itself.

### columns / column

Multi-column layouts for email and other rich channels.

```json
{
  "type": "columns",
  "elements": [
    {
      "type": "column",
      "width": "40%",
      "elements": [
        { "type": "image", "src": "{{product_image}}", "altText": "Product" }
      ]
    },
    {
      "type": "column",
      "width": "60%",
      "elements": [
        { "type": "text", "content": "**{{product_name}}**", "format": "markdown", "align": "left" },
        { "type": "text", "content": "${{price}}", "align": "left" }
      ]
    }
  ]
}
```

`columns` contains `column` children. Each `column` has a `width` (CSS percentage or pixel value) and its own `elements` array.

### list / list-item

Ordered and unordered lists with nesting support (up to 5 levels deep).

```json
{
  "type": "list",
  "elements": [
    { "type": "list-item", "content": "Email notifications configured" },
    { "type": "list-item", "content": "SMS provider connected" },
    { "type": "list-item", "content": "Push tokens registered" }
  ]
}
```

### html

Raw HTML content for custom formatting not available through other element types. Primarily renders in email.

```json
{
  "type": "html",
  "content": "<table style=\"width:100%\"><tr><td>Item</td><td>Qty</td><td>Price</td></tr></table>"
}
```

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `content` | `string` | Yes | Raw HTML markup |

### jsonnet

Programmatic content generation using Jsonnet templates. Useful for complex Slack Block Kit or Teams Adaptive Card payloads.

```json
{
  "type": "jsonnet",
  "content": "local data = std.extVar('data');\n{ blocks: [{ type: 'section', text: { type: 'mrkdwn', text: '*Order ' + data.order_id + '*' } }] }"
}
```

### comment

Non-rendered documentation within templates. Comments are stripped from the final output.

```json
{ "type": "comment", "content": "Stripped at render time — use for template author notes, like 'add product image after launch'" }
```

---

## Control Flow

Control flow properties work on any element type, including `group` containers.

### Conditional Rendering (`if`)

The `if` expression is evaluated as **Jsonnet** against the send `data` object (not JavaScript). Use Jsonnet equality (`==`, `!=`), logical operators (`&&`, `||`, `!`), and string literals in single quotes. The other common pitfall is triple-equals (`===`) — that's JavaScript-only and will fail to parse.

```json
{
  "type": "text",
  "content": "As a premium member, you get early access.",
  "align": "left",
  "if": "data.tier == 'premium'"
}
```

```json
{
  "type": "action",
  "content": "Upgrade to Premium",
  "href": "https://example.com/upgrade",
  "if": "data.tier != 'premium'"
}
```

```json
{
  "type": "text",
  "content": "Reminder: your trial ends soon.",
  "align": "left",
  "if": "data.trial == true && data.days_left <= 3"
}
```

### Iteration (`loop`)

Repeat an element for each item in an array. Use `{{$.item}}` for the current item and `{{$.index}}` for the zero-based index:

```json
{
  "type": "group",
  "loop": "data.products",
  "elements": [
    {
      "type": "text",
      "content": "{{$.item.name}} — ${{$.item.price}}",
      "align": "left"
    }
  ]
}
```

### Element References (`ref`)

Tag an element and reference it from another:

```json
[
  { "type": "text", "content": "Welcome back!", "align": "left", "ref": "greeting" },
  {
    "type": "text",
    "content": "Since you're here, check out what's new.",
    "align": "left",
    "if": "refs.greeting.visible"
  }
]
```

### Channel Filtering (`channels`)

Show an element only on specific channels without using a `channel` container:

```json
{
  "type": "image",
  "src": "https://example.com/banner.jpg",
  "altText": "Welcome banner",
  "channels": ["email"]
}
```

---

## Localization

Elements that support text content (`text`, `action`, `quote`, `meta`) accept a `locales` property for multi-language content. Courier serves the right locale based on the recipient's profile.

```json
{
  "type": "text",
  "content": "Welcome, {{name}}!",
  "align": "left",
  "locales": {
    "es": { "content": "¡Bienvenido, {{name}}!" },
    "fr": { "content": "Bienvenue, {{name}} !" },
    "de": { "content": "Willkommen, {{name}}!" }
  }
}
```

The `meta` element carries `title` (used as the email subject and push/chat title), so its `locales` entries override `title` — not `content`:

```json
{
  "type": "meta",
  "title": "Your order has shipped",
  "locales": {
    "es": { "title": "Tu pedido ha sido enviado" },
    "fr": { "title": "Votre commande a été expédiée" }
  }
}
```

For full localization setup, see the official [Locales](https://www.courier.com/docs/platform/content/elemental/locales) docs and the [Translations API](https://www.courier.com/docs/api-reference/translations/get-a-translation) for workspace-wide string management.

## Related

- [Templates](./templates.md) — template lifecycle (create, publish, version, archive) and inline-vs-templated decisions
- [Multi-Channel](./multi-channel.md) — routing strategies for the top-level `channel` elements
- [Quickstart](./quickstart.md) — send your first notification
- [Elemental Overview](https://www.courier.com/docs/platform/content/elemental/elemental-overview) — official reference
- [Elements Reference](https://www.courier.com/docs/platform/content/elemental/elements/index) — complete element type reference

````


### `resources/guides/journeys.md`

````markdown
# Journeys

Build multi-step notification workflows as code using directed acyclic graphs (DAGs). A journey is a sequence of nodes — send, delay, branch, fetch, throttle, batch, add-to-digest, AI, and exit — that Courier executes asynchronously when you invoke the journey via API or a Segment event.

> **Journeys are the recommended way to build multi-step flows.** If you have existing Courier Automations, they continue to work — see [Migrating from Automations](#migrating-from-automations) at the bottom of this file.

## Quick Reference

### Rules
- A journey must have at least one `trigger` node — it is the entry point for all runs
- Journey-scoped templates are **not** workspace templates — they live under `POST /journeys/{id}/templates` and cannot be referenced from the Send API or shared across journeys
- Journeys must be **published** before they can be invoked — draft changes are not executed
- `PUT /journeys/{id}` is a **full replacement** of the draft — include all nodes, not just the ones you changed
- `POST /journeys/{id}/invoke` returns `202` with a `runId` — processing is asynchronous
- **Node `id`s are server-generated.** Do **not** send client-supplied node `id`s on `POST /journeys` — they're rejected with `400` (`client-supplied node ids are not allowed`). On `PUT /journeys/{id}` (replace) `id`s are accepted and preserved, but they're optional. Branch paths nest their child nodes inline, so you don't need `id`s to wire the graph at all.
- Elemental version string for journey-scoped templates is always `"2022-01-01"` — see [Elemental](./elemental.md)
- Delay durations use ISO 8601 format (e.g., `"PT1H"` for one hour, `"PT30M"` for 30 minutes, `"P1D"` for one day)
- Conditions use **string** tuples: `[path, operator, value]` for binary, `[path, operator]` for unary. A single condition is the bare tuple; multiple conditions use an `{ "AND": [...] }` / `{ "OR": [...] }` object. Comparison values are always strings (`"true"`, `"50"`), not native booleans/numbers — see [Conditions](#conditions)
- Header/value interpolation differs by context: templates and fetch URLs use `{{field}}` (no prefix); branch/trigger conditions use `data.field`; fetch **header values** use the `$ref` object form `{ "$ref": "data.token" }` — see [Variable Interpolation](#variable-interpolation)

### Common Mistakes
- Forgetting to publish after creating or updating the journey (invoke uses the last published version, not the draft)
- Referencing workspace template IDs (`nt_...`) in send nodes — send nodes require journey-scoped template IDs created under `POST /journeys/{id}/templates`
- Creating send nodes before creating the journey-scoped templates they reference (the template must exist to wire its ID)
- Omitting the trigger node when replacing a journey via `PUT` (every journey needs at least one trigger)
- Using `POST /journeys/{id}/invoke` on an unpublished journey — returns an error; publish first
- Assuming the trigger `schema` rejects bad payloads at invoke — it does **not**. The `schema` powers editor autofill and variable hints only; missing fields are not rejected at invocation. A run proceeds until it reaches a node that references a missing field, then fails there. Use **trigger `conditions`** to gate invocation (a failed trigger condition returns `422`)
- Including `send` nodes in the `POST /journeys` body — send nodes are **not allowed on create**. Create the shell (trigger only), add templates, then add send nodes via `PUT /journeys/{id}`
- Wrapping a single condition in an extra array (`[[...]]`) or using non-string values — a single condition is a bare tuple of strings (`["data.plan", "is equal", "pro"]`); use an `{ "AND": [...] }` / `{ "OR": [...] }` object for multiple conditions

### SDK shape — Journey management

> **Note:** The API reference uses `{templateId}` as the path parameter name for the journey ID. This is the journey's own ID (returned from `client.journeys.create`), not a notification template ID. This guide uses `{id}` for clarity.

Journey management is supported by the Node and Python SDKs and the CLI. Use the SDK in application code; use curl/CLI for ad-hoc work.

| Operation | Node | Python | CLI |
|-----------|------|--------|-----|
| Create | `client.journeys.create({ name, nodes, enabled })` | `client.journeys.create(name=..., nodes=..., enabled=...)` | `courier journeys create --name ... --node '{...}'` |
| List | `client.journeys.list()` | `client.journeys.list()` | `courier journeys list` |
| Retrieve | `client.journeys.retrieve(id)` | `client.journeys.retrieve(id)` | `courier journeys retrieve --template-id ID` |
| Replace (draft) | `client.journeys.replace(id, { name, nodes, enabled })` | `client.journeys.replace(id, name=..., nodes=...)` | `courier journeys replace --template-id ID ...` |
| Archive | `client.journeys.archive(id)` | `client.journeys.archive(id)` | `courier journeys archive --template-id ID` |
| List versions | `GET /journeys/{id}/versions` (REST) | `GET /journeys/{id}/versions` (REST) | `courier journeys versions --template-id ID` |
| Publish | `client.journeys.publish(id)` | `client.journeys.publish(id)` | `courier journeys publish --template-id ID` |
| Invoke | `client.journeys.invoke(id, { user_id, data, profile })` → `{ runId }` | `client.journeys.invoke(template_id=id, user_id=..., data=..., profile=...)` → `.run_id` | `courier journeys invoke --template-id ID --user-id user-123 --data '{...}'` |

### SDK shape — Journey-scoped templates

Journey-scoped template CRUD is **not** in the SDK or MCP yet — use REST/curl. (Workspace templates under `/notifications` do have full SDK support — see [templates.md](./templates.md).)

| Operation | REST |
|-----------|------|
| Create | `POST /journeys/{id}/templates` |
| List | `GET /journeys/{id}/templates` |
| Retrieve | `GET /journeys/{id}/templates/{templateId}` |
| Replace | `PUT /journeys/{id}/templates/{templateId}` |
| Archive | `DELETE /journeys/{id}/templates/{templateId}` |
| Publish | `POST /journeys/{id}/templates/{templateId}/publish` |
| List versions | `GET /journeys/{id}/templates/{templateId}/versions` |

---

## Concepts

### Journey Structure

A journey is a directed acyclic graph (DAG) of nodes. Each node performs one action (send a notification, wait, branch, fetch data, throttle, run an LLM prompt, or exit), and the array order defines execution sequence.

```
[Trigger] → [Send Welcome] → [Delay 1 day] → [Branch: setup complete?]
                                                   ├─ Yes → [Send Success] → [Exit]
                                                   └─ No  → [Send Reminder] → [Delay 2 days] → [Send Nudge] → [Exit]
```

### Triggers

Every journey starts with a trigger node. Two types:

| Trigger type | How runs begin | Required fields |
|-------------|----------------|-----------------|
| `api-invoke` | You call `POST /journeys/{id}/invoke` | None beyond discriminators. Optional: `schema` (JSON Schema for editor autofill/variable hints — **not** invoke-time validation), `conditions` (gate invocation; failed condition → `422`). |
| `segment` | A matching Segment event arrives | `request_type` (`identify`, `group`, or `track`). Optional: `event_id`, `conditions`. For `track` events, the event `userId` must be a valid Courier Profile ID or the journey won't start. |

### Journey-Scoped vs Workspace Templates

| | Journey-scoped | Workspace |
|--|----------------|-----------|
| Created via | `POST /journeys/{id}/templates` | `POST /notifications` or Design Studio |
| Used from | Send nodes within the journey | Send API (`client.send.message`) |
| Shareable | No — exclusive to one journey | Yes — any send can reference them |
| Content format | [Elemental](./elemental.md) (`version` + `elements`) | Elemental or Design Studio |
| Publishable | Independently or with journey publish | Via `notifications.publish` |

Journey-scoped templates are published **automatically** when you publish the journey itself. You can also publish them independently via `POST /journeys/{id}/templates/{templateId}/publish` if you need to update a template without republishing the entire journey.

If you need a template reusable across journeys or callable from the Send API, use a workspace template. If the template is specific to one journey, keep it scoped.

---

## Standard Workflow

Every journey follows the same five-step process: create the shell, add templates, wire them into the DAG, publish, and invoke.

### Step 1: Create the journey shell

Create a journey with a name and at least a trigger node.

```bash
curl -sS -X POST "https://api.courier.com/journeys" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome Journey",
    "nodes": [
      {
        "type": "trigger",
        "trigger_type": "api-invoke",
        "schema": {
          "type": "object",
          "properties": {
            "first_name": { "type": "string" },
            "company_name": { "type": "string" },
            "dashboard_url": { "type": "string" }
          },
          "required": ["first_name"]
        }
      }
    ],
    "enabled": true
  }'
```

> Do **not** include client-supplied node `id`s on create — the server generates them (and returns `400` if you send your own). `send` nodes are not allowed on `POST /journeys` either. Create the shell with the trigger (and other non-send nodes) only; add send nodes later via `PUT` once their templates exist. To publish immediately on create, pass `"state": "PUBLISHED"` (defaults to `"DRAFT"`).

Create returns `201` with the journey. The response echoes back **server-generated** node `id`s (e.g. `"PK5BA6NV424BAYN58R6CVM2GTH10"`). Save the top-level journey `id` — you'll use it in every subsequent request:

```json
{
  "id": "3ac3b1ba-5910-4954-9871-99e601d77bb8",
  "name": "Welcome Journey",
  "state": "DRAFT",
  "enabled": true,
  "nodes": [ { "id": "PK5BA6NV424BAYN58R6CVM2GTH10", "type": "trigger", "trigger_type": "api-invoke" } ],
  "created": 1715000000000,
  "creator": null,
  "updated": 1715000000000,
  "updater": null,
  "published": null
}
```

### Step 2: Create journey-scoped templates

Create the notification templates your send nodes will reference. Content uses [Elemental](./elemental.md) format.

```bash
JOURNEY_ID="<id from step 1>"

curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/templates" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "notification": {
      "name": "Welcome Email",
      "tags": [],
      "brand": null,
      "subscription": null,
      "content": {
        "version": "2022-01-01",
        "elements": [
          { "type": "meta", "title": "Welcome to {{company_name}}, {{first_name}}!" },
          { "type": "text", "content": "Hi {{first_name}}, thanks for signing up. We are excited to have you on board." },
          { "type": "text", "content": "Here are a few things to get you started:" },
          { "type": "action", "content": "Go to your dashboard", "href": "{{dashboard_url}}" }
        ]
      }
    }
  }'
```

Save the template `id` from the response.

### Step 3: Wire templates into the journey

Replace the journey draft with your full node graph, referencing template IDs from step 2.

```bash
TEMPLATE_ID="<id from step 2>"

curl -sS -X PUT "https://api.courier.com/journeys/$JOURNEY_ID" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome Journey",
    "nodes": [
      {
        "id": "trigger-1",
        "type": "trigger",
        "trigger_type": "api-invoke",
        "schema": {
          "type": "object",
          "properties": {
            "first_name": { "type": "string" },
            "company_name": { "type": "string" },
            "dashboard_url": { "type": "string" }
          },
          "required": ["first_name"]
        }
      },
      {
        "id": "send-welcome",
        "type": "send",
        "message": {
          "template": "'"$TEMPLATE_ID"'"
        }
      }
    ],
    "enabled": true
  }'
```

### Step 4: Publish

Lock in the current draft as a versioned snapshot. All new runs execute against the published version.

```bash
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/publish" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json"
```

### Step 5: Invoke

Start a run. The journey must be **published** first. You can invoke by journey **ID or alias**. Provide **either** `user_id` **or** a `profile` with contact info (Courier can also resolve the recipient from `user_id`/`userId`/`anonymousId` inside `profile` or `data`). Returns `202` with a `runId`. Courier processes the run asynchronously, walking through the DAG. The `runId` is what you look up in Run Inspection.

```json
{ "runId": "1-65f240a0-47a6a120c8374de9bcf9f22c" }
```

> **Tip:** If any downstream `fetch` node references `{{user_id}}` in its URL, also include `user_id` inside `data` — the top-level `user_id` is used for recipient resolution, but Courier does not guarantee it is projected into `data` for variable interpolation. Passing it both places is the safe default.

**Recipient resolution & profiles:**
- **Profile-only** (no stored Courier user): pass `profile` with contact info and omit `user_id`.
- **Profile merge:** when you pass both `user_id` and `profile`, request fields override stored profile fields with the same key; other stored fields are preserved.
- **Tenant-scoped profile** (multi-tenant): pass `profile.context.tenant_id` to load the user's tenant-scoped profile:

```json
{
  "user_id": "doctor-smith",
  "profile": { "context": { "tenant_id": "hospital-a" } },
  "data": { "report_date": "2026-01-15" }
}
```

**Node:**
```typescript
const { runId } = await client.journeys.invoke(JOURNEY_ID, {
  user_id: "user_abc123",
  profile: { email: "alice@example.com" },
  data: {
    user_id: "user_abc123", // mirror for fetch URL templating
    first_name: "Alice",
    company_name: "Acme Corp",
    dashboard_url: "https://app.acme.com/dashboard",
  },
});
```

**Python:**
```python
response = client.journeys.invoke(
    template_id=JOURNEY_ID,
    user_id="user_abc123",
    profile={"email": "alice@example.com"},
    data={
        "user_id": "user_abc123",
        "first_name": "Alice",
        "company_name": "Acme Corp",
        "dashboard_url": "https://app.acme.com/dashboard",
    },
)
run_id = response.run_id
```

**curl:**
```bash
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user_abc123",
    "profile": { "email": "alice@example.com" },
    "data": {
      "user_id": "user_abc123",
      "first_name": "Alice",
      "company_name": "Acme Corp",
      "dashboard_url": "https://app.acme.com/dashboard"
    }
  }'
```

---

## Node Types Reference

### Summary

| Node type | Description |
|-----------|-------------|
| `trigger` | Entry point. Discriminated by `trigger_type`: `api-invoke` or `segment`. |
| `send` | Send a notification using a journey-scoped template. References the template via `message.template`. |
| `delay` | Pause the run. Discriminated by `mode`: `duration` (ISO 8601) or `until` (timestamp). |
| `fetch` | Make an HTTP request and merge the response into run state. Discriminated by `method`: `get`, `delete`, `post`, or `put`. |
| `branch` | Conditional routing. Evaluates `paths[]` in order and routes to the first match, with a `default` fallback. |
| `throttle` | Rate-limit runs. Discriminated by `scope`: `user`, `global`, or `dynamic`. |
| `batch` | Collect multiple events into one aggregated payload, then fire one downstream step. Releases on `max_items`, a quiet `wait_period`, or the `max_wait_period` ceiling. |
| `add-to-digest` | Add the event to a digest keyed by a subscription topic; the digest releases on the topic's configured schedule. |
| `ai` | Run an LLM prompt with optional web search. Returns structured output per `output_schema`. |
| `exit` | End the run immediately. |

### Detailed Reference

| Type | Discriminator | Required fields |
|------|--------------|-----------------|
| Trigger (API) | `type: "trigger"`, `trigger_type: "api-invoke"` | None beyond discriminators. Optional: `id`, `schema`, `conditions`. |
| Trigger (Segment) | `type: "trigger"`, `trigger_type: "segment"` | `request_type` (`identify`, `group`, or `track`). Optional: `event_id`, `conditions`. |
| Send | `type: "send"` | `message.template` (journey-scoped template ID). Optional: `message.to` (overrides), `message.delay`, `message.data`, `conditions`. |
| Delay (duration) | `type: "delay"`, `mode: "duration"` | `duration` (ISO 8601 duration string, e.g. `"PT30M"`). Optional: `conditions`. |
| Delay (until) | `type: "delay"`, `mode: "until"` | `until` (ISO 8601 timestamp or context reference). Optional: `conditions`. |
| Fetch (GET/DELETE) | `type: "fetch"`, `method: "get"` or `"delete"` | `url`, `merge_strategy`. Optional: `headers`, `query_params`, `response_schema`, `conditions`. |
| Fetch (POST/PUT) | `type: "fetch"`, `method: "post"` or `"put"` | `url`, `merge_strategy`. Optional: `body`, `headers`, `query_params`, `response_schema`, `conditions`. |
| Branch | `type: "branch"` | `paths[]` (each with `conditions` and `nodes[]`), `default` (with `nodes[]`). Optional: `paths[].label`, `default.label`. |
| Throttle (static) | `type: "throttle"`, `scope: "user"` or `"global"` | `max_allowed`, `period`. Optional: `conditions`. |
| Throttle (dynamic) | `type: "throttle"`, `scope: "dynamic"` | `max_allowed`, `period`, `throttle_key`. Optional: `conditions`. |
| Batch | `type: "batch"`, `scope: "user"` | `wait_period` (ISO 8601 quiet window), `max_wait_period` (ISO 8601 hard ceiling; must be > `wait_period`), `retain` (`{ type: "first"\|"last"\|"highest"\|"lowest", count: 0–25, sort_key }`; `sort_key` required for `highest`/`lowest`). Optional: `max_items` (1–1000, default 100), `category_key` (partition key, ≤256 chars), `conditions`. |
| Add to digest | `type: "add-to-digest"` | `subscription_topic_id`. Optional: `conditions`. |
| AI | `type: "ai"` | `output_schema` (JSON Schema for the structured result). Optional: `model`, `user_prompt`, `web_search`, `conditions`. |
| Exit | `type: "exit"` | None. Optional: `id`. |

---

## Conditions

Several node types (`branch` paths, `send`, `delay`, `fetch`, `throttle`, triggers, …) support a `conditions` field. A condition's elements are **always strings** — compare against `"true"`/`"false"` and `"50"`, never native booleans or numbers.

The `conditions` field accepts one of three shapes:

**1. A single condition (bare tuple).** Binary is `[path, operator, value]`; unary is `[path, operator]`:

```json
"conditions": ["data.plan", "is equal", "pro"]
```

```json
"conditions": ["data.email", "exists"]
```

> Do **not** wrap a single condition in an extra array (`[[...]]`) — that is not a valid shape.

**2. A group (AND/OR).** An object with exactly one of `AND` or `OR`, each a list of 2+ condition tuples:

```json
"conditions": {
  "AND": [
    ["data.is_first_order", "is equal", "true"],
    ["data.order_total", "greater than", "50"]
  ]
}
```

**3. A nested group.** An object with `AND`/`OR` whose entries are themselves groups — e.g. "first-time buyer over $50 **OR** returning buyer over $200":

```json
"conditions": {
  "OR": [
    { "AND": [["data.is_first_order", "is equal", "true"], ["data.order_total", "greater than", "50"]] },
    { "AND": [["data.is_first_order", "is equal", "false"], ["data.order_total", "greater than", "200"]] }
  ]
}
```

### Available Operators

| Type | Operators |
|------|-----------|
| Binary | `is equal`, `is not equal`, `contains`, `does not contain`
...<truncated>
````


### `resources/guides/mcp.md`

````markdown
# Courier MCP Server

> **Last verified: 2026-04.** Tool count, installation UI paths, and JSON config shape can drift as Courier ships MCP updates or editors change their settings surface. If this file is older than **3 months**, spot-check the tool count and the Cursor/Claude Code installation snippets against https://www.courier.com/docs/tools/mcp (or fetch `https://www.courier.com/docs/llms.txt` and follow the MCP entry) before quoting specifics.

## Quick Reference

### Rules
- MCP provides structured tool access; agents discover tools automatically and call them with typed parameters
- Auth via `api_key` header; use the same API key from [Settings > API Keys](https://app.courier.com/settings/api-keys)
- Tools cover most of the Courier API (send, messages, profiles, lists, audiences, notifications, brands, automations, bulk, tenants, preferences, tokens, translations, inbound, audit). The exact count may change — call the MCP server's tool-list endpoint for the current list
- **What MCP cannot do yet (use SDK/CLI/REST instead):**
  - **Journey management** (create/replace/publish/invoke) — use SDK (`client.journeys.*`) or CLI (`courier journeys ...`). See [Journeys](./journeys.md).
  - **Notification template writes** (create, replace, publish, archive, versions, checks) — use [CLI](./cli.md) or REST. MCP has read-only template tools (list/get content/get draft).
  - **Journey-scoped templates** — REST-only across all surfaces.
- Prefer MCP when your editor supports it (Cursor, Claude Code, Claude Desktop, Windsurf, VSCode); fall back to [CLI](./cli.md) for shell-only environments or CI/CD
- MCP tools return structured JSON responses; errors include HTTP status code and message

### Practical setup guardrails

- Treat tool count as informative, not absolute: if the number changed, proceed as long as the tools you need are present.
- If a workflow requires template writes, switch to [CLI](./cli.md) or REST early instead of forcing MCP.
- For production or CI usage, prefer a dedicated API key per environment/workspace.
- Validate auth and basic tool calls immediately after setup before relying on the integration for larger tasks.

### Quick verification checklist

Run this once after setup:

1. Confirm the server connects in your editor (status is healthy/connected).
2. Run one read call (for example `list_notifications` or `list_messages`) to confirm auth.
3. Run one write-safe call in your expected workflow area (for example profile merge or tenant list) to confirm parameter shape expectations.
4. Verify your needed feature is in MCP; if not (for example template publish/create), route to CLI/REST.
5. Save a short note in project docs or PR description indicating which path is used (`MCP` vs `CLI/REST`) for repeatability.

### MCP vs CLI

| Use MCP | Use CLI |
|---------|---------|
| Editor has MCP support (Cursor, Claude Code, Windsurf, VSCode) | Shell-only environments (Codex, CI/CD pipelines) |
| Typed parameters with auto-discovery | Ad-hoc debugging in a terminal |
| Structured JSON responses | Human-readable or piped output |
| No shell required | `--transform` for GJSON filtering |

Both authenticate with the same `COURIER_API_KEY`. They overlap substantially but are not identical — MCP covers read operations on notification templates only, while the CLI (and REST) cover the full template lifecycle (create, publish, archive, versions, checks). Use CLI for template authoring and CI/CD; use MCP for agent-driven send, profile, list, tenant, preference, and audience workflows.

---

## Installation

### Cursor

In Cursor, go to **Cursor > Cursor Settings > Tools & Integrations > MCP Tools > New MCP Server**, then add:

```json
{
  "mcpServers": {
    "courier": {
      "url": "https://mcp.courier.com",
      "headers": {
        "api_key": "YOUR_COURIER_API_KEY"
      }
    }
  }
}
```

Or use the one-click install: [Install MCP Server](https://cursor.com/en/install-mcp?name=courier&config=eyJ1cmwiOiJodHRwczovL21jcC5jb3VyaWVyLmNvbSIsImhlYWRlcnMiOnsiYXBpX2tleSI6IlhYWFgifX0%3D) — after installing, open **Cursor Settings > MCP** and replace `XXXX` with your actual Courier API key.

Works best with Agent mode enabled (in the Cursor chat input, select "Agent" instead of "Ask" or "Edit").

### Claude Code

```bash
claude mcp add --transport http courier https://mcp.courier.com --header api_key:YOUR_COURIER_API_KEY
```

### Claude Desktop

In Claude Desktop, go to **Claude > Settings > Developer > Edit Config**, then add:

```json
{
  "mcpServers": {
    "courier": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.courier.com", "--header", "api_key: YOUR_COURIER_API_KEY"]
    }
  }
}
```

### Windsurf

In Windsurf, go to **Windsurf > Windsurf Settings > Manage MCP Servers > View Raw Config**, then add:

```json
{
  "mcpServers": {
    "courier": {
      "serverUrl": "https://mcp.courier.com",
      "headers": {
        "api_key": "YOUR_COURIER_API_KEY"
      },
      "disabled": false,
      "disabledTools": []
    }
  }
}
```

### VSCode

Create `.vscode/mcp.json` in your project:

```json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "courier-api-key",
      "description": "API key for Courier service",
      "password": true
    }
  ],
  "servers": {
    "courier": {
      "url": "https://mcp.courier.com",
      "type": "http",
      "headers": {
        "api_key": "${input:courier-api-key}"
      }
    }
  }
}
```

Open the chat window, click the Gear icon, then MCP Servers, and start the "courier" server.

### OpenAI Responses API

```javascript
import OpenAI from "openai";

const client = new OpenAI();

const response = await client.responses.create({
  model: "gpt-4o",
  input: "Look up the profile for user-123 and tell me their email",
  tools: [
    {
      type: "mcp",
      server_label: "courier",
      server_url: "https://mcp.courier.com",
      headers: { api_key: "YOUR_COURIER_API_KEY" },
      require_approval: "never",
    },
  ],
});
```

## Available Tools

Tools cover most of the Courier API, all backed by the official `@trycourier/courier` Node SDK with typed error handling. Notification template **writes** (create/replace/publish/archive/versions/checks) are not yet in MCP — use the [CLI](./cli.md) or REST. Call the MCP server's tool-list endpoint for the current count and names.

### Send

| Tool | Description |
|------|-------------|
| `send_message` | Send a message using inline title and body content |
| `send_message_template` | Send a message using a notification template |
| `send_message_to_list` | Send inline content to all subscribers of a list |
| `send_message_to_list_template` | Send a template to all subscribers of a list |

### Messages

| Tool | Description |
|------|-------------|
| `list_messages` | List sent messages with filters (status, recipient, provider, tags) |
| `get_message` | Get full details and delivery status of a message |
| `get_message_content` | Get the rendered HTML, text, and subject of a sent message |
| `get_message_history` | Get the event history for a message (enqueued, sent, delivered, etc.) |
| `cancel_message` | Cancel a message currently being delivered |

### Profiles

| Tool | Description |
|------|-------------|
| `get_user_profile_by_id` | Get a user profile by ID |
| `create_or_merge_user` | Create or merge values into an existing profile |
| `replace_profile` | Fully replace a user profile (PUT) |
| `delete_profile` | Delete a user profile |
| `get_user_list_subscriptions` | Get all list subscriptions for a user |
| `subscribe_user_to_lists` | Subscribe a user to one or more lists |
| `delete_user_list_subscriptions` | Remove all list subscriptions for a user |

### Lists

| Tool | Description |
|------|-------------|
| `list_lists` | Get all lists, optionally filtered by pattern |
| `get_list` | Get a list by ID |
| `get_list_subscribers` | Get all subscribers of a list |
| `create_list` | Create or update a list |
| `subscribe_user_to_list` | Subscribe a user to a list |
| `unsubscribe_user_from_list` | Unsubscribe a user from a list |

### Audiences

| Tool | Description |
|------|-------------|
| `get_audience` | Get an audience by ID |
| `list_audiences` | List all audiences |
| `list_audience_members` | List members of an audience |
| `update_audience` | Create or update an audience with a filter definition |
| `delete_audience` | Delete an audience |

### Notifications

| Tool | Description |
|------|-------------|
| `list_notifications` | List notification templates |
| `get_notification_content` | Get published content blocks of a template |
| `get_notification_draft_content` | Get draft content blocks of a template |

### Brands

| Tool | Description |
|------|-------------|
| `create_brand` | Create a new brand |
| `get_brand` | Get a brand by ID |
| `list_brands` | List all brands |

### Auth & Tokens

| Tool | Description |
|------|-------------|
| `generate_jwt_for_user` | Generate a JWT token for client-side SDK auth |
| `list_user_push_tokens` | List all push/device tokens for a user |
| `get_user_push_token` | Get a specific push token |
| `create_or_replace_user_push_token` | Create or replace a push token |

### Automations (Legacy)

> **For new multi-step flows, use [Journeys](./journeys.md) instead.** Journey **management** is supported by the Node/Python SDKs and the CLI (`client.journeys.create/replace/publish/invoke`, `courier journeys ...`); **journey-scoped template** CRUD is currently REST-only. **MCP has no journey tools yet** — drive Journeys from the SDK or CLI, not MCP.

| Tool | Description |
|------|-------------|
| `invoke_automation_template` | Invoke an automation from a template (legacy) |
| `invoke_ad_hoc_automation` | Invoke an ad-hoc automation with inline steps (legacy) |

### Bulk

| Tool | Description |
|------|-------------|
| `create_bulk_job` | Create a bulk job for multi-recipient sends |
| `add_bulk_users` | Add users to an existing bulk job |
| `run_bulk_job` | Trigger delivery for a bulk job |
| `get_bulk_job` | Get the status of a bulk job |
| `list_bulk_users` | List users in a bulk job |

### Tenants

| Tool | Description |
|------|-------------|
| `get_tenant` | Get a tenant by ID |
| `create_or_update_tenant` | Create or replace a tenant |
| `list_tenants` | List all tenants |
| `delete_tenant` | Delete a tenant |

### Users

| Tool | Description |
|------|-------------|
| `get_user_preferences` | Get a user's notification preferences |
| `update_user_preference_topic` | Update a user's preference for a subscription topic |
| `list_user_tenants` | List all tenants a user belongs to |
| `add_user_to_tenant` | Add a user to a tenant |
| `remove_user_from_tenant` | Remove a user from a tenant |

### Translations

| Tool | Description |
|------|-------------|
| `get_translation` | Get a translation for a locale |
| `update_translation` | Create or update a translation |

### Inbound

| Tool | Description |
|------|-------------|
| `track_inbound_event` | Track an inbound event that can trigger automations or journeys |

### Audit Events

| Tool | Description |
|------|-------------|
| `get_audit_event` | Get a specific audit event |
| `list_audit_events` | List audit events |

## Error Handling

All tools return structured error responses:

```json
{
  "error": true,
  "status": 404,
  "message": "Profile not found"
}
```

| Status | Meaning |
|--------|---------|
| `400` | Bad request (missing or invalid parameters) |
| `401` | Invalid API key |
| `404` | Resource not found |
| `429` | Rate limited |

## Related

- [CLI](./cli.md) - Shell-based alternative for environments without MCP support
- [Quickstart](./quickstart.md) - Send your first notification with SDK, CLI, or curl
- [Reliability](./reliability.md) - Idempotency keys and retry patterns
- [Patterns](./patterns.md) - Reusable code patterns for common notification tasks

Documentation: [courier.com/docs/tools/mcp](https://www.courier.com/docs/tools/mcp)

````


### `resources/guides/migrate-from-knock.md`

````markdown
# Migrate from Knock

## Quick Reference

### Rules
- Use Courier's Test environment API key during migration; switch to Production only after validation
- Knock Workflows = Courier Templates (content) + Journeys (orchestration) — split them. Use [Journeys](./journeys.md) for new flows; Automations are legacy.
- Knock Objects don't have a 1:1 equivalent; pass that context via the `data` field on each send
- Knock Feeds map to Courier Inbox — swap `@knocklabs/react` for `@trycourier/courier-react`
- Keep the same `user_id` values when creating Courier profiles to simplify cutover
- Idempotency keys work the same way — keep using them

### Concept Mapping

| Knock | Courier | Notes |
|-------|---------|-------|
| Channels | Integrations | Provider config lives in the Courier dashboard; supports 50+ providers |
| Workflows | Templates + Journeys | Content and orchestration are independent resources — see [Journeys](./journeys.md) |
| Recipients / Users | Users / Profiles | Same shape; profiles accept nested JSON |
| Objects | `data` on send | Pass context inline instead of managing a separate entity |
| Preferences (PreferenceSet) | Preferences (topics) | Courier enforces at send time automatically |
| Tenants | Tenants | Branding lives directly on the Tenant resource |
| Feeds (in-app) | Inbox | Drop-in React, iOS, Android, and vanilla JS components |
| Commits | Publish (draft/live) | Edit in draft, publish when ready; published versions are immutable |
| Batch function | Journeys (`throttle` + `delay` + `fetch` nodes) | Timing in journey; aggregation in your app. For built-in aggregation, see Automations batch step. |

### API Mapping

| Operation | Knock | Courier |
|-----------|-------|---------|
| Send a notification | `POST /v1/workflows/:key/trigger` | `POST /send` |
| Create/update a user | `PUT /v1/users/:id` | `POST /profiles/:id` (merge) |
| Get a user | `GET /v1/users/:id` | `GET /profiles/:id` |
| Set user preferences | `PUT /v1/users/:id/preferences/:set_id` | `PUT /users/:id/preferences/:topic` |
| Get message status | `GET /v1/messages/:id` | `GET /messages/:id` |
| List messages | `GET /v1/messages` | `GET /messages` |
| Bulk send | `POST /v1/workflows/:key/trigger` (recipients array) | 3-step flow: `POST /bulk` (create job with `event`) → `POST /bulk/{job_id}` (add users; email jobs need `profile.email` per user) → `POST /bulk/{job_id}/run` |
| Create/update tenant | `PUT /v1/objects/tenants/:id` (Knock models tenants as Objects) | `PUT /tenants/:id` |

> Note: `PUT /profiles/:id` is a **full replacement** — any fields not included are deleted. Use `POST` for partial updates; reach for `PUT` only when you deliberately want to wipe unlisted fields.

### Common Mistakes
- Trying to recreate Knock Objects as a first-class Courier resource (use `data` instead)
- Copying a Knock Workflow into a single Courier resource (split content into a Template, orchestration into a Journey)
- Forgetting to configure provider integrations in the dashboard before sending
- Migrating to Production keys before validating delivery in the Test environment
- Not migrating user preferences (users lose their opt-out choices)

---

Migrate your notification infrastructure from Knock to Courier. This guide provides side-by-side code examples so you can translate Knock SDK calls to Courier equivalents. For the full official walkthrough, see [Courier's migration docs](https://www.courier.com/docs/tutorials/migrate/from-knock).

## 1. Swap the SDK

### TypeScript / Node.js

**Before (Knock):**
```bash
npm install @knocklabs/node
```

**After (Courier):**
```bash
npm install @trycourier/courier
```

### Python

**Before (Knock):**
```bash
pip install knockapi
```

**After (Courier):**
```bash
pip install trycourier
```

## 2. Initialize the Client

### TypeScript

**Before (Knock):**
```typescript
import Knock from "@knocklabs/node";

const knock = new Knock(process.env.KNOCK_API_KEY);
```

**After (Courier):**
```typescript
import Courier from "@trycourier/courier";

const client = new Courier();
```

> Both `import Courier from "@trycourier/courier"` (default export) and `import { Courier } from "@trycourier/courier"` (named export) work — the named export is `Courier`, not `CourierClient`. The examples in this guide use the default export.

### Python

**Before (Knock):**
```python
from knockapi import Knock

knock = Knock(api_key="sk_...")
```

**After (Courier):**
```python
from courier import Courier

client = Courier()
```

## 3. Configure Integrations

In the Courier dashboard, connect the same providers you use in Knock (SendGrid, Twilio, FCM, etc.). Each provider maps to a channel type.

You can add multiple providers per channel type for automatic failover — if your primary email provider goes down, the backup takes over without code changes.

If you use Knock's in-app feed, enable [Courier Inbox](https://www.courier.com/docs/platform/inbox/inbox-overview). No external provider is needed.

## 4. Recreate Templates

Knock bundles content and delivery logic into Workflows. In Courier, split them:

1. Create a **Template** in the [Designer](https://www.courier.com/docs/platform/content/template-designer/template-designer-overview) for the content (email body, SMS text, push title/body)
2. Use `{{variable}}` syntax for dynamic data — same Handlebars-style as Knock
3. Add content blocks for each channel the notification should support
4. If your Knock Workflow has delays, conditions, or batching, create a separate **Journey** for the orchestration logic — see [Journeys](./journeys.md)

## 5. Migrate Users

Create Courier profiles with the same identifiers you use in Knock.

### TypeScript

**Before (Knock):**
```typescript
// Current Knock Node SDK uses users.update (the older users.identify helper
// was removed; update is an upsert-by-ID).
await knock.users.update("user-123", {
  email: "jane@example.com",
  phone_number: "+15551234567",
  name: "Jane Doe",
});
```

**After (Courier):**
```typescript
await client.profiles.create("user-123", {
  profile: {
    email: "jane@example.com",
    phone_number: "+15551234567",
    name: "Jane Doe",
    plan: "enterprise",
  },
});
```

### Python

**Before (Knock):**
```python
# Current Knock Python SDK uses users.update (upsert by ID).
knock.users.update("user-123", data={
    "email": "jane@example.com",
    "phone_number": "+15551234567",
    "name": "Jane Doe",
})
```

**After (Courier):**
```python
client.profiles.create(
    user_id="user-123",
    profile={
        "email": "jane@example.com",
        "phone_number": "+15551234567",
        "name": "Jane Doe",
        "plan": "enterprise",
    },
)
```

### Bulk Migration

For large user sets, use the [Bulk API](https://www.courier.com/docs/api-reference/bulk/create-a-bulk-job) to upsert users in batches instead of one-by-one calls.

## 6. Update Send Calls

### TypeScript

**Before (Knock):**
```typescript
await knock.workflows.trigger("welcome-email", {
  recipients: ["user-123"],
  data: {
    name: "Jane Doe",
    action_url: "https://app.example.com",
  },
});
```

**After (Courier):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbw4q7x1v5d8c2n6w9hj",
    data: {
      name: "Jane Doe",
      action_url: "https://app.example.com",
    },
  },
});
```

### Python

**Before (Knock):**
```python
knock.workflows.trigger("welcome-email",
    recipients=["user-123"],
    data={
        "name": "Jane Doe",
        "action_url": "https://app.example.com",
    },
)
```

**After (Courier):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbw4q7x1v5d8c2n6w9hj",
        "data": {
            "name": "Jane Doe",
            "action_url": "https://app.example.com",
        },
    }
)
```

### Multi-Channel Routing

Knock embeds channel steps inside workflows. In Courier, specify routing on the send call or in the template:

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: { orderNumber: "12345", trackingUrl: "https://acme.co/track/12345" },
    routing: {
      method: "single",
      channels: ["email", "push", "sms"],
    },
  },
});
```

See [Multi-Channel](./multi-channel.md) for routing strategies, escalation patterns, and provider failover.

## 7. Migrate Preferences

Knock's `PreferenceSet` maps to Courier preference topics. Courier enforces preferences automatically at send time.

### TypeScript

**Before (Knock):**
```typescript
await knock.users.setPreferences("user-123", {
  channel_types: { email: true, sms: false },
  workflows: { "weekly-digest": false },
});
```

**After (Courier):**
```typescript
await client.users.preferences.updateOrCreateTopic("weekly-digest", {
  user_id: "user-123",
  topic: {
    status: "OPTED_OUT",
    has_custom_routing: true,
    custom_routing: ["email"],
  },
});
```

### Hosted Preference Page

Courier provides a [hosted preference page](https://www.courier.com/docs/platform/preferences/hosted-page) you can deploy in minutes, plus embeddable [React components](https://www.courier.com/docs/platform/preferences/embedding-preferences) for building a preference center directly into your app.

See [Preferences](./preferences.md) for topic structure, category defaults, and implementation patterns.

## 8. Migrate In-App Feeds to Inbox

Knock Feeds become Courier Inbox. Swap the React package and component:

### Installation

**Before (Knock):**
```bash
npm install @knocklabs/react
```

**After (Courier):**
```bash
npm install @trycourier/courier-react
```

### React Component

**Before (Knock):**
```tsx
import { KnockProvider, NotificationFeedPopover } from "@knocklabs/react";

function App() {
  return (
    <KnockProvider apiKey="pk_..." userId="user-123">
      <NotificationFeedPopover />
    </KnockProvider>
  );
}
```

**After (Courier v8):**
```tsx
import { useEffect } from "react";
import { CourierInbox, useCourier } from "@trycourier/courier-react";

function App() {
  const courier = useCourier();

  useEffect(() => {
    fetch("/api/courier-token")
      .then((res) => res.json())
      .then((data) => {
        courier.shared.signIn({ userId: "user-123", jwt: data.token });
      });
  }, []);

  return <CourierInbox />;
}
```

v8 uses JWT authentication (not client keys), `CourierInbox` (not `Inbox`), and `useCourier()` for auth. See [Inbox](../channels/inbox.md) for full setup, theming, feeds/tabs, and mobile SDK options.

## 9. Migrate Orchestration Logic

Knock workflow steps (batch, delay, conditions) map to Courier [Journeys](./journeys.md) (recommended) or Automations (legacy):

| Knock Step | Courier Journey Node | Legacy Automation Equivalent |
|------------|---------------------|------------------------------|
| Batch function | `throttle` node + `delay` node | Automation batch/digest step |
| Delay step | `delay` node (`mode: "duration"`) | Automation delay step |
| Branch/condition step | `branch` node with `conditions` | Automation condition step |
| Channel step | `send` node (references a journey-scoped template) | Automation send step |

Knock's code-first workflow definitions map naturally to Journeys — both are defined programmatically and versioned.

### Batch Example

**Before (Knock):** Batch function configured inside the workflow with a window and batch key.

**After (Courier Journeys):**

```bash
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user-123",
    "data": { "event_type": "like", "actor_name": "Jane", "target_id": "post-789" }
  }'
```

The journey DAG handles throttling and batching via `throttle` and `delay` nodes. See [Journeys](./journeys.md) for the full workflow.

**Legacy: Automations approach:**
```typescript
await client.automations.invoke.invokeByTemplate("social-activity-batch", {
  recipient: "user-123",
  data: { event_type: "like", actor_name: "Jane", target_id: "post-789" },
});
```

See [Batching](./batching.md) for window strategies, aggregation patterns, and digest implementation.

## 10. Migrate Tenants

Tenants work similarly across both platforms. In Courier, branding attributes live directly on the Tenant resource instead of a separate Brands object.

```typescript
// `update` is create-or-replace (PUT /tenants/{id}). Brands live in the Brands API;
// tenants reference a brand via `brand_id`.
await client.tenants.update("acme-corp", {
  name: "Acme Corp",
  brand_id: "BRAND_ACME", // optional
});

await client.send.message({
  message: {
    to: { user_id: "user-123", tenant_id: "acme-corp" },
    template: "nt_01kmrbw4q7x1v5d8c2n6w9hj",
    data: { name: "Jane Doe" },
  },
});
```

## 11. Test and Cut Over

1. Send test messages using your Test environment API key
2. Verify delivery in [Message Logs](https://app.courier.com/logs) — check rendered content, delivery timeline, and provider response
3. Validate that preferences, routing, and template rendering match your Knock setup
4. Switch to the Production API key
5. Monitor [Analytics](https://www.courier.com/docs/platform/analytics/analytics) for delivery rates and engagement

## What's Next

| Goal | Guide |
|------|-------|
| Set up multi-channel routing and fallbacks | [Multi-Channel](./multi-channel.md) |
| Configure user notification preferences | [Preferences](./preferences.md) |
| Build an in-app notification center | [Inbox](../channels/inbox.md) |
| Add idempotency keys and retry logic | [Reliability](./reliability.md) |
| Set up digests and batch notifications | [Batching](./batching.md) |
| Plan notifications for your app type | [Catalog](./catalog.md) |

## Related

- [Quickstart](./quickstart.md) - Send your first Courier notification
- [Multi-Channel](./multi-channel.md) - Routing, failover, and escalation
- [Preferences](./preferences.md) - Preference topics and opt-out handling
- [Inbox](../channels/inbox.md) - In-app notification center
- [Batching](./batching.md) - Digest and batch strategies
- [Reliability](./reliability.md) - Idempotency, retries, and error recovery
- [Patterns](./patterns.md) - Reusable code patterns

````


### `resources/guides/migrate-from-novu.md`

````markdown
# Migrate from Novu

## Quick Reference

### Rules
- Use Courier's Test environment API key during migration; switch to Production only after validation
- Novu Workflows = Courier Templates (content) + Journeys (orchestration) — split them. Use [Journeys](./journeys.md) for new flows; Automations are legacy.
- Novu's code-first `@novu/framework` workflows map to Journeys — extract content into Templates and orchestration into Journey DAGs
- Novu Inbox (`@novu/react`) maps to Courier Inbox — swap for `@trycourier/courier-react`
- Keep the same `subscriberId` values as Courier `user_id` to simplify cutover
- Idempotency keys work the same way — keep using them

### Concept Mapping

| Novu | Courier | Notes |
|------|---------|-------|
| Workflows (code-first via `@novu/framework`) | Templates + Journeys | Content and orchestration are independent resources — see [Journeys](./journeys.md) |
| Events API / `novu.trigger()` | `POST /send` | Different payload shape; `subscriberId` becomes `user_id` |
| Subscribers | Users / Profiles | Courier profiles accept nested JSON for custom data |
| Subscriber credentials (deviceTokens, webhookUrl) | Profile channel tokens | Stored directly on the Courier profile |
| Topics | Subscription Topics / Lists | Topics for broadcast fan-out; lists for static subscriber groups |
| Tenants | Tenants | Branding lives directly on the Courier Tenant resource |
| Digest step | Journeys (`throttle` + `delay` + `fetch` nodes) | Timing in journey; aggregation in your app |
| Delay step | Journeys `delay` node | Same concept — `mode: "duration"` or `mode: "until"` |
| Custom step | Journeys `fetch` node | HTTP request + merge response into run state |
| Inbox (`@novu/react`, `NovuProvider`) | Inbox (`@trycourier/courier-react`, `CourierInbox`) | v8 uses JWT auth and `useCourier()` hook; both offer headless mode |
| Subscriber preferences | Preferences (topics) | Courier enforces at send time automatically |
| Environments (dev/production) | Test/Production keys | Courier uses separate API keys per environment; copy from [Settings > API Keys](https://app.courier.com/settings/api-keys) |

### API Mapping

| Operation | Novu | Courier |
|-----------|------|---------|
| Send a notification | `POST /v1/events/trigger` | `POST /send` |
| Create/update a subscriber | `POST /v2/subscribers` | `POST /profiles/:id` (merge) |
| Get a subscriber | `GET /v2/subscribers/:id` | `GET /profiles/:id` |
| Set subscriber preferences | `PATCH /v2/subscribers/:id/preferences` | `PUT /users/:id/preferences/:topic` |
| Get message status | `GET /messages/:id` | `GET /messages/:id` |
| List messages | `GET /messages` | `GET /messages` |
| Broadcast to topic | `POST /v1/events/trigger` (with `topics`) | `POST /send` (with list/audience) |
| Create/update tenant | `POST /v1/tenants` | `PUT /tenants/:id` |
| Bulk send | Topic trigger (fan-out) | 3-step flow: `POST /bulk` (create job with `event`) → `POST /bulk/{job_id}` (add users; email jobs need `profile.email` per user) → `POST /bulk/{job_id}/run` |

> Note: `PUT /profiles/:id` is a **full replacement** — any fields not included are deleted. Use `POST` for partial updates; reach for `PUT` only when you deliberately want to wipe unlisted fields.

### Common Mistakes
- Trying to replicate Novu's code-first workflow definitions (`@novu/framework`) as a single Courier resource (split content into a Template, orchestration into a Journey)
- Using `subscriberId` in Courier send calls instead of mapping it to `user_id`
- Forgetting to configure provider integrations in the Courier dashboard before sending
- Not migrating subscriber preferences (users lose their opt-out choices)
- Migrating to Production keys before validating delivery in the Test environment
- Expecting Novu's topic fan-out to work identically (use Courier Lists or subscription topics instead)

---

Migrate your notification infrastructure from Novu to Courier. This guide provides side-by-side code examples so you can translate Novu SDK calls to Courier equivalents. For the full official walkthrough, see [Courier's migration docs](https://www.courier.com/docs/tutorials/migrate/from-novu).

## 1. Swap the SDK

### TypeScript / Node.js

**Before (Novu):**
```bash
# If using the legacy SDK:
npm install @novu/node
# Or the newer SDK:
npm install @novu/api
```

**After (Courier):**
```bash
npm install @trycourier/courier
```

### Python

**Before (Novu):**
```bash
# If using the legacy SDK:
pip install novu
# Or the newer SDK:
pip install novu-py
```

**After (Courier):**
```bash
pip install trycourier
```

## 2. Initialize the Client

### TypeScript

**Before (Novu — `@novu/api`):**
```typescript
import { Novu } from "@novu/api";

const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY });
```

**Before (Novu — `@novu/node`):**
```typescript
import { Novu } from "@novu/node";

const novu = new Novu(process.env.NOVU_SECRET_KEY);
```

**After (Courier):**
```typescript
import Courier from "@trycourier/courier";

const client = new Courier();
```

### Python

**Before (Novu — `novu-py`):**
```python
from novu_py import Novu

novu = Novu(secret_key="sk_...")
```

**Before (Novu — legacy `novu` SDK, pseudocode):**
```python
# The legacy `novu` Python SDK used an EventApi class. Real usage requires
# passing the secret key; this snippet is pseudocode for shape only.
from novu.api import EventApi

event_api = EventApi(url="https://api.novu.co", api_key="sk_...")
event_api.trigger(
    name="welcome-email",
    recipients="user-123",
    payload={"name": "Jane Doe"},
)
```

**After (Courier):**
```python
from courier import Courier

client = Courier()
```

## 3. Configure Integrations

In the Courier dashboard, connect the same providers you use in Novu (SendGrid, Twilio, FCM, etc.). Each provider maps to a channel type.

You can add multiple providers per channel type for automatic failover — if your primary email provider goes down, the backup takes over without code changes.

If you use Novu's Inbox component, enable [Courier Inbox](https://www.courier.com/docs/platform/inbox/inbox-overview). No external provider is needed.

## 4. Recreate Templates

Novu bundles content, routing, and orchestration into code-first Workflows (via `@novu/framework`) or the dashboard workflow editor. In Courier, split them:

1. Create a **Template** in the [Designer](https://www.courier.com/docs/platform/content/template-designer/template-designer-overview) for the content (email body, SMS text, push title/body)
2. Use `{{variable}}` syntax for dynamic data — same Handlebars-style as Novu
3. Add content blocks for each channel the notification should support
4. If your Novu Workflow has digest, delay, or condition steps, create a separate **Journey** for the orchestration logic — see [Journeys](./journeys.md)

### Novu Step → Courier Equivalent

| Novu Workflow Step | Courier Journey Node |
|--------------------|---------------------|
| Channel step (email, sms, push, chat, in-app) | `send` node (references a journey-scoped template) |
| Digest step | `throttle` node + `delay` node (aggregation in your app) |
| Delay step | `delay` node (`mode: "duration"`) |
| Custom step | `fetch` node (HTTP request + merge into state) |

## 5. Migrate Subscribers

Create Courier profiles with the same identifiers you use as `subscriberId` in Novu.

### TypeScript

**Before (Novu):**
```typescript
await novu.subscribers.create({
  subscriberId: "user-123",
  email: "jane@example.com",
  phone: "+15551234567",
  firstName: "Jane",
  lastName: "Doe",
});
```

**After (Courier):**
```typescript
await client.profiles.create("user-123", {
  profile: {
    email: "jane@example.com",
    phone_number: "+15551234567",
    name: "Jane Doe",
    plan: "enterprise",
  },
});
```

### Python

**Before (Novu):**
```python
novu.subscribers.create(
    subscriber_id="user-123",
    email="jane@example.com",
    phone="+15551234567",
    first_name="Jane",
    last_name="Doe",
)
```

**After (Courier):**
```python
client.profiles.create(
    user_id="user-123",
    profile={
        "email": "jane@example.com",
        "phone_number": "+15551234567",
        "name": "Jane Doe",
        "plan": "enterprise",
    },
)
```

### Device Tokens and Webhooks

Novu stores push device tokens and chat webhook URLs as subscriber credentials. In Courier:

- **Push device tokens** are a first-class resource. Register them with `client.users.tokens.addSingle(token, { user_id, provider_key, device })` — not as a profile field. This way Courier can manage per-device routing, token refresh, and invalidation. See [push.md](../channels/push.md) and [SKILL.md](../../SKILL.md) for the canonical shape.
- **Chat webhooks / Slack / Teams credentials** live on the profile (`slack`, `ms_teams`, `discord`, etc.) so template routing can pick them up automatically.

```typescript
await client.users.tokens.addSingle("fcm-device-token-here", {
  user_id: "user-123",
  provider_key: "firebase-fcm",
  device: { app_id: "com.acme.app", platform: "android" },
});

await client.profiles.create("user-123", {
  profile: {
    email: "jane@example.com",
    slack: { access_token: "xoxb-...", channel: "C01234" },
  },
});
```

```python
client.users.tokens.add_single(
    token="fcm-device-token-here",
    user_id="user-123",
    provider_key="firebase-fcm",
    device={"app_id": "com.acme.app", "platform": "android"},
)

client.profiles.create(
    user_id="user-123",
    profile={
        "email": "jane@example.com",
        "slack": {"access_token": "xoxb-...", "channel": "C01234"},
    },
)
```

> For iOS (APNs), set `provider_key: "apn"`. See [push.md](../channels/push.md) for the full provider-key table.

### Bulk Migration

For large subscriber sets, use the [Bulk API](https://www.courier.com/docs/api-reference/bulk/create-a-bulk-job) to upsert users in batches instead of one-by-one calls.

## 6. Update Send Calls

### TypeScript

**Before (Novu — `@novu/api`, current SDK):**
```typescript
import { Novu } from "@novu/api";

const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY! });

await novu.trigger({
  workflowId: "welcome-email",
  to: [{ subscriberId: "user-123" }],
  payload: {
    name: "Jane Doe",
    action_url: "https://app.example.com",
  },
});
```

**Before (Novu — `@novu/node`, legacy SDK):**
```typescript
import { Novu } from "@novu/node";

const novu = new Novu(process.env.NOVU_API_KEY!);

await novu.trigger("welcome-email", {
  to: { subscriberId: "user-123" },
  payload: {
    name: "Jane Doe",
    action_url: "https://app.example.com",
  },
});
```

**After (Courier):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbw4q7x1v5d8c2n6w9hj",
    data: {
      name: "Jane Doe",
      action_url: "https://app.example.com",
    },
  },
});
```

### Python

**Before (Novu — `novu-py`, current SDK):**
```python
from novu_py import Novu
from novu_py.models import TriggerEventRequestDto

novu = Novu(secret_key="sk_...")

novu.trigger(trigger_event_request_dto=TriggerEventRequestDto(
    workflow_id="welcome-email",
    to={"subscriber_id": "user-123"},
    payload={
        "name": "Jane Doe",
        "action_url": "https://app.example.com",
    },
))
```

**After (Courier):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbw4q7x1v5d8c2n6w9hj",
        "data": {
            "name": "Jane Doe",
            "action_url": "https://app.example.com",
        },
    }
)
```

### Multi-Channel Routing

Novu embeds channel steps inside workflows. In Courier, specify routing on the send call or in the template:

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: { orderNumber: "12345", trackingUrl: "https://acme.co/track/12345" },
    routing: {
      method: "single",
      channels: ["email", "push", "sms"],
    },
  },
});
```

See [Multi-Channel](./multi-channel.md) for routing strategies, escalation patterns, and provider failover.

## 7. Migrate Preferences

Novu's subscriber preferences (per-workflow and per-channel) map to Courier preference topics. Courier enforces preferences automatically at send time.

### Read preferences

**Before (Novu):**
```typescript
const prefs = await novu.subscribers.getPreferences("user-123");
```

**After (Courier) — TypeScript:**
```typescript
const prefs = await client.users.preferences.retrieve("user-123");
```

**After (Courier) — Python:**
```python
prefs = client.users.preferences.retrieve("user-123")
```

### Update a topic preference

Novu's per-workflow/per-channel preference writes map to Courier's `updateOrCreateTopic`. This is a separate call from reading — there is no single "set all preferences" API.

**TypeScript:**
```typescript
await client.users.preferences.updateOrCreateTopic("weekly-digest", {
  user_id: "user-123",
  topic: {
    status: "OPTED_OUT",
    has_custom_routing: true,
    custom_routing: ["email"],
  },
});
```

**Python:**
```python
client.users.preferences.update_or_create_topic(
    "weekly-digest",
    user_id="user-123",
    topic={
        "status": "OPTED_OUT",
        "has_custom_routing": True,
        "custom_routing": ["email"],
    },
)
```

### Hosted Preference Page

Courier provides a [hosted preference page](https://www.courier.com/docs/platform/preferences/hosted-page) you can deploy in minutes, plus embeddable [React components](https://www.courier.com/docs/platform/preferences/embedding-preferences) for building a preference center directly into your app.

See [Preferences](./preferences.md) for topic structure, category defaults, and implementation patterns.

## 8. Migrate In-App Inbox

Novu Inbox becomes Courier Inbox. Swap the React package and component:

### Installation

**Before (Novu):**
```bash
npm install @novu/react
```

**After (Courier):**
```bash
npm install @trycourier/courier-react
```

### React Component

**Before (Novu):**
```tsx
import { NovuProvider, Inbox } from "@novu/react";

function App() {
  return (
    <NovuProvider applicationIdentifier="APP_ID" subscriberId="user-123">
      <Inbox />
    </NovuProvider>
  );
}
```

**After (Courier v8):**
```tsx
import { useEffect } from "react";
import { CourierInbox, useCourier } from "@trycourier/courier-react";

function App() {
  const courier = useCourier();

  useEffect(() => {
    fetch("/api/courier-token")
      .then((res) => res.json())
      .then((data) => {
        courier.shared.signIn({ userId: "user-123", jwt: data.token });
      });
  }, []);

  return <CourierInbox />;
}
```

v8 uses JWT authentication (not client keys), `CourierInbox` (not `Inbox`), and `useCourier()` for auth. See [Inbox](../channels/inbox.md) for full setup, theming, feeds/tabs, and mobile SDK options.

### Headless Mode

Both platforms offer headless mode for custom UI. In Courier, use the headless hooks from `@trycourier/courier-react` or the vanilla JS client for non-React frameworks.

## 9. Migrate Topics to Lists

Novu Topics let you broadcast to subscriber groups with a single trigger. In Courier, use Lists or Audiences:

### TypeScript

**Before (Novu):**
```typescript
await novu.trigger({
  workflowId: "product-update",
  to: [{ type: "Topic", topicKey: "beta-users" }],
  payload: { feature: "Dark mode is here" },
});
```

**After (Courier):**
```typescript
await client.send.message({
  message: {
    to: { list_id: "beta-users" },
    template: "nt_01kmrbzc8x2q6v1d4c7n5j9ht",
    data: { feature: "Dark mode is here" },
  },
});
```

Manage list membership via the API:

```typescript
// Additive single-user subscribe. The list is auto-created if it doesn't exist.
await client.lists.subscriptions.subscribeUser("user-123", { list_id: "beta-users" });
```

For attribute-based targeting (e.g., all users on the enterprise plan), use [Audiences](https://www.courier.com/docs/platform/users/audiences) instead of lists.

## 10. Migrate Orchestration Logic

Novu workflow steps (digest, delay, conditions) map to Courier [Journeys](./journeys.md) (recommended) or Automations (legacy):

| Novu Step | Courier Journey Node | Legacy Automation Equivalent |
|-----------|---------------------|------------------------------|
| Digest step | `throttle` node + `delay` node | Automation digest node |
| Delay step | `delay` node (`mode: "duration"`) | Automation delay node |
| Condition / filter | `branch` node with `conditions` | Automation condition node |
| Channel step | `send` node (references a journey-scoped template) | Automation send node |
| Custom step | `fetch` node (HTTP request + merge into state) | No direct equivalent |

Novu's code-first `@novu/framework` workflow definitions map naturally to Journeys — both are defined programmatically as DAGs.

### Digest Example

**Before (Novu):** Digest step configured inside the workflow with `amount`, `unit`, and optional `digestKey`.

**After (Courier Journeys):**

```bash
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user-123",
    "data": { "event_type": "like", "actor_name": "Jane", "target_id": "post-789" }
  }'
```

The journey DAG handles throttling and batching via `throttle` and `delay` nodes. See [Journeys](./journeys.md) for the full workflow.

**Legacy: Automations approach:**
```typescript
await client.automations.invoke.invokeByTemplate("social-activity-batch", {
  recipient: "user-123",
  data: { event_type: "like", actor_name: "Jane", target_id: "post-789" },
});
```

See [Batching](./batching.md) for window strategies, aggregation patterns, and digest implementation.

## 11. Migrate Tenants

Tenants work similarly across both platforms. In Novu, tenant data is passed during trigger and available in templates as `{{ tenant.data.* }}`. In Courier, branding attributes live directly on the Tenant resource.

```typescript
// `update` is create-or-replace (PUT /tenants/{id}). Brands live in the Brands API;
// tenants reference a brand via `brand_id`.
await client.tenants.update("acme-corp", {
  name: "Acme Corp",
  brand_id: "BRAND_ACME", // optional
});

await client.send.message({
  message: {
    to: { user_id: "user-123", tenant_id: "acme-corp" },
    template: "nt_01kmrbw4q7x1v5d8c2n6w9hj",
    data: { name: "Jane Doe" },
  },
});
```

## 12. Test and Cut Over

1. Send test messages using your Test environment API key
2. Verify delivery in [Message Logs](https://app.courier.com/logs) — check rendered content, delivery timeline, and provider response
3. Validate that preferences, routing, and template rendering match your Novu setup
4. Switch to the Production API key
5. Monitor [Analytics](https://www.courier.com/docs/platform/analytics/analytics) for delivery rates and engagement

## What's Next

| Goal | Guide |
|------|-------|
| Set up multi-channel routing and fallbacks | [Multi-Channel](./multi-channel.md) |
| Configure user notification preferences | [Preferences](./preferences.md) |
| Build an in-app notification center | [Inbox](../channels/inbox.md) |
| Add idempotency keys and retry logic | [Reliability](./reliability.md) |
| Set up digests and batch notifications | [Batching](./batching.md) |
| Plan notifications for your app type | [Catalog](./catalog.md) |

## Related

- [Quickstart](./quickstart.md) - Send your first Courier notification
- [Multi-Channel](./multi-channel.md) - Routing, failover, and escalation
- [Preferences](./preferences.md) - Preference topics and opt-out handling
- [Inbox](../channels/inbox.md) - In-app notification center
- [Batching](./batching.md) - Digest and batch strategies
- [Reliability](./reliability.md) - Idempotency, retries, and error recovery
- [Patterns](./patterns.md) - Reusable code patterns

````


### `resources/guides/migrate-general.md`

````markdown
# Migrate from Any Notification System

## Quick Reference

### Rules
- Use Courier's Test environment API key during migration; switch to Production only after validation
- Run old and new systems in parallel during migration — don't cut over in one step
- Keep the same user identifiers when creating Courier profiles to simplify cutover
- Content lives in Templates; orchestration lives in Journeys (or legacy Automations) — keep them separate
- Configure provider integrations (SendGrid, Twilio, etc.) in the Courier dashboard before sending
- Idempotency keys prevent duplicates during the parallel-run phase

### Common Mistakes
- Cutting over all traffic at once instead of running in parallel
- Forgetting to configure providers in the dashboard before sending
- Not migrating user preferences (users lose their opt-out choices)
- Recreating complex orchestration logic in code instead of using Journeys
- Sending to production before validating in the Test environment
- Not mapping existing user IDs to Courier profile IDs (causes duplicate users)

---

This guide covers migrating from any custom-built or third-party notification system to Courier. If you're migrating from a specific platform, see the dedicated guides: [Migrate from Knock](./migrate-from-knock.md), [Migrate from Novu](./migrate-from-novu.md).

## 1. Audit Your Existing System

Before writing any code, inventory what you have. This prevents missed notifications during cutover.

### Notification Inventory

For each notification your system sends, record:

| Field | Example |
|-------|---------|
| Name / ID | `order-confirmation` |
| Trigger | Order placed event |
| Channels | Email, SMS |
| Template / content source | HTML template in S3, hardcoded string, etc. |
| Provider | SendGrid, Twilio |
| Data / variables | `orderNumber`, `items`, `total` |
| Recipient source | User table, event payload |
| Preferences | User can opt out of marketing; transactional always sent |
| Orchestration | Immediate, delayed, batched, sequenced |

### Questions to Answer

- How many distinct notification types do you send?
- Which providers do you use per channel?
- Where do templates live (code, database, third-party)?
- Do you have orchestration logic (delays, batching, sequences)?
- How are user preferences stored and enforced?
- Do you have multi-tenant / white-label requirements?

## 2. Map to Courier Concepts

| Your System | Courier | Notes |
|-------------|---------|-------|
| Email/SMS/push send function | `client.send.message()` | Single API for all channels |
| User / recipient table | Profiles | `POST /profiles/:id` (merge) — accepts nested JSON |
| Template (HTML, text, etc.) | Templates | Built in Courier Designer or via API (Elemental format) |
| Send queue / orchestration | Journeys (recommended) or Automations (legacy) | Delays, batching, conditions, sequences — see [Journeys](./journeys.md) |
| User preferences / opt-outs | Preference Topics | Enforced automatically at send time |
| Provider config (API keys, etc.) | Integrations | Configured in the Courier dashboard |
| Webhook handlers for status | Courier Webhooks | Delivery, bounce, complaint events |
| Multi-tenant branding | Tenants | Brand attributes live on the Tenant resource |

> Note: `PUT /profiles/:id` is a **full replacement** — any fields not included are deleted. Use `POST` for partial updates; reach for `PUT` only when you deliberately want to wipe unlisted fields.

## 3. Set Up Courier

### Install the SDK

**TypeScript:**
```bash
npm install @trycourier/courier
```

**Python:**
```bash
pip install trycourier
```

### Initialize

**TypeScript:**
```typescript
import Courier from "@trycourier/courier";

const client = new Courier();
```

**Python:**
```python
from courier import Courier

client = Courier()
```

Set `COURIER_API_KEY` in your environment. Use the **Test** environment key during migration.

### Configure Providers

In the [Courier dashboard](https://app.courier.com), connect the same providers you currently use:

| Channel | Common Providers |
|---------|-----------------|
| Email | SendGrid, Amazon SES, Postmark, Mailgun, Resend |
| SMS | Twilio, Vonage, MessageBird, Plivo, Telnyx |
| Push | Firebase Cloud Messaging (FCM), APNs, Expo |
| Chat | Slack, Microsoft Teams, Discord |

You can add multiple providers per channel for automatic failover.

## 4. Migrate Users

Create Courier profiles with the same identifiers your current system uses.

**TypeScript:**
```typescript
await client.profiles.create("user-123", {
  profile: {
    email: "jane@example.com",
    phone_number: "+15551234567",
    name: "Jane Doe",
  },
});
```

**Python:**
```python
client.profiles.create(
    user_id="user-123",
    profile={
        "email": "jane@example.com",
        "phone_number": "+15551234567",
        "name": "Jane Doe",
    },
)
```

For large user sets, use the [Bulk API](https://www.courier.com/docs/api-reference/bulk/create-a-bulk-job) to upsert in batches.

### Device Tokens

If you send push notifications, store device tokens on the profile:

```typescript
await client.users.tokens.addSingle("fcm-token-abc", {
  user_id: "user-123",
  provider_key: "firebase-fcm",
  device: {
    app_id: "com.acme.app",
    platform: "android",
  },
});
```

## 5. Recreate Templates

You have two options for content:

### Option A: Use the Template Designer

Build templates visually in the [Courier Designer](https://www.courier.com/docs/platform/content/template-designer/template-designer-overview). Use `{{variable}}` for dynamic data. Add content blocks for each channel.

### Option B: Send Inline Content

Skip templates entirely and send content inline — useful for simple notifications or when content is generated in code:

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    content: {
      title: "Order Confirmed",
      body: "Your order #{{orderNumber}} has been confirmed.",
    },
    data: { orderNumber: "12345" },
  },
});
```

### Option C: Manage Templates via API

Create and publish templates programmatically using the Notifications API. See [Templates](./templates.md) for the full CRUD lifecycle and Elemental format.

## 6. Migrate Send Calls

Replace your existing send functions with `client.send.message()`.

### Before (Custom System)

```typescript
// Your existing code might look like this
await sendEmail({
  to: user.email,
  template: "order-confirmation",
  data: { orderNumber, items, total },
});

await sendSMS({
  to: user.phone,
  body: `Order #${orderNumber} confirmed!`,
});
```

### After (Courier)

```typescript
await client.send.message({
  message: {
    to: { user_id: user.id },
    template: "nt_01kmrbq6ypf25tsge12qek41r0",
    data: { orderNumber, items, total },
    routing: {
      method: "all",
      channels: ["email", "sms"],
    },
  },
}, {
  headers: { "Idempotency-Key": `order-confirmation-${orderNumber}` },
});
```

Use your own workspace template ID (`nt_...`) for `message.template`; the value above is an example.

One API call replaces separate email and SMS sends. Courier handles provider selection, template rendering, and delivery.

### Multi-Channel Routing

If your old system had custom routing logic (try push, fall back to email), replace it with Courier's routing:

```jsonc
// message.routing fragment on client.send.message
{
  "method": "single",
  "channels": ["push", "email"]
}
```

See [Multi-Channel](./multi-channel.md) for routing strategies, escalation, and provider failover.

## 7. Migrate Orchestration

If your system has delays, sequences, or batching, move that logic to Courier [Journeys](./journeys.md).

| Your System | Courier Journey Node |
|-------------|---------------------|
| Cron job / delayed queue | `delay` node (`mode: "duration"` or `mode: "until"`) |
| Batch / digest aggregation | `throttle` node + `delay` node |
| Conditional sends (if user did X) | `branch` node with conditions |
| Multi-step sequences (onboarding drip) | Journey DAG with `send` + `delay` + `branch` nodes |

```bash
# Create and invoke a journey — see journeys.md for full workflow
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "user-123", "data": {"name": "Jane", "plan": "pro"}}'
```

See [Journeys](./journeys.md) for the full create → template → wire → publish → invoke workflow, and [Batching](./batching.md) for digest strategies.

**Legacy: Automations** — if you prefer dashboard-configured orchestration, Automations still work:

```typescript
await client.automations.invoke.invokeByTemplate("onboarding-sequence", {
  recipient: "user-123",
  data: { name: "Jane", plan: "pro" },
});
```

## 8. Migrate Preferences

If your system tracks user notification preferences, migrate them to Courier topics.

**TypeScript:**
```typescript
await client.users.preferences.updateOrCreateTopic("marketing-emails", {
  user_id: "user-123",
  topic: {
    status: "OPTED_OUT",
  },
});
```

**Python:**
```python
client.users.preferences.update_or_create_topic(
    "marketing-emails",
    user_id="user-123",
    topic={"status": "OPTED_OUT"},
)
```

Courier enforces preferences automatically at send time — you don't need to check preferences in your application code.

See [Preferences](./preferences.md) for topic structure, categories, and building a preference center.

## 9. Run in Parallel

The safest migration strategy is to run both systems simultaneously:

```
Phase 1: Shadow mode (1-2 weeks)
├── Old system sends to users (production)
└── Courier sends to test addresses only (validation)

Phase 2: Dual send (1 week)
├── Old system sends to users (production)
└── Courier sends to users (production) — monitor for parity

Phase 3: Courier primary (1 week)
├── Courier sends to users (production)
└── Old system as fallback (monitor)

Phase 4: Cutover
└── Courier only
```

### Shadow Mode Pattern

Send to Courier with a test recipient to validate rendering and delivery without affecting users:

```typescript
if (process.env.MIGRATION_PHASE === "shadow") {
  await client.send.message({
    message: {
      to: { email: "notifications-test@yourcompany.com" },
      template: "nt_01kmrbq6ypf25tsge12qek41r0",
      data: { orderNumber, items, total },
    },
  });
}
```

### Dual Send Pattern

Once validated, send through both systems. Use idempotency keys to prevent issues if you accidentally double-send:

```typescript
await client.send.message({
  message: {
    to: { user_id: user.id },
    template: "nt_01kmrbq6ypf25tsge12qek41r0",
    data: { orderNumber, items, total },
  },
}, {
  headers: { "Idempotency-Key": `order-confirmation-${orderNumber}` },
});
```

## 10. Validate and Cut Over

1. Send test messages using your Test environment API key
2. Verify delivery in [Message Logs](https://app.courier.com/logs) — check rendered content, delivery timeline, and provider response
3. Compare delivery rates between old and new systems during dual-send
4. Validate preference enforcement (opted-out users should not receive)
5. Switch to the Production API key
6. Remove old system code
7. Monitor [Analytics](https://www.courier.com/docs/platform/analytics/analytics) for delivery rates and engagement

## Migration Checklist

- [ ] Inventory all notification types
- [ ] Configure providers in Courier dashboard
- [ ] Migrate user profiles (with same IDs)
- [ ] Migrate device tokens for push
- [ ] Create or migrate templates
- [ ] Migrate user preferences
- [ ] Replace send calls with `client.send.message()`
- [ ] Migrate orchestration to Journeys (or Automations if using dashboard)
- [ ] Set up webhook handlers for delivery events
- [ ] Run shadow mode and validate
- [ ] Run dual-send and compare
- [ ] Cut over to Courier only
- [ ] Remove old system code

## What's Next

| Goal | Guide |
|------|-------|
| Set up multi-channel routing and fallbacks | [Multi-Channel](./multi-channel.md) |
| Configure user notification preferences | [Preferences](./preferences.md) |
| Build an in-app notification center | [Inbox](../channels/inbox.md) |
| Add idempotency keys and retry logic | [Reliability](./reliability.md) |
| Set up digests and batch notifications | [Batching](./batching.md) |
| Plan notifications for your app type | [Catalog](./catalog.md) |
| Create templates programmatically | [Templates](./templates.md) |

## Related

- [Quickstart](./quickstart.md) - Send your first Courier notification
- [Migrate from Knock](./migrate-from-knock.md) - Platform-specific migration from Knock
- [Migrate from Novu](./migrate-from-novu.md) - Platform-specific migration from Novu
- [Multi-Channel](./multi-channel.md) - Routing, failover, and escalation
- [Reliability](./reliability.md) - Idempotency, retries, and error recovery
- [Patterns](./patterns.md) - Reusable code patterns

````


### `resources/guides/multi-channel.md`

````markdown
# Multi-Channel Routing

## Quick Reference

### Rules
- `method: "single"`: Try channels in order until one succeeds (use for most cases)
- `method: "all"`: Send to all channels simultaneously (use for critical alerts)
- Each channel requires user to have valid contact info (email, phone, push token)
- Channel order in array = priority order for fallbacks
- Courier auto-checks user preferences when configured
- Use idempotency keys to prevent duplicate sends across retries

### Channel Selection by Use Case
| Use Case | Method | Channels |
|----------|--------|----------|
| OTP/2FA | single | [sms, email] |
| Password reset | single | [email] |
| Order confirmation | single | [email] |
| Order shipped | all | [email, push] |
| Security alert | all | Tiered by severity — see [authentication.md#security-alert-channels](../transactional/authentication.md#security-alert-channels). Lowest tier: `[email, push]`; highest tier: `[email, push, sms, inbox]` |
| Activity mention | all | [push, inbox] |
| Weekly digest | single | [email] |

Rationale for "order shipped": email is the durable record; push is the awareness nudge. `method: "all"` automatically skips channels where the user has no contact info (no push token = push silently dropped, email still sent). Inbox is intentionally omitted — a shipping notification doesn't need in-app persistence.

### Routing Edge Cases
| Situation | Behavior |
|-----------|----------|
| `user_id` has no email, email is first in `channels` | Skips email, tries next channel |
| `user_id` has no contact info at all | Typically `UNDELIVERABLE`, but if the workspace has the Courier Inbox provider (or any catch-all channel) enabled, the send may still complete as `SENT` via that channel. Check `providers[]` on the retrieved message to see what actually ran. |
| User opted out of a channel via preferences | Channel skipped automatically |
| `routing` omitted entirely | Courier uses channels configured in the template |
| Provider returns 5xx | Courier retries, then tries next provider in priority order |
| All providers for a channel fail | With `method: "single"`, falls through to next channel |

### Common Mistakes
- Using `all` when `single` is appropriate (spams user on all channels)
- Not setting channel priority order correctly
- Missing fallback channels for critical notifications
- Sending same content to all channels (adapt per channel)
- Not respecting user preferences
- Forgetting idempotency keys (causes duplicates)

---

Orchestrate notifications across channels for maximum deliverability and engagement.

## Routing Methods

Courier provides two core routing methods:

### Single Channel (`method: "single"`)

Try channels in priority order until one succeeds. Best for most notifications where you want exactly one delivery.

Pick the priority order based on the use case — OTP/2FA should be `["sms", "email"]` (speed matters more than durability), password reset should be `["email"]` (the secure link is the point), general fallback can be `["email", "sms"]` (email is the default record, SMS is the backstop).

**TypeScript (generic fallback, email first):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbzj3q6x9v2d5c8n1w4ht",
    routing: {
      method: "single",
      channels: ["email", "sms"]
    }
  }
});
```

**Python:**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbzj3q6x9v2d5c8n1w4ht",
        "routing": {"method": "single", "channels": ["email", "sms"]},
    }
)
```

**Behavior:**
1. Attempt the first channel
2. If it fails (no contact info, provider error), try the next
3. Stop after first successful delivery

> For OTP specifically, swap the channel order to `["sms", "email"]` — the Quick Reference and By-Urgency tables above are the source of truth for per-use-case ordering.

### All Channels (`method: "all"`)

Send to all specified channels simultaneously. Best for critical notifications.

**TypeScript:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrc06x1q5v8d2c6n4w9hj",
    routing: {
      method: "all",
      channels: ["email", "push", "sms"]
    }
  }
});
```

**Python:**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrc06x1q5v8d2c6n4w9hj",
        "routing": {"method": "all", "channels": ["email", "push", "sms"]},
    }
)
```

**Behavior:**
- Attempts delivery to all channels in parallel
- Each channel is independent (one failure doesn't affect others)
- User receives on all channels where they have valid contact info

## Channel Priority Matrix

### By Urgency

| Urgency | Strategy | Channels | Example |
|---------|----------|----------|---------|
| Critical | All channels | Email + Push + SMS + In-app | Account compromised |
| High | Primary + aggressive fallback | SMS → Email (canonical OTP order: the Quick Reference is the source of truth) | OTP code |
| Medium | Record + awareness nudge | Email + Push (`method: "all"`) | Order shipped |
| Low | Least intrusive | In-app only | Weekly digest available |

### By Notification Type

| Notification | Primary | Secondary | Rationale |
|--------------|---------|-----------|-----------|
| **OTP/2FA** | SMS | Email | Speed, visibility, works offline |
| **Password reset** | Email | - | Secure link delivery |
| **Order confirmed** | Email | In-app | Receipt for records |
| **Order shipped** | Email + Push (`method: "all"`, channels `[email, push]`) | - | Email is the record, push the awareness nudge |
| **Out for delivery** | Push + SMS | - | Real-time, actionable |
| **Security alert** | Tiered (see [authentication.md](../transactional/authentication.md#security-alert-channels)) | - | Fan-out scales with event severity |
| **Direct message** | Push | In-app | Immediate, conversational |
| **Comment/mention** | Push | In-app | Immediate, contextual |
| **Likes (batched)** | In-app | - | Low priority, batch |
| **Weekly digest** | Email | - | Long-form content |
| **Appointment reminder** | SMS + Push | Email | Time-sensitive |

## Channel Selection Examples

### Security Alert (Highest Tier — Suspicious Activity)

Security alerts fan out by severity (see [Security Alert Channels](../transactional/authentication.md#security-alert-channels)). The example below is for a **high-severity** event like suspicious activity, 2FA disabled, or a password change — which earns the full channel set. For a lower-severity event like a new device login, drop SMS and Inbox and use `channels: ["email", "push"]`.

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrc06x1q5v8d2c6n4w9hj",
    data: {
      alertType: "suspicious_activity",
      device: "Chrome on Windows",
      location: "New York, USA",
      time: new Date().toISOString()
    },
    routing: {
      method: "all",
      channels: ["email", "push", "sms", "inbox"]
    }
  }
});
```

### Order Shipped

```typescript
// Email is the durable record; push is the awareness nudge.
// method: "all" silently skips channels the user has no contact info for
// (e.g., no push token → push is dropped, email is still sent).
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: {
      orderNumber: "12345",
      trackingUrl: "https://acme.co/track/12345",
      carrier: "FedEx",
      estimatedDelivery: "Thursday, Jan 30"
    },
    routing: {
      method: "all",
      channels: ["email", "push"]
    }
  }
});
```

### Activity Notification

```typescript
// Push for immediate, inbox for persistence
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbvb7x1q5v8d2c6n4w9hj",
    data: {
      commenterName: "Jane",
      commentPreview: "Great point about the API design!",
      postId: "post-456"
    },
    routing: {
      method: "all",
      channels: ["push", "inbox"]
    }
  }
});
```

## Escalation Patterns

### Time-Based Escalation

Start with less intrusive channel, escalate if no engagement:

```
T+0:      In-app notification
T+15min:  Push notification (if unread)
T+1hr:    Email (if still unread)
T+24hr:   SMS (if critical and still unread)
```

**Implementation with Courier Journeys (recommended):**

Build the escalation as a journey DAG with delay and send nodes. See [Journeys](./journeys.md) for the full create → template → wire → publish → invoke workflow.

```json
{
  "name": "Escalating Notification",
  "nodes": [
    { "id": "trigger-1", "type": "trigger", "trigger_type": "api-invoke" },
    { "id": "send-inbox", "type": "send", "message": { "template": "<inbox-template-id>" } },
    { "id": "wait-15m", "type": "delay", "mode": "duration", "duration": "PT15M" },
    {
      "id": "check-read",
      "type": "fetch",
      "method": "get",
      "url": "https://api.yourapp.com/notifications/{{notification_id}}/status",
      "merge_strategy": "overwrite"
    },
    {
      "id": "branch-read",
      "type": "branch",
      "paths": [
        {
          "label": "Already read",
          "conditions": ["data.read", "is equal", "true"],
          "nodes": [{ "id": "exit-read", "type": "exit" }]
        }
      ],
      "default": {
        "nodes": [
          { "id": "send-push", "type": "send", "message": { "template": "<push-template-id>" } },
          { "id": "wait-1h", "type": "delay", "mode": "duration", "duration": "PT1H" },
          { "id": "send-email", "type": "send", "message": { "template": "<email-template-id>" } }
        ]
      }
    }
  ],
  "enabled": true
}
```

Invoke the journey when the event occurs:

```bash
curl -sS -X POST "https://api.courier.com/journeys/$JOURNEY_ID/invoke" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "user-123",
    "data": { "approvalId": "apr-789", "requestedBy": "Jane" }
  }'
```

**Legacy: Automations approach** (if you have existing Automations):

```typescript
await client.automations.invoke.invokeByTemplate("escalating-notification", {
  recipient: "user-123",
  data: {
    template: "nt_01kmrc0f2q5x9v1d4c7n8w6hj",
    approvalId: "apr-789",
    requestedBy: "Jane"
  }
});
```

### Delivery-Based Escalation

Automatically try next channel when delivery fails:

```typescript
// Single routing method handles this automatically
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrc0n6x9q3v7d1c5n8w2hj",
    routing: {
      method: "single",
      channels: ["push", "email", "sms"] // Fallback chain
    }
  }
});
```

**Failure reasons that trigger fallback:**
- Missing contact info (no email address, no phone, no push token)
- Provider error (temporary or permanent)
- Invalid recipient (bounced email, invalid phone)

## Channel-Specific Formatting

Same notification, optimized for each channel:

### Email (Detailed)

```jsonc
// message.channels fragment — rich HTML with images, tracking details, etc.
{
  "email": {
    "override": {
      "subject": "Your order #12345 has shipped",
      "body": "<h1>Order shipped</h1><p>Tracking: <a href=\"https://acme.co/t/12345\">acme.co/t/12345</a></p>"
    }
  }
}
```

### Push (Concise)

```jsonc
// message.channels fragment
{
  "push": {
    "override": {
      "title": "Order shipped!",
      "body": "Arriving Thursday. Tap to track.",
      "data": { "deepLink": "acme://orders/12345" }
    }
  }
}
```

### SMS (Ultra-Short)

```jsonc
// message.channels fragment
{
  "sms": {
    "override": {
      "body": "Acme: Order #12345 shipped! Arrives Thu. Track: acme.co/t/12345"
    }
  }
}
```

### Complete Multi-Channel Example

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: {
      orderNumber: "12345",
      carrier: "FedEx",
      trackingNumber: "TRACK123",
      estimatedDelivery: "Thursday, January 30",
      items: [
        { name: "Widget Pro", quantity: 2 }
      ]
    },
    routing: {
      method: "all",
      channels: ["email", "push", "inbox"]
    },
    channels: {
      email: {
        override: {
          subject: "Your order #12345 has shipped!"
        }
      },
      push: {
        override: {
          title: "Order shipped! 📦",
          body: "Arriving Thursday. Tap to track.",
          data: {
            deepLink: "acme://orders/12345",
            image: "https://acme.com/images/package.png"
          }
        }
      }
    }
  }
});
```

## User Preferences Integration

### Respect User Choices

Let users control which channels they receive:

```typescript
// Check preferences before routing
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbu5x8q2v6d1c4n7w9hj",
    routing: {
      method: "single",
      channels: ["email", "inbox"] // Courier filters by user preference
    }
  }
});
```

Courier automatically checks user preferences and filters channels.

### Set Up Preference Topics

1. Create topics in Courier dashboard (Settings → Preferences)
2. Link templates to topics
3. Courier checks preferences before delivery

```typescript
// User's preference might be:
{
  items: [
    {
      topic_id: "weekly-digest",
      status: "OPTED_IN",
      has_custom_routing: true,
      custom_routing: ["email"]
    }
  ]
}
```

## Cross-Channel Coordination

### Deduplication

Prevent duplicate notifications with idempotency keys:

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: { orderId: "12345" }
  }
}, {
  headers: { "Idempotency-Key": `order-shipped-12345` }
});
```

### Read State Sync

Mark notification as read across channels when user engages with one:

```typescript
// When user opens email
app.post('/webhooks/email-opened', async (req, res) => {
  const { messageId, userId } = req.body;
  
  // Cancel the inbox notification to avoid duplicate engagement
  await client.messages.cancel(messageId);
  
  res.sendStatus(200);
});
```

### Cancellation

With [Journeys](./journeys.md), build exit logic directly into the DAG using branch nodes and exit nodes — the journey checks conditions before each step and exits early when appropriate. See [Patterns — Sequence Cancellation](./patterns.md#sequence-cancellation) for details.

**Legacy: Automations cancellation** (if you have existing Automations):

```typescript
await client.automations.invoke.invokeAdHoc({
  recipient: userId,
  automation: {
    steps: [
      { action: "cancel", cancelation_token: `approval-${approvalId}` }
    ]
  }
});
```

## Provider Failover

### Setup (Dashboard)

1. Go to **Channels** in the [Courier dashboard](https://app.courier.com/channels)
2. Add multiple providers for the same channel type (e.g., two email providers)
3. Drag to set priority order — Courier tries top-to-bottom
4. Each provider needs its own credentials (API key, etc.)

```
Email Providers (Priority Order):
1. SendGrid (Primary)     ← tried first
2. Mailgun (Backup)       ← tried if SendGrid fails
3. AWS SES (Last resort)  ← tried if both fail

SMS Providers:
1. Twilio (Primary)
2. MessageBird (Backup)
```

### Setup (API)

The same setup can be done over the API — configure the provider integrations via `/providers`, then define priority order per channel via a routing strategy's `channels.{channel}.providers` array. The position of a provider key in that array is the drag-to-reorder equivalent.

```typescript
// 1. Configure the provider integrations (one-time, usually from an IaC script).
//    See providers.md for the catalog discovery pattern and full CRUD.
await client.providers.create({
  provider: "sendgrid",
  settings: { api_key: process.env.SENDGRID_API_KEY!, from_address: "notifications@acme.co" }
});
await client.providers.create({
  provider: "aws-ses",
  settings: { access_key_id: process.env.AWS_ACCESS_KEY_ID!, secret_access_key: process.env.AWS_SECRET_ACCESS_KEY!, region: "us-east-1" }
});
await client.providers.create({
  provider: "twilio",
  settings: { account_sid: process.env.TWILIO_ACCOUNT_SID!, auth_token: process.env.TWILIO_AUTH_TOKEN!, messaging_service_sid: "MG..." }
});

// 2. Define priority order per channel via a routing strategy.
//    Array order in channels.{channel}.providers = failover priority.
await client.routingStrategies.create({
  name: "Transactional",
  routing: { method: "single", channels: ["email", "sms"] },
  channels: {
    email: { providers: ["sendgrid", "aws-ses"] }, // SendGrid first, SES fallback
    sms: { providers: ["twilio"] }
  }
});
```

- See [providers.md](./providers.md) for configuring provider integrations (catalog discovery, key rotation, per-provider `settings`).
- See [routing-strategies.md](./routing-strategies.md) for the full routing strategy CRUD, per-provider `override`/`if`/`timeouts`, and how to link the resulting `rs_...` to templates via `routing.strategy_id`.

### When Failover Triggers

Courier fails over to the next provider when:
- Provider returns a 5xx error (after built-in retries)
- Provider times out
- Provider-specific permanent error (e.g., invalid credentials)

Courier does NOT fail over for:
- Recipient-level failures (invalid email, bounced)
- Rate limiting (retries with backoff on same provider)
- Template rendering errors

### Checking Which Provider Was Used

Inspect the message delivery history to see which provider handled a send:

```bash
courier messages history --message-id "1-abc123" --format json
```

The history shows each provider attempt with timestamps and status.

### Provider-Specific Overrides

Pass provider-level configuration without changing dashboard settings:

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbr0s4y7qv2n8c5d1xj9k",
    providers: {
      twilio: {
        override: {
          messaging_service_sid: "MG..."
        }
      },
      sendgrid: {
        override: {
          ip_pool_name: "transactional"
        }
      }
    }
  }
});
```

## Monitoring & Analytics

### Track Per Channel

| Metric | What It Tells You |
|--------|-------------------|
| Delivery rate | % successfully delivered |
| Open/read rate | User engagement |
| Click-through rate | Action taken |
| Bounce/failure rate | Data quality issues |
| Unsubscribe rate | Channel fatigue |

### Track Routing Effectiveness

```typescript
// Analyze which channels perform best
const analytics = {
  notification: "order_shipped",
  channels: {
    email: { sent: 10000, opened: 4500, clicked: 2000 },
    push: { sent: 8000, opened: 6000, clicked: 3500 },
    sms: { sent: 5000, clicked: 2500 }
  }
};

// Push has highest engagement for this notification type
// Consider making push primary
```

## Best Practices

### Do

- **Match channel to content:** Long content → email, urgent → push/SMS
- **Respect preferences:** Let users choose their channels
- **Use fallbacks:** Have backup channels for critical notifications
- **Adapt content:** Optimize message for each channel's constraints
- **Track performance:** Monitor per-channel metrics

### Don't

- **Spam all channels:** Sending same notification everywhere annoys users
- **Ignore context:** User active in app? Don't also send email
- **Over-escalate:** Not everything needs SMS
- **Forget mobile:** 60%+ of notifications read on mobile

## Related

- [Routing Strategies](./routing-strategies.md) - Create/replace stored `rs_...` strategies via API
- [Providers](./providers.md) - Configure provider integrations via API (catalog, settings, rotation)
- [Preferences](./preferences.md) - User channel preferences
- [Reliability](./reliability.md) - Failover and retry logic
- [Batching]
...<truncated>
````


### `resources/guides/patterns.md`

````markdown
# Reusable Patterns

Copy-paste implementations for cross-cutting concerns that apply across notification types. Each pattern includes TypeScript, Python, CLI, and/or curl examples.

> **Reading order:** This file is the copy-paste companion. For the **concepts** behind idempotency, webhook handling, retries, and failover — including when to use which pattern and what the failure modes are — read [Reliability](./reliability.md) first, then come back here for code.

## Quick Reference

### Rules
- **Idempotency keys are sent as an `Idempotency-Key` HTTP header**, never inside `message`. In Node pass `{ headers: { "Idempotency-Key": "..." } }` as the second argument to `client.send.message`. In Python pass `extra_headers={"Idempotency-Key": "..."}`. On the REST API use the `Idempotency-Key` header directly. (The Node SDK's `idempotencyKey` request option may not be wired through in all SDK versions — always set the header explicitly to be safe. Verify against your installed `@trycourier/courier` version before relying on any other path. Python does not accept `idempotency_key=` at all.) The CLI does not yet have an `--idempotency-key` flag; for idempotent ad-hoc sends, use the SDK or curl. Keys are valid for 24 hours.
- **Check consent before growth/marketing sends**, not before transactional sends. Transactional notifications (password reset, OTP, receipts, security alerts) bypass marketing consent.
- **Quiet hours apply to non-urgent channels only.** Never delay OTP, password reset, security alerts, or critical account alerts, regardless of time.
- **Throttle checks should be per-user × per-category.** Global throttles are usually wrong — a user's OTP budget is not the same as their marketing budget.
- **Fallback routing (`method: "single"`) tries channels in order until one succeeds.** Use `method: "all"` only for genuinely multi-channel events (order shipped = email + push).
- **Webhook handlers must respond 200 fast** (< 3s) and do work async. Always verify the `courier-signature` header — see [reliability.md](./reliability.md#verify-webhook-signatures).
- **Retry only transient errors** (5xx, network, 429). Don't retry 4xx client errors, and don't retry indefinitely — cap attempts and use exponential backoff with jitter.
- **Aggregate repeated actors** to avoid "Alice liked your post" × 15. Use batching with a 5–30 minute window for social-style notifications.
- **Cancel scheduled messages** with `client.messages.cancel(messageId)` when the triggering condition becomes stale (e.g., cart abandonment after purchase).
- **Mask sensitive data** (emails, phones, card last-4) in notification bodies and templates before rendering. Never pass raw PII into notification content that could be logged, rendered in Inbox, or included in email preview text.
- **Bulk sends need `event`** (required on `client.bulk.createJob`); list/audience sends use `to: { list_id }` or `to: { audience_id }`.
- **Tenant-scoped sends** (B2B multi-tenant) pass `tenant_id` in `message` to pick up per-tenant brand and preference overrides.

### Pattern → when to use it

| Pattern | Use when | Skip when |
|---------|----------|-----------|
| [Idempotency Keys](#idempotency-keys) | Transactional sends where duplicates are harmful (OTP, payment confirmations, security alerts) | Marketing blasts, where a retry should produce a fresh send |
| [Consent Check](#consent-check) | Growth, marketing, weekly-digest sends | Transactional sends — consent is implied by the transaction |
| [Quiet Hours](#quiet-hours) | Non-urgent push, SMS, marketing | OTP, password reset, security alerts, outage notifications |
| [Throttle Check](#throttle-check) | You need per-user per-category frequency caps beyond Courier preferences | Global platform-level rate limiting (Courier handles provider limits) |
| [Multi-Channel Fallback](#multi-channel-fallback) | OTP, critical transactional, anything with a hard SLA | Events where you genuinely want every channel ("order shipped" → `method: "all"`) |
| [Webhook Handler](#webhook-handler) | You need to react to delivery events (bounces, clicks, undeliverable) | You only need to check status on demand (use `client.messages.retrieve`) |
| [Retry with Exponential Backoff](#retry-with-exponential-backoff) | Your ingest path calls `send.message` and must survive 5xx/network errors | Courier-to-provider retries — those are Courier's job |
| [Actor Aggregation](#actor-aggregation) | Social/activity notifications ("Alice liked", "Bob commented") | Transactional — never aggregate critical messages |
| [Sequence Cancellation](#sequence-cancellation) | Scheduled reminders whose triggering condition can go stale (cart, abandoned signup) | One-shot sends |
| [Data Masking](#data-masking) | Security alerts, change confirmations, any notification with PII in the body | Sends where the PII IS the value (order confirmation needs the address) |
| [Lists and Bulk Sends](#lists-and-bulk-sends) | Audience-scale delivery (newsletters, product-launch digests) | Single-recipient sends |
| [Tenants (Multi-Tenant / B2B)](#tenants-multi-tenant-b2b) | B2B apps where each customer org needs its own branding or preferences | Consumer apps with one brand |

### Common Mistakes
- Putting `Idempotency-Key` **inside** the `message` object instead of sending it as a request header
- Using the same idempotency key for "send OTP" across multiple attempts (a resend should have a distinct key — typically `otp-{userId}-{otpRequestId}`, keyed off the unique id of the OTP request from your own system)
- Checking consent for transactional sends (you'll lock legitimate users out of OTP/password reset)
- Applying quiet hours to security alerts
- Retrying 4xx errors (you'll hit the same validation failure forever)
- Aggregating OTP or security events
- Forgetting `event` on `client.bulk.createJob` (returns 400)
- Verifying webhooks by re-hashing the parsed JSON — always hash the raw request body concatenated with the timestamp, see [reliability.md](./reliability.md#verify-webhook-signatures)

## Idempotency Keys

Always use idempotency keys for transactional sends. Courier stores keys for 24 hours. See [Reliability](./reliability.md) for full idempotency guidance and key pattern table.

**TypeScript:**
```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbq6ypf25tsge12qek41r0",
    data: { orderId: "12345" }
  }
}, {
  headers: { "Idempotency-Key": `order-confirmation-12345` }
});
```

**Python:**
```python
from courier import Courier

client = Courier()

client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbq6ypf25tsge12qek41r0",
        "data": {"orderId": "12345"},
    },
    extra_headers={"Idempotency-Key": "order-confirmation-12345"},
)
```

**CLI:** The Courier CLI does not yet support idempotency keys directly on `courier send message`. For idempotent ad-hoc sends, use the SDK (above) or call the REST API directly with curl (below).

**curl:**
```bash
curl -X POST https://api.courier.com/send \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-confirmation-12345" \
  -d '{
    "message": {
      "to": { "user_id": "user-123" },
      "template": "nt_01kmrbq6ypf25tsge12qek41r0",
      "data": { "orderId": "12345" }
    }
  }'
```

Key pattern: `{notification-type}-{unique-id}`. For OTP and password reset, the unique id should be the per-request id from your own system (e.g. `otp-{userId}-{otpRequestId}`) — that way a retry of the same request is deduped, but a legitimately new user-initiated request gets a fresh key. See [Reliability > Idempotency Key Patterns](./reliability.md#idempotency-key-patterns) for the full table.

## Consent Check

Check user consent before sending growth/marketing notifications. Courier auto-checks preferences for templates linked to topics, but verify programmatically when needed.

**TypeScript:**
```typescript
async function hasConsent(userId: string, topic: string): Promise<boolean> {
  try {
    const prefs = await client.users.preferences.retrieve(userId);
    const topicPref = prefs.items?.find((p: any) => p.topic_id === topic);
    return topicPref?.status === "OPTED_IN";
  } catch {
    return false;
  }
}

if (await hasConsent(userId, "growth-notifications")) {
  await client.send.message({ ... });
}
```

**Python:**
```python
def has_consent(user_id: str, topic: str) -> bool:
    try:
        prefs = client.users.preferences.retrieve(user_id)
        topic_pref = next((t for t in prefs.items if t.topic_id == topic), None)
        return topic_pref is not None and topic_pref.status == "OPTED_IN"
    except Exception:
        return False

if has_consent(user_id, "growth-notifications"):
    client.send.message(message={...})
```

### Opt-In Record Structure

Store opt-in records so you can prove when and how a user consented:

```typescript
interface OptInRecord {
  userId: string;
  email?: string;
  phone?: string;
  consentedAt: Date;
  method: "web_form" | "sms_keyword" | "verbal" | "import";
  ipAddress?: string;
  consentText: string;       // Exact text user agreed to
  categories: string[];      // e.g. ["marketing", "product-updates"]
  expiresAt?: Date;          // Optional, for time-bound opt-ins
}
```

## Quiet Hours

Never send non-critical notifications during 10pm-8am in the user's local timezone.

**TypeScript:**
```typescript
function isQuietHours(timezone: string): boolean {
  const hour = parseInt(
    new Date().toLocaleString("en-US", {
      timeZone: timezone,
      hour: "numeric",
      hour12: false,
    })
  );
  return hour >= 22 || hour < 8;
}

async function sendWithQuietHours(
  userId: string,
  timezone: string,
  priority: "critical" | "high" | "medium" | "low",
  message: object
): Promise<void> {
  if (priority === "critical") {
    await client.send.message({ message });
    return;
  }

  if (isQuietHours(timezone)) {
    await queueForLater(userId, message, { deliverAt: "08:00", timezone });
    return;
  }

  await client.send.message({ message });
}
```

**Python:**
```python
from datetime import datetime
from zoneinfo import ZoneInfo

def is_quiet_hours(timezone: str) -> bool:
    hour = datetime.now(ZoneInfo(timezone)).hour
    return hour >= 22 or hour < 8
```

## Throttle Check

Enforce per-user notification limits by priority level. See [Throttling](./throttling.md) for full guidance.

```typescript
async function shouldSend(
  userId: string,
  priority: "critical" | "high" | "medium" | "low"
): Promise<boolean> {
  if (priority === "critical") return true;

  const count = await getNotificationCount(userId, "1h");
  const limits: Record<string, number> = {
    high: 20,
    medium: 10,
    low: 5,
  };

  return count < (limits[priority] ?? 10);
}
```

### Recommended Limits by Channel

| Channel | Limit | Window |
|---------|-------|--------|
| Push | 5-10 | Per hour |
| Email | 3-5 | Per day |
| SMS | 2-3 | Per day |
| In-app | 20-50 | Per hour |

## Multi-Channel Fallback

Standard pattern for sending with fallback channels. See [Multi-Channel](./multi-channel.md) for routing strategies, escalation patterns, and channel-specific formatting.

**TypeScript:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrc0n6x9q3v7d1c5n8w2hj",
    routing: {
      method: "single",
      channels: ["push", "email", "sms"]
    }
  }
});
```

**Python:**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrc0n6x9q3v7d1c5n8w2hj",
        "routing": {
            "method": "single",
            "channels": ["push", "email", "sms"],
        },
    }
)
```

**CLI:**
```bash
courier send message \
  --message.to '{"user_id":"user-123"}' \
  --message.template "nt_01kmrc0n6x9q3v7d1c5n8w2hj" \
  --message.routing '{"method":"single","channels":["push","email","sms"]}'
```

**curl:**
```bash
curl -X POST https://api.courier.com/send \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": {
      "to": { "user_id": "user-123" },
      "template": "nt_01kmrc0n6x9q3v7d1c5n8w2hj",
      "routing": {
        "method": "single",
        "channels": ["push", "email", "sms"]
      }
    }
  }'
```

For critical alerts that send to all channels simultaneously, use `method: "all"`. See [Multi-Channel > All Channels](./multi-channel.md#all-channels-method-all) for examples.

## Webhook Handler

Always respond 200 immediately and process asynchronously. Handle duplicates. In production, also verify the webhook signature — see [Reliability > Verify Webhook Signatures](./reliability.md#verify-webhook-signatures) for the full pattern.

**TypeScript (Express):**
```typescript
app.post("/webhooks/courier", async (req, res) => {
  res.sendStatus(200);

  const { type, data } = req.body;
  const messageId = data?.id;

  const alreadyProcessed = await cache.get(`webhook-${messageId}-${type}`);
  if (alreadyProcessed) return;
  await cache.set(`webhook-${messageId}-${type}`, true, { ttl: 86400 });

  await queue.add("process-webhook", req.body);
});
```

**Python (Flask):**
```python
@app.route("/webhooks/courier", methods=["POST"])
def courier_webhook():
    payload = request.get_json()
    event_type = payload.get("type")
    message_id = payload.get("data", {}).get("id")

    cache_key = f"webhook-{message_id}-{event_type}"
    if cache.get(cache_key):
        return "", 200
    cache.set(cache_key, True, ex=86400)

    queue.enqueue("process_webhook", payload)
    return "", 200
```

## Retry with Exponential Backoff

For retrying failed sends (5xx errors only, never retry 4xx). See [Reliability > Retry Logic](./reliability.md#retry-logic) for full guidance.

**TypeScript:**
```typescript
async function sendWithRetry(
  message: object,
  maxAttempts: number = 3
): Promise<void> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      await client.send.message({ message });
      return;
    } catch (error: any) {
      if (error.status >= 400 && error.status < 500) {
        throw error;
      }
      if (attempt === maxAttempts - 1) {
        throw error;
      }
      const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
      await new Promise((r) => setTimeout(r, delay + Math.random() * 1000));
    }
  }
}
```

**Python:**
```python
import time
import random
from courier import Courier

def send_with_retry(client: Courier, message: dict, max_attempts: int = 3):
    for attempt in range(max_attempts):
        try:
            return client.send.message(message=message)
        except Exception as e:
            status = getattr(e, "status_code", 500)
            if 400 <= status < 500:
                raise
            if attempt == max_attempts - 1:
                raise
            delay = min(2 ** attempt, 30) + random.random()
            time.sleep(delay)
```

## Actor Aggregation

Format actor names for batched notifications ("Jane and 5 others").

**TypeScript:**
```typescript
function formatActors(names: string[]): string {
  if (names.length === 1) return names[0];
  if (names.length === 2) return `${names[0]} and ${names[1]}`;
  const remaining = names.length - 2;
  return `${names[0]}, ${names[1]}, and ${remaining} ${remaining === 1 ? "other" : "others"}`;
}
```

**Python:**
```python
def format_actors(names: list[str]) -> str:
    if len(names) == 1:
        return names[0]
    if len(names) == 2:
        return f"{names[0]} and {names[1]}"
    remaining = len(names) - 2
    noun = "other" if remaining == 1 else "others"
    return f"{names[0]}, {names[1]}, and {remaining} {noun}"
```

## Sequence Cancellation

End a multi-step sequence when the user takes the desired action. With [Journeys](./journeys.md), build exit logic directly into the DAG using branch nodes and exit nodes — the journey checks a condition before each step and exits early when the goal is met.

**Journeys approach (recommended):**

Design the journey with a branch node that checks whether the user has activated before continuing. This makes cancellation part of the flow itself — no external cancel call needed.

```json
{
  "id": "check-activated",
  "type": "fetch",
  "method": "get",
  "url": "https://api.yourapp.com/users/{{user_id}}/status",
  "merge_strategy": "overwrite"
},
{
  "id": "branch-activated",
  "type": "branch",
  "paths": [
    {
      "label": "User activated",
      "conditions": ["data.activated", "is equal", "true"],
      "nodes": [{ "id": "exit-activated", "type": "exit" }]
    }
  ],
  "default": {
    "label": "Continue sequence",
    "nodes": [
      { "id": "send-reminder", "type": "send", "message": { "template": "<template-id>" } },
      { "id": "wait-2d", "type": "delay", "mode": "duration", "duration": "P2D" }
    ]
  }
}
```

See [Journeys](./journeys.md) for the full workflow (create → template → wire → publish → invoke).

**Legacy: Automations approach** (if you have existing Automations):

```typescript
await client.automations.invoke.invokeByTemplate("onboarding-sequence", {
  recipient: "user-123",
  data: {
    userName: "Jane",
    cancelation_token: `onboarding-${userId}`
  }
});

// Later, when user activates
await client.automations.invoke.invokeAdHoc({
  recipient: userId,
  automation: {
    steps: [
      { action: "cancel", cancelation_token: `onboarding-${userId}` }
    ]
  }
});
```

```python
client.automations.invoke.invoke_by_template(
    "onboarding-sequence",
    recipient="user-123",
    data={
        "userName": "Jane",
        "cancelation_token": f"onboarding-{user_id}",
    },
)

# Later, when user activates
client.automations.invoke.invoke_ad_hoc(
    recipient=user_id,
    automation={
        "steps": [
            {"action": "cancel", "cancelation_token": f"onboarding-{user_id}"}
        ]
    },
)
```

## Data Masking

Mask sensitive data in notification content. Required for security change notifications.

**TypeScript:**
```typescript
function maskEmail(email: string): string {
  const [local, domain] = email.split("@");
  return `${local[0]}${"*".repeat(Math.max(local.length - 2, 1))}${local.slice(-1)}@${domain}`;
}

function maskPhone(phone: string): string {
  return `***-***-${phone.slice(-4)}`;
}

function maskCard(last4: string): string {
  return `****-****-****-${last4}`;
}
```

**Python:**
```python
def mask_email(email: str) -> str:
    local, domain = email.split("@")
    return f"{local[0]}{'*' * max(len(local) - 2, 1)}{local[-1]}@{domain}"

def mask_phone(phone: str) -> str:
    return f"***-***-{phone[-4:]}"

def mask_card(last4: str) -> str:
    return f"****-****-****-{last4}"
```

## Lists and Bulk Sends

Send to groups of users via lists, or use the Bulk API for large audiences.

### Lists

**TypeScript:**
```typescript
await client.lists.update("beta-testers", { name: "Beta Testers" });

// Add a single user to a list (additive; does not overwrite existing subscribers).
await client.lists.subscriptions.subscribeUser("user-123", { list_id: "beta-testers" });
await client.lists.subscriptions.subscribeUser("user-456", { list_id: "beta-testers" });

// Or replace the full subscriber set in one call:
// await client.lists.subscriptions.subscribe("beta-testers", {
//   recipients: [{ recipientId: "user-123" }, { recipientId: "user-456" }],
// });

await client.send.message({
  message: {
    to: { list_id: "beta-testers" },
    template: "nt_01kmrbs3q6w9x2c5v8n1d4tjh",
    data: { feature: "Design Studio" },
  },
});
```

**Python:**
```python
client.lists.update("beta-testers", name="Beta Testers")

# Add a single user to a list (additive; does not overwrite existing subscribers).
client.lists.subscriptions.subscribe_user("user-123", list_id="beta-testers")
clien
...<truncated>
````


### `resources/guides/preferences.md`

````markdown
# User Preferences

## Quick Reference

### Rules
- Transactional notifications: user usually CANNOT opt out (security, orders, receipts)
- Marketing notifications: MUST have explicit opt-in, MUST allow opt-out
- Product notifications: default on, allow opt-out
- Honor preference changes immediately
- Link to preferences in every email footer
- Provide one-click unsubscribe for marketing
- Opt-out must be easy — one click or one reply

### Category Defaults
| Category | Default | Can Opt Out? |
|----------|---------|--------------|
| Security alerts | ON | No |
| Order updates | ON | No |
| Password reset | ON | No |
| Product updates | ON | Yes |
| Activity (mentions) | ON | Yes |
| Weekly digest | ON | Yes |
| Marketing/Promotions | OFF | Yes (opt-in required) |
| Newsletter | OFF | Yes (opt-in required) |

### Common Mistakes
- Allowing opt-out from security/transaction notifications
- Pre-checking marketing opt-in boxes (invalid consent)
- Making opt-out difficult
- Not honoring preference changes immediately
- No unsubscribe link in marketing emails
- Too many preference categories (confuses users)
- Too few categories (users unsubscribe entirely)

### Templates

**Get User Preferences:**

TypeScript:
```typescript
const prefs = await client.users.preferences.retrieve("user-123");
```

Python:
```python
prefs = client.users.preferences.retrieve("user-123")
```

**Update Preferences:**

The topic (`weekly-digest`) must already exist in Settings > Preferences; these calls set the *user's* preference for that topic, they do not create the topic definition itself. Calling for an unknown topic returns 404.

TypeScript:
```typescript
await client.users.preferences.updateOrCreateTopic("weekly-digest", {
  user_id: "user-123",
  topic: {
    status: "OPTED_OUT"
  }
});
```

Python:
```python
client.users.preferences.update_or_create_topic(
    "weekly-digest",
    user_id="user-123",
    topic={"status": "OPTED_OUT"},
)
```

**Check Before Sending:**

Courier auto-checks when topics are configured — link templates to topics in the Courier dashboard.

TypeScript:
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbu5x8q2v6d1c4n7w9hj" // Template linked to "weekly-digest" topic
  }
});
```

Python:
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbu5x8q2v6d1c4n7w9hj",  # Template linked to "weekly-digest" topic
    },
)
```

---

Let users control what notifications they receive and how.

## Why Preferences Matter

- **Reduce unsubscribes:** Users who can control frequency don't opt out entirely
- **Better engagement:** Users receive what they want, when they want
- **Trust:** Respecting preferences builds trust

## Preference Types

### Channel Preferences

Which channels can we use to reach this user?

- Email: enabled/disabled
- Push: enabled/disabled
- SMS: enabled/disabled
- Slack: enabled/disabled

### Category Preferences

What types of notifications does the user want?

- **Transactional (usually can't opt out):** Order updates, security alerts
- **Product:** Product updates, activity mentions, weekly digest
- **Marketing (requires opt-in):** Promotions, newsletter

### Frequency Preferences

How often?

- Realtime
- Daily digest
- Weekly digest
- Monthly
- Off

## Courier Preferences API

### Create Topics (Dashboard)

Topics are the categories users can opt into/out of. Set them up in the Courier dashboard:

1. Go to **Settings > Preferences** in the [Courier dashboard](https://app.courier.com/settings/preferences)
2. Click **Create Topic** — give it an ID (e.g., `weekly-digest`), name, and description
3. Set the **default status**: `OPTED_IN` (product notifications) or `REQUIRED` (transactional — user can't opt out)
4. Set allowed channels for this topic

### Link Templates to Topics

Linking a template to a topic means Courier auto-checks the user's preference before sending. If the user has opted out, the send is silently skipped — no error returned.

1. Open a template in the [Designer](https://app.courier.com/designer)
2. In template settings, select the **Preference Topic** this template belongs to
3. When `client.send.message()` uses this template, Courier checks the user's preference for that topic

### How Preferences Interact with Routing

When both routing and preferences are configured:

1. Courier builds the channel list from `routing.channels`
2. It removes any channels the user has disabled for the linked topic
3. With `method: "single"`, it tries remaining channels in order
4. If no channels remain (user opted out of all), the message is silently skipped

Example: routing specifies `["email", "push", "sms"]` but user opted out of SMS for this topic → Courier only tries email and push.

### Get User Preferences

**TypeScript:**
```typescript
const preferences = await client.users.preferences.retrieve("user-123");
// Returns: { items: [{ topic_id, status, ... }] }
```

**Python:**
```python
preferences = client.users.preferences.retrieve("user-123")
# Returns an object with `.items` — list of { topic_id, status, ... }
```

### Update Preferences for a Topic

**TypeScript:**
```typescript
await client.users.preferences.updateOrCreateTopic("order-updates", {
  user_id: "user-123",
  topic: {
    status: "OPTED_IN", // or "OPTED_OUT"
    has_custom_routing: true,
    custom_routing: ["email"]
  }
});
```

**Python:**
```python
client.users.preferences.update_or_create_topic(
    "order-updates",
    user_id="user-123",
    topic={
        "status": "OPTED_IN",  # or "OPTED_OUT"
        "has_custom_routing": True,
        "custom_routing": ["email"],
    },
)
```

## Preference Center UI

### Courier Preferences Component

Courier provides a pre-built preference center component. For v8 React apps, `@trycourier/courier-react` includes `<CourierPreferences />` which renders a ready-made UI for topic opt-in/opt-out. For setup and customization options, see the [Courier Preferences docs](https://www.courier.com/docs/sdk-libraries/courier-react/preferences).

### Custom Preference Center

Build your own UI with these sections:

**Essential Notifications (can't turn off)**
- Security alerts
- Order and payment updates

**Product Updates**
- Activity (mentions, comments, replies)
- Weekly digest
- New feature announcements

**Channels**
- Email
- Push notifications
- SMS

**Marketing**
- Promotions and offers
- Newsletter

Include a "Save Preferences" button and an option to unsubscribe from all marketing emails.

## Implementing Preferences

### Check Before Sending

Before sending a notification, check if the user has opted into that category. If opted out, skip sending.

### Courier Handles It

When you set up categories in Courier and link templates to categories, Courier automatically checks preferences before sending.

## Category Structure

### Example Hierarchy

```
notifications/
├── essential/           # Can't opt out
│   ├── security
│   ├── account
│   └── orders
├── product/             # Default on, can opt out
│   ├── activity
│   ├── digest
│   └── features
└── marketing/           # Default off, must opt in
    ├── promotions
    └── newsletter
```

### Category Configuration

For each category, define:
- Name (display name)
- Description
- Default status (opted in or opted out)
- Whether user can opt out

## Channel + Category Matrix

Users can control both what and how:

|                     | Email | Push | SMS |
|---------------------|-------|------|-----|
| Security alerts     | Required | Required | Required at highest severity tier only (see below) |
| Order updates       | Yes | Yes | Optional |
| Activity            | Yes | Yes | Optional |
| Weekly digest       | Yes | No | No |
| Promotions          | Optional | Optional | Optional |

"Required" means the user can't opt out — it does **not** mean every alert uses every required channel. Security-alert fan-out is tiered by event severity (see [Security Alert Channels](../transactional/authentication.md#security-alert-channels)); SMS is only sent for the highest-severity events (password changed, 2FA disabled, suspicious activity), not for every new device login.

## Frequency Preferences

### Options

- **Realtime:** As events happen
- **Daily digest:** Once per day
- **Weekly digest:** Once per week
- **Off:** Don't send

### Implementation

If the user prefers digest for a category, queue notifications for batched sending instead of immediate delivery — **but only for categories that are safe to batch**. Transactional categories that [batching.md](./batching.md) excludes (OTP, password reset, security alerts, order confirmations, payment receipts, shipping) must always send in real time regardless of the user's digest preference. Enforce the blocklist in your digest dispatcher; don't rely on UI to prevent users from opting these into a digest.

## Best Practices

### Default to On (Carefully)

- **Transactional:** Always on, can't opt out
- **Product:** Default on, easy to opt out
- **Marketing:** Default off, require opt in

### Make It Easy

- Link to preferences in every email footer
- Provide preference center in app settings
- Allow quick opt-out without login

### Granularity Balance

- Too few options: Users unsubscribe entirely
- Too many options: Users confused, don't set
- **Sweet spot:** 5-10 meaningful categories

### Respect Immediately

When user changes preference:
1. Update preference immediately
2. If opted out, cancel any pending notifications in that category

## Unsubscribe Handling

### One-Click Unsubscribe

Generate unique unsubscribe links for each email. Gmail and Yahoo require list-unsubscribe headers for bulk senders; Courier sets these automatically when you configure an unsubscribe link.

### Unsubscribe vs Preference Center

Offer both options:
- Quick unsubscribe from specific category
- Link to preference center for more control
- Option to resubscribe

## Related

- [Multi-Channel](./multi-channel.md) - Channel routing
- [Campaigns](../growth/campaigns.md) - Marketing opt-in requirements
- [Hosted Preference Page](https://www.courier.com/docs/platform/preferences/hosted-page) - Deploy a hosted preference page in minutes
- [Embedding Preferences](https://www.courier.com/docs/platform/preferences/embedding-preferences) - Embeddable React components for building a preference center into your app

````


### `resources/guides/providers.md`

````markdown
# Providers

Configure provider integrations (SendGrid, Twilio, APNS, Slack, etc.) over the `/providers` API. A "provider" here is an instance of a Courier integration in your workspace — i.e. the thing that holds the API key, `from_address`, `messaging_service_sid`, and so on, that Courier uses when delivering a message through that channel.

## Quick Reference

### Rules
- The `provider` field on create must be a **catalog key** (e.g. `"sendgrid"`, `"twilio"`, `"apn"`, `"firebase-fcm"`, `"slack"`). Fetch `/providers/catalog` for the canonical list — do not guess
- `settings` keys are **snake_case on the wire** (e.g. `api_key`, `from_address`, `ip_pool_name`). Each provider's required `settings` schema comes from `/providers/catalog`
- `settings` holds **secrets**. Read them from environment variables; never hardcode in committed code
- `PUT /providers/{id}` is a **full replacement** — always read the current settings, merge, and write back, or you will drop fields
- Provider IDs are opaque workspace-scoped values returned on create (distinct from the catalog `provider` key)
- `title` defaults to `"Default Configuration"` when omitted. For multiple configs of the same provider (e.g. two SendGrid sub-accounts), set a unique `title` and `alias`
- Routing strategies reference providers by their **catalog key** (e.g. `"sendgrid"`) in `channels.{channel}.providers`. If you need multiple configs for the same provider key (two SendGrid sub-accounts, etc.), set distinct `title`/`alias` values and disambiguate in the dashboard — the docs don't yet spec an API-only way to target a specific configuration from a routing strategy

### API vs Dashboard
Prefer the API for:
- Infrastructure-as-code / Terraform-style workspace provisioning
- Multi-workspace rollouts (dev → staging → prod with the same scripted setup)
- AI-agent workflows where the agent already has env-var access to provider credentials
- Programmatic rotation of `api_key`/`auth_token` values

Prefer the dashboard for:
- Day-to-day human credential management (encrypted at rest, team-audited, OAuth flows where available)
- First-time setup of a provider you haven't configured before — the dashboard surfaces required fields interactively
- Providers that use OAuth-based setup (Slack, Gmail, MS Teams, Discord) — these generally still require dashboard-initiated auth

### Common Mistakes
- Guessing `settings` keys instead of reading them from `/providers/catalog` — hallucinated keys are silently ignored and the provider will fail at send time
- Using `provider` values from documentation prose that don't match the catalog (e.g. `"aws_ses"` vs the real `"aws-ses"`, or `"fcm"` vs `"firebase-fcm"`)
- Using `PUT` with only the fields you want to change — it's a full replacement, so any omitted `settings` are wiped
- Committing an `api_key` directly in a `providers.create` call instead of reading `process.env.SENDGRID_API_KEY`
- Creating a provider config via API for a catalog entry that requires OAuth — those need to be initiated from the dashboard

### SDK shape

| Operation | Node | Python |
|-----------|------|--------|
| Create | `client.providers.create({ provider, settings?, title?, alias? })` | `client.providers.create(provider=..., settings=..., ...)` |
| List | `client.providers.list({ cursor? })` | `client.providers.list(cursor=...)` |
| Retrieve | `client.providers.retrieve(id)` | `client.providers.retrieve(id)` |
| Replace | `client.providers.replace(id, { provider, settings, ... })` | `client.providers.replace(id, provider=..., settings=..., ...)` |
| Archive | `client.providers.archive(id)` | `client.providers.archive(id)` |
| Catalog list | `client.providers.catalog.list({ keys?, name?, channel? })` | `client.providers.catalog.list(keys=..., name=..., channel=...)` |

> Providers endpoints are **not yet listed in `https://www.courier.com/docs/llms.txt`**. Fetch these URLs directly when you need the full OpenAPI spec:
> - `https://www.courier.com/docs/api-reference/providers/create-a-provider`
> - `https://www.courier.com/docs/api-reference/providers/list-available-provider-types`
> - `https://www.courier.com/docs/api-reference/providers/list-providers`

---

## Discover Required Settings (`/providers/catalog`)

Before creating a provider config, call the catalog to get the exact `settings` schema. Each field descriptor has `type` and `required`; `enum` types also have `values`.

**TypeScript:**
```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

const catalog = await client.providers.catalog.list({ keys: "sendgrid" });

for (const entry of catalog.results) {
  console.log(entry.provider, entry.channel, entry.settings);
  // entry.settings → { api_key: { type: "string", required: true }, ... }
}
```

**Python:**
```python
from courier import Courier

client = Courier()

catalog = client.providers.catalog.list(keys="sendgrid")

for entry in catalog.results:
    print(entry.provider, entry.channel, entry.settings)
```

**curl:**
```bash
# Single provider
curl -s "https://api.courier.com/providers/catalog?keys=sendgrid" \
  -H "Authorization: Bearer $COURIER_API_KEY"

# All email providers
curl -s "https://api.courier.com/providers/catalog?channel=email" \
  -H "Authorization: Bearer $COURIER_API_KEY"

# Substring search on display name
curl -s "https://api.courier.com/providers/catalog?name=grid" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

Use the returned `settings` map to build your `providers.create` payload. Any field with `required: true` must be present.

---

## Create a Provider

### SendGrid (worked example)

After consulting the catalog you know SendGrid needs `api_key` (required) and optionally `from_address`, `ip_pool_name`, etc.

**TypeScript:**
```typescript
const provider = await client.providers.create({
  provider: "sendgrid",
  title: "SendGrid — Production",
  alias: "sendgrid-prod",
  settings: {
    api_key: process.env.SENDGRID_API_KEY!,
    from_address: "notifications@acme.co"
  }
});

const providerId = provider.id;
```

**Python:**
```python
import os

provider = client.providers.create(
    provider="sendgrid",
    title="SendGrid — Production",
    alias="sendgrid-prod",
    settings={
        "api_key": os.environ["SENDGRID_API_KEY"],
        "from_address": "notifications@acme.co",
    },
)

provider_id = provider.id
```

**curl:**
```bash
curl -X POST "https://api.courier.com/providers" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"provider\": \"sendgrid\",
    \"title\": \"SendGrid — Production\",
    \"alias\": \"sendgrid-prod\",
    \"settings\": {
      \"api_key\": \"$SENDGRID_API_KEY\",
      \"from_address\": \"notifications@acme.co\"
    }
  }"
```

### Twilio

```typescript
await client.providers.create({
  provider: "twilio",
  settings: {
    account_sid: process.env.TWILIO_ACCOUNT_SID!,
    auth_token: process.env.TWILIO_AUTH_TOKEN!,
    messaging_service_sid: "MG..." // or `from` for a single number
  }
});
```

After creating the provider, reference it by catalog key in a routing strategy — see [routing-strategies.md](./routing-strategies.md):

```typescript
await client.routingStrategies.create({
  name: "Transactional",
  routing: { method: "single", channels: ["email", "sms"] },
  channels: {
    email: { providers: ["sendgrid"] },
    sms: { providers: ["twilio"] }
  }
});
```

---

## List, Retrieve, Replace, Archive

### List

**TypeScript:**
```typescript
const { results, paging } = await client.providers.list();
for (const p of results) console.log(p.id, p.provider, p.title, p.alias);
```

**Python:**
```python
response = client.providers.list()
for p in response.results:
    print(p.id, p.provider, p.title, p.alias)
```

**curl:**
```bash
curl -s "https://api.courier.com/providers" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

### Retrieve

**TypeScript:**
```typescript
const provider = await client.providers.retrieve("provider_01abc123");
```

**Python:**
```python
provider = client.providers.retrieve("provider_01abc123")
```

### Replace (rotate an API key safely)

Replace is full-document — read, modify, write back.

**TypeScript:**
```typescript
const current = await client.providers.retrieve("provider_01abc123");

await client.providers.replace("provider_01abc123", {
  provider: current.provider,
  title: current.title,
  alias: current.alias,
  settings: {
    ...current.settings,
    api_key: process.env.SENDGRID_API_KEY_NEW! // rotated key
  }
});
```

**Python:**
```python
current = client.providers.retrieve("provider_01abc123")

client.providers.replace(
    "provider_01abc123",
    provider=current.provider,
    title=current.title,
    alias=current.alias,
    settings={**current.settings, "api_key": os.environ["SENDGRID_API_KEY_NEW"]},
)
```

### Archive

**TypeScript:**
```typescript
await client.providers.archive("provider_01abc123");
```

**Python:**
```python
client.providers.archive("provider_01abc123")
```

**curl:**
```bash
curl -X DELETE "https://api.courier.com/providers/provider_01abc123" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

Unlink the provider from any routing strategies (remove its key from each `channels.{channel}.providers` array) before archiving to avoid silent send failures.

---

## Per-send provider overrides (recap)

You don't need to create or replace a provider config to tweak a single send. Pass `providers.{key}.override` on the Send API instead:

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_...",
    providers: {
      sendgrid: { override: { ip_pool_name: "marketing" } },
      twilio: { override: { messaging_service_sid: "MG..." } }
    }
  }
});
```

See [multi-channel.md](./multi-channel.md#provider-failover) for failover semantics and [routing-strategies.md](./routing-strategies.md) for workspace-level provider priority.

---

## Related

- [Routing Strategies](./routing-strategies.md) — reference providers in `channels.{channel}.providers`
- [Multi-Channel](./multi-channel.md) — failover semantics, per-send `providers.{key}.override`
- [Email](../channels/email.md), [SMS](../channels/sms.md), [Push](../channels/push.md) — channel-specific deliverability considerations
- [Create a provider](https://www.courier.com/docs/api-reference/providers/create-a-provider) — official endpoint reference
- [List available provider types](https://www.courier.com/docs/api-reference/providers/list-available-provider-types) — `/providers/catalog` reference
- [Integrations Overview](https://www.courier.com/docs/external-integrations/integrations-overview) — human-facing integration docs

<!-- Target line budget: <= 500 lines. -->

````


### `resources/guides/quickstart.md`

````markdown
# Quickstart

Send your first notification with Courier using the SDK, CLI, or curl.

## Quick Reference

### Rules
- You need a Courier API key from [Settings > API Keys](https://app.courier.com/settings/api-keys)
- Use a test environment key for development — copy the "Test" key from Settings
- Inbox works out of the box with no provider setup. Email works out of the box in **fresh** test workspaces (Courier provisions a built-in email provider), but if the workspace's email routing list references providers that aren't currently installed you'll see `status: UNROUTABLE` with `reason: PROVIDER_ERROR` — add or remove the referenced providers in [Integrations](https://app.courier.com/integrations). SMS, push, and other channels always require adding a provider first.
- All tools read `COURIER_API_KEY` from the environment (SDK, CLI, curl)
- Both `import Courier from "@trycourier/courier"` (default export) and `import { Courier } from "@trycourier/courier"` (named export) work — the named export is `Courier`, not `CourierClient`
- Always use idempotency keys for transactional sends in production
- Check delivery status via the Messages API or Courier dashboard

### Addressing Modes (the `to` field)
| Mode | Syntax | When to use |
|------|--------|-------------|
| Direct email | `to: { email: "jane@example.com" }` | Quick send, no profile needed |
| Direct phone | `to: { phone_number: "+15551234567" }` | SMS/WhatsApp, no profile needed |
| User profile | `to: { user_id: "user-123" }` | User exists in Courier with stored contact info |
| Multi-tenant | `to: { user_id: "user-123", tenant_id: "acme-corp" }` | B2B apps with per-tenant branding |
| List | `to: { list_id: "beta-testers" }` | Send to a group of users |
| List pattern | `to: { list_pattern: "eng.*" }` | Send to all lists matching a prefix |

If a `user_id` has no contact info for the target channel, the send is skipped for that channel (no error). With `method: "single"`, it falls through to the next channel in the array.

### Steps
1. Get an API key
2. Install SDK or CLI
3. Send a message
4. Check delivery status

---

## 1. Get an API Key

Create an API key in your [Courier Settings](https://app.courier.com/settings/api-keys). Use a test key for development.

## 2. Install and Authenticate

**TypeScript/Node.js:**

```bash
npm install @trycourier/courier
export COURIER_API_KEY="your-api-key"
```

**Python:**

```bash
pip install trycourier
export COURIER_API_KEY="your-api-key"
```

**CLI:**

```bash
npm install -g @trycourier/cli
export COURIER_API_KEY="your-api-key"
```

The SDK, CLI, and curl all read `COURIER_API_KEY` from the environment. The examples below use `$COURIER_API_KEY`.

You can also pass the key explicitly in code:

```typescript
const client = new Courier({ apiKey: "your-api-key" });
// `authorizationToken` is accepted as an alias in Node for legacy callers.
```

```python
client = Courier(api_key="your-api-key")
# Python's Stainless-generated client accepts `api_key=` only —
# `authorization_token=` raises TypeError.
```

## 3. Send a Message

### TypeScript

```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

const response = await client.send.message({
  message: {
    to: { email: "jane@example.com" },
    content: {
      title: "Hello from Courier",
      body: "This is your first notification!"
    }
  }
});

console.log(response.requestId);
```

### Python

```python
from courier import Courier

client = Courier()

response = client.send.message(
    message={
        "to": {"email": "jane@example.com"},
        "content": {
            "title": "Hello from Courier",
            "body": "This is your first notification!",
        },
    }
)

print(response.request_id)
```

### CLI

```bash
courier send message \
  --message.to '{"email":"jane@example.com"}' \
  --message.content '{"title":"Hello from Courier","body":"This is your first notification!"}'
```

### curl

```bash
curl -X POST https://api.courier.com/send \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": {
      "to": { "email": "jane@example.com" },
      "content": {
        "title": "Hello from Courier",
        "body": "This is your first notification!"
      }
    }
  }'
```

## 4. Check Delivery Status

Use the `requestId` from the send response to check delivery status.

The `requestId` returned by `send.message` doubles as the message ID for single sends, so you can pass it directly to `messages.retrieve` (and to the CLI's `--message-id` flag). For list and bulk sends this is not true — see the [CLI debugging note](./cli.md#debugging-list-bulk-sends-requestid-vs-message-id).

Common values you'll see in `message.status`: `ENQUEUED` → `ROUTED` → `SENT` → `DELIVERED` on the happy path; `UNROUTABLE` (routing failed — no channel/provider), `UNDELIVERABLE` (every provider attempt failed), `FAILED` (platform error). Final states can take seconds after send.

### TypeScript

```typescript
// `response` is the result of `client.send.message(...)` from step 3.
const message = await client.messages.retrieve(response.requestId);
console.log(message.status); // ENQUEUED | ROUTED | SENT | DELIVERED | UNROUTABLE | UNDELIVERABLE | FAILED
```

### Python

```python
# `response` is the result of `client.send.message(...)` from step 3.
message = client.messages.retrieve(response.request_id)
print(message.status)
```

### CLI

For single sends, the `requestId` from `courier send message` is also the message ID — pass it to `--message-id`:

```bash
courier messages retrieve --message-id "REQUEST_ID" --format json
```

### curl

```bash
curl https://api.courier.com/messages/REQUEST_ID \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

## 5. Send with a Template

Templates are designed in the [Courier Designer](https://app.courier.com/designer). Reference them by template ID (`nt_...`).
Use your own workspace template IDs in production; IDs shown below are examples.

### TypeScript

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbq6ypf25tsge12qek41r0",
    data: { orderId: "12345", total: "$99.00" }
  }
});
```

### Python

```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbq6ypf25tsge12qek41r0",
        "data": {"orderId": "12345", "total": "$99.00"},
    }
)
```

### CLI

```bash
courier send message \
  --message.to '{"user_id":"user-123"}' \
  --message.template "nt_01kmrbq6ypf25tsge12qek41r0" \
  --message.data '{"orderId": "12345", "total": "$99.00"}'
```

## Common First-Run Errors

| Error | Cause | Fix |
|-------|-------|-----|
| 401 Unauthorized | Missing or invalid API key | Check `COURIER_API_KEY` env var; verify key in [Settings](https://app.courier.com/settings/api-keys) |
| 400 Bad Request | Malformed message payload | Verify `to`, `content`/`template` structure; check required fields |
| 404 Not Found | Template ID doesn't exist | Use the correct `nt_...` template ID from the Designer/API; check environment (test vs production) |
| Message sent but not delivered | No provider configured for channel | Email works in fresh test workspaces without setup; otherwise, add a provider in [Integrations](https://app.courier.com/integrations) |
| Sent to `user_id` but nothing happened | User profile missing contact info | Create profile with `email`/`phone_number` first, or send directly with `to: { email: "..." }` |
| `status: UNROUTABLE`, `reason: PROVIDER_ERROR` | Routing list references a provider that isn't installed. Error string like `"No provider(s) resend in the list of message channel provider(s): postmark."` means the routing has `resend` listed but only `postmark` is installed (or vice versa). | Install the referenced provider in [Integrations](https://app.courier.com/integrations), or edit the channel's routing list to remove it. |
| `status: UNROUTABLE` with no provider error | No channel in `routing.channels` has a provider the user can reach (no contact info, no installed provider, or all opted out via preferences) | Verify `to`, `routing.channels`, installed providers, and user preferences. |
| `404` from `messages.retrieve` for a list/bulk send | You passed the job-level `requestId`, which isn't the per-recipient message ID | For list/bulk sends, look up per-recipient message IDs via `courier messages list --trace-id "<requestId>"`. See [CLI debugging](./cli.md#debugging-list-bulk-sends-requestid-vs-message-id). |

## What's Next

| Goal | Guide |
|------|-------|
| Send via multiple channels (email, SMS, push) | [Multi-Channel](./multi-channel.md) |
| Add idempotency keys and retry logic | [Reliability](./reliability.md) |
| Manage user notification preferences | [Preferences](./preferences.md) |
| Use the CLI for debugging and agent workflows | [CLI](./cli.md) |
| Build a specific notification type | [Catalog](./catalog.md) |
| Build multi-step notification sequences | [Journeys](./journeys.md) |

## Related

- [Email](../channels/email.md) - Email deliverability and SPF/DKIM/DMARC
- [SMS](../channels/sms.md) - SMS setup and 10DLC
- [Push](../channels/push.md) - Push notification setup
- [Patterns](./patterns.md) - Reusable code patterns

````


### `resources/guides/reliability.md`

````markdown
# Reliability

> **Reading order:** This file covers **concepts and failure modes** (idempotency semantics, retry strategy, webhook delivery guarantees, provider failover). For **copy-paste code** implementing these patterns in TypeScript, Python, CLI, and curl, see [Patterns](./patterns.md).

## Quick Reference

### Rules
- ALWAYS use idempotency keys for transactional notifications
- Courier stores idempotency keys for 24 hours
- Don't retry 4xx errors (client errors) - fix the issue instead
- DO retry 5xx errors with exponential backoff
- Respond to webhooks with 200 immediately, process async
- Handle webhook duplicates (they can be delivered multiple times)
- Configure multiple providers per channel for failover

### Idempotency Key Patterns
| Notification | Key Pattern |
|--------------|-------------|
| Order confirmation | `order-confirmation-{orderId}` |
| Password reset | `password-reset-{userId}-{resetRequestId}` |
| Payment receipt | `payment-receipt-{paymentId}` |
| Shipping update | `shipping-{shipmentId}-{status}` |
| OTP code | `otp-{userId}-{otpRequestId}` |
| Welcome email | `welcome-{userId}` |

Use a **request id** (the unique id of the OTP/reset attempt from your own system) rather than a timestamp — it's deterministic on retry, so the second attempt of the *same* OTP request gets deduped while a legitimate *new* OTP request gets a fresh key.

### Common Mistakes
- Missing idempotency keys (causes duplicate notifications)
- Static idempotency keys for notifications that should repeat (OTP needs a unique per-request ID, not a fixed key)
- Retrying 4xx errors (they won't succeed, fix the issue)
- Blocking on webhook processing (should be async)
- Not handling webhook duplicates
- No fallback providers configured
- No alerting on high failure rates

### Templates

See [Patterns](./patterns.md) for full copy-paste implementations: [Idempotency Keys](./patterns.md#idempotency-keys), [Webhook Handler](./patterns.md#webhook-handler), [Retry with Backoff](./patterns.md#retry-with-exponential-backoff).

### Message Status Glossary

The value returned as `message.status` from `client.messages.retrieve` (and the `status` field on `messages list` rows and webhook events). Happy-path progression is roughly `ENQUEUED → ROUTED → SENT → DELIVERED`; terminal failures are `UNROUTABLE`, `UNDELIVERABLE`, and `FAILED`.

| Status | Meaning | Typical cause / next step |
|--------|---------|---------------------------|
| `ENQUEUED` | Courier has accepted the request and queued it for routing. | Transient — re-check in a few seconds. |
| `PROCESSED` | Internal processing step (event mapping, template resolution). You may see this briefly on list rows. | Transient. |
| `ROUTED` | Routing decision made; message is ready to hand to a provider. | Transient. |
| `SENT` | At least one provider accepted the payload for delivery. For email this means the provider returned 2xx; for Inbox it means the message is visible in the feed. | Transient on the way to `DELIVERED`, or terminal for Inbox/webhook channels. |
| `DELIVERED` | Provider confirmed the message reached the recipient (e.g. email DSN, SMS carrier report). | Terminal success. |
| `OPENED` / `CLICKED` | Engagement signals for email (requires open/click tracking). | Terminal success with engagement. |
| `UNMAPPED` | The `event` on the send didn't map to any notification/template in this workspace. Common for bulk sends with a typo'd `event` value. | Fix the event ID or create an event mapping in Settings. |
| `UNROUTABLE` | Routing failed — no channel/provider combination could accept the send. Check `reason` and `error` for detail (e.g. `reason: "PROVIDER_ERROR"` with message `"No provider(s) resend in the list of message channel provider(s): postmark."` means the channel's routing list references a provider that isn't installed). | Fix provider configuration in [Integrations](https://app.courier.com/integrations), adjust `routing.channels`, or populate the user's contact info. |
| `UNDELIVERABLE` | All providers attempted returned a terminal failure (bounce, invalid number, suppressed). | Verify the recipient's contact info; inspect `providers[].providerResponse` for the specific error. |
| `FAILED` | Platform-side error (rare). | Retry; if persistent, contact Courier support with the `id`. |

Statuses are returned verbatim on webhooks and on `GET /messages/{id}`. For list/bulk sends, a single `requestId` can fan out to many per-recipient `message_id`s, each with its own status — look them up via `courier messages list --trace-id "<requestId>"` (see [CLI debugging](./cli.md#debugging-list-bulk-sends-requestid-vs-message-id)).

---

Ensure notifications are delivered reliably with idempotency, retry logic, and error handling.

## Idempotency

### Why It Matters

Without idempotency, you might send duplicate notifications if:
- Network timeout occurs but request succeeded
- Your application retries a failed request
- Webhook is delivered multiple times

### Courier Idempotency Keys

Add the `Idempotency-Key` header to your API requests. Courier stores keys for 24 hours and returns cached responses for duplicate requests. See [Patterns > Idempotency Keys](./patterns.md#idempotency-keys) for TypeScript, Python, CLI, and curl examples.

### Key Patterns

See [Idempotency Key Patterns](#idempotency-key-patterns) in the Quick Reference above for the canonical table.

### Don't Over-Dedupe

Some notifications should be sent multiple times:

- **OTP / password reset:** User might request multiple. Key off the unique request id (e.g. `otp-{userId}-{otpRequestId}`) so a legitimate new request gets a fresh key while a retry of the same request is deduped.
- **Welcome messages:** Only send once. Use a static key like `welcome-{userId}`.

## Retry Logic

### Courier's Built-In Retry

Courier automatically retries failed sends. You can configure:
- Number of retries
- Retry intervals
- Which errors to retry

### Custom Retry for Your Backend

If your call to Courier fails, implement retry with exponential backoff:

1. Don't retry client errors (4xx) - fix the issue instead
2. Retry server errors (5xx) and network errors
3. Use exponential backoff: 2s, 4s, 8s...
4. Add jitter to prevent thundering herd
5. Set a maximum retry count

### Exponential Backoff

Calculate delay as: `min(baseDelay * 2^(attempt-1), maxDelay) + random jitter`

Example: 1s base, 30s max → delays of ~1s, ~2s, ~4s, ~8s, ~16s, ~30s

## Error Handling

### Error Categories

| Category | Example | Action |
|----------|---------|--------|
| Client error (4xx) | Invalid template | Don't retry, fix issue |
| Server error (5xx) | Service unavailable | Retry with backoff |
| Network error | Timeout | Retry with backoff |
| Rate limit (429) | Too many requests | Retry with longer delay |

### Handling Different Errors

- **400 Bad Request:** Log, alert engineering, don't retry
- **429 Rate Limited:** Queue for later with 60+ second delay
- **5xx Server Error:** Retry with exponential backoff
- **Unknown Error:** Log, alert, don't retry

## Webhooks

### Setup

1. Go to **Settings > Webhooks** in the [Courier dashboard](https://app.courier.com/settings/webhooks)
2. Add your endpoint URL (e.g., `https://api.acme.com/webhooks/courier`)
3. Copy the **signing secret** — you'll use it to verify payloads
4. Select which events to subscribe to (or receive all)

Courier retries failed webhook deliveries (non-2xx response) with exponential backoff for up to 24 hours.

### Verify Webhook Signatures

Courier signs every webhook payload with an HMAC-SHA256 signature in the `courier-signature` header. The header value has the form:

```
t=<unix_timestamp_ms>,signature=<hex_hmac_sha256>
```

The signed payload is `${timestamp}.${raw_request_body}`. Always verify in production — and also reject old timestamps (default tolerance 5 minutes) to prevent replay attacks.

**TypeScript (Express):**

Use `express.raw()` on the webhook route so you verify against the exact bytes Courier sent, not a re-serialized version:

```typescript
import crypto from "crypto";
import express from "express";

function parseCourierSignature(header: string | undefined) {
  if (!header) return null;
  const parts = header.split(",").reduce<Record<string, string>>((acc, part) => {
    const [k, v] = part.split("=");
    if (k && v) acc[k.trim()] = v.trim();
    return acc;
  }, {});
  if (!parts.t || !parts.signature) return null;
  return { timestamp: parts.t, signature: parts.signature };
}

function verifyWebhookSignature(
  rawBody: Buffer,
  header: string | undefined,
  secret: string,
  toleranceMs = 5 * 60 * 1000
): boolean {
  const parsed = parseCourierSignature(header);
  if (!parsed) return false;

  const ts = Number(parsed.timestamp);
  if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > toleranceMs) {
    return false;
  }

  const expectedHex = crypto
    .createHmac("sha256", secret)
    .update(`${parsed.timestamp}.${rawBody.toString("utf8")}`, "utf8")
    .digest("hex");

  const a = Buffer.from(parsed.signature, "hex");
  const b = Buffer.from(expectedHex, "hex");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/courier",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["courier-signature"] as string | undefined;
    if (
      !verifyWebhookSignature(
        req.body,
        signature,
        process.env.COURIER_WEBHOOK_SECRET!
      )
    ) {
      return res.sendStatus(401);
    }

    const payload = JSON.parse(req.body.toString("utf8"));
    res.sendStatus(200);
    queue.add("process-webhook", payload);
  }
);
```

**Python (Flask):**

Use `request.get_data()` to get the raw bytes before Flask parses JSON:

```python
import hmac
import hashlib
import os
import time


def parse_courier_signature(header: str | None):
    if not header:
        return None
    parts = {}
    for chunk in header.split(","):
        if "=" in chunk:
            k, v = chunk.split("=", 1)
            parts[k.strip()] = v.strip()
    if "t" not in parts or "signature" not in parts:
        return None
    return parts["t"], parts["signature"]


def verify_webhook_signature(
    raw_body: bytes,
    header: str | None,
    secret: str,
    tolerance_ms: int = 5 * 60 * 1000,
) -> bool:
    parsed = parse_courier_signature(header)
    if not parsed:
        return False
    timestamp, signature = parsed

    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time() * 1000) - ts) > tolerance_ms:
        return False

    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected = hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)


@app.route("/webhooks/courier", methods=["POST"])
def courier_webhook():
    raw_body = request.get_data()
    signature = request.headers.get("courier-signature")
    if not verify_webhook_signature(
        raw_body, signature, os.environ["COURIER_WEBHOOK_SECRET"]
    ):
        return "", 401

    queue.enqueue("process_webhook", request.get_json())
    return "", 200
```

### Common Webhook Events

Courier webhook events use **colon notation** in the `type` field and carry event-specific data under `data`. For `message:updated`, the delivery stage is in `data.status` (one of `ENQUEUED`, `SENT`, `DELIVERED`, `OPENED`, `CLICKED`, `UNDELIVERABLE`, `UNROUTABLE`).

| `type` | `data.status` / trigger | Useful for |
|--------|-------------------------|------------|
| `message:updated` | `DELIVERED` — message reached recipient | Delivery tracking |
| `message:updated` | `UNDELIVERABLE` — all channels/providers failed (check `data.reason`) | Alerting, fallback logic |
| `message:updated` | `UNROUTABLE` — no eligible channel/provider for recipient | Data quality, profile fixes |
| `message:updated` | `OPENED` — email open pixel tracked | Engagement metrics |
| `message:updated` | `CLICKED` — tracked link clicked | Conversion tracking |
| `notification:submitted` | Template submitted for review | Approval workflows |
| `notification:published` | Template published | Cache invalidation, audit logging |
| `audiences:user:matched` / `audiences:user:unmatched` | User entered/left an audience | Sync downstream systems |

> **Note on bounces/complaints:** Courier does not emit dedicated `message:bounced` or `message:complained` events. Hard bounces and spam complaints typically surface as `UNDELIVERABLE` `message:updated` events with the provider-specific reason in `data.providers[].error` and the aggregated reason in `data.reason`. Inspect those fields to drive list-hygiene logic.

### Webhook Best Practices

1. Respond immediately with 200 OK
2. Process in background queue
3. Use idempotent processing (check if already handled)
4. Verify webhook signatures
5. Store the signing secret in environment variables, never in code

## Related

- [Multi-Channel](./multi-channel.md) - Fallback routing
- [Throttling](./throttling.md) - Rate limiting and frequency control
- [Batching](./batching.md) - Combining notifications
- [Transactional](../transactional/index.md) - Critical notification patterns
- [Billing](../transactional/billing.md) - Dunning retry strategies

````


### `resources/guides/routing-strategies.md`

````markdown
# Routing Strategies

Workspace-level, reusable routing configurations. A routing strategy bundles a `routing` tree (`method` + `channels`), per-channel provider priority, and per-provider overrides into a single object with an `rs_...` ID. Templates reference it via `routing.strategy_id`.

## Quick Reference

### Rules
- Routing strategy IDs use the `rs_` prefix (e.g., `rs_01abc123`) and are opaque, workspace-scoped values
- `name` and `routing` are the only required fields on create; both `channels` and `providers` are top-level optional fields that default to empty when omitted
- **Channel-level vs provider-level config:** `channels.{channel}.providers` is the **ordered failover list** for a channel (position = priority). The **top-level** `providers` map carries **per-provider** settings (`override`, `if`, `timeouts`, `metadata`) keyed by provider name. You typically use both: `channels.email.providers: ["sendgrid","aws-ses"]` for order, and `providers.sendgrid.override: {...}` for SendGrid-specific config
- `routing.method` is `"single"` (try channels in order until one succeeds) or `"all"` (send to all channels in parallel)
- `channels.{channel}.providers` is an **ordered** array — position = failover priority (same as dragging providers in the dashboard)
- `PUT /routing-strategies/{id}` is a **full replacement** — any field you omit is reset to its default
- `DELETE /routing-strategies/{id}` archives; it returns **409 Conflict** if any notification template still references the strategy. Unlink templates first (replace their `routing` with a different strategy or `null`)
- `GET /routing-strategies` returns metadata only (no `routing`/`channels`/`providers` content) — use `GET /routing-strategies/{id}` for the full document
- Templates and routing strategies are decoupled: one strategy can back many templates, and swapping a strategy's providers reroutes every template pointing at it without republishing

### Common Mistakes
- Omitting fields on `PUT` and losing `tags`/`description`/`providers` config silently (it's a replace, not a merge)
- Trying to archive a strategy that's still linked to templates (returns 409 — repoint templates first)
- Confusing the **message-level** `routing` on `send.message` (per-send override) with the **workspace-level** routing strategy (stored object with an `rs_...` ID and referenced by templates)
- Forgetting that provider order in `channels.{channel}.providers` determines failover — reordering the array changes which provider gets tried first
- Creating a strategy that references a provider key you haven't configured yet via `/providers` — the strategy will save, but sends will skip that provider silently

### SDK shape

| Operation | Node | Python |
|-----------|------|--------|
| Create | `client.routingStrategies.create({ name, routing, channels?, providers?, description?, tags? })` | `client.routing_strategies.create(name=..., routing=..., ...)` |
| List | `client.routingStrategies.list({ cursor?, limit? })` | `client.routing_strategies.list(cursor=..., limit=...)` |
| Retrieve | `client.routingStrategies.retrieve(id)` | `client.routing_strategies.retrieve(id)` |
| Replace | `client.routingStrategies.replace(id, { name, routing, ... })` | `client.routing_strategies.replace(id, name=..., routing=..., ...)` |
| Archive | `client.routingStrategies.archive(id)` | `client.routing_strategies.archive(id)` |

---

## Create a Routing Strategy

### Minimal

**TypeScript:**
```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

const strategy = await client.routingStrategies.create({
  name: "Simple Email",
  routing: { method: "single", channels: ["email"] }
});

const strategyId = strategy.id; // "rs_..."
```

**Python:**
```python
from courier import Courier

client = Courier()

strategy = client.routing_strategies.create(
    name="Simple Email",
    routing={"method": "single", "channels": ["email"]},
)

strategy_id = strategy.id  # "rs_..."
```

**curl:**
```bash
curl -X POST "https://api.courier.com/routing-strategies" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Simple Email",
    "routing": { "method": "single", "channels": ["email"] }
  }'
```

### Full — provider priority + per-provider override

**TypeScript:**
```typescript
const strategy = await client.routingStrategies.create({
  name: "Email via SendGrid",
  description: "Routes email through SendGrid with SES failover",
  tags: ["production", "email"],
  routing: {
    method: "single",
    channels: ["email"]
  },
  channels: {
    email: {
      providers: ["sendgrid", "aws-ses"], // failover order
      timeouts: { provider: 5000, channel: 30000 },
      override: {
        // channel-level override applied to every provider
      },
      if: "data.locale !== 'ja-JP'" // skip this channel entirely when false
    }
  },
  providers: {
    sendgrid: {
      override: {
        ip_pool_name: "transactional"
      },
      timeouts: 4000,
      if: "profile.email_verified === true"
    }
  }
});
```

**Python:**
```python
strategy = client.routing_strategies.create(
    name="Email via SendGrid",
    description="Routes email through SendGrid with SES failover",
    tags=["production", "email"],
    routing={"method": "single", "channels": ["email"]},
    channels={
        "email": {
            "providers": ["sendgrid", "aws-ses"],
            "timeouts": {"provider": 5000, "channel": 30000},
            "override": {},
            "if": "data.locale !== 'ja-JP'",
        }
    },
    providers={
        "sendgrid": {
            "override": {"ip_pool_name": "transactional"},
            "timeouts": 4000,
            "if": "profile.email_verified === true",
        }
    },
)
```

**curl:**
```bash
curl -X POST "https://api.courier.com/routing-strategies" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Email via SendGrid",
    "description": "Routes email through SendGrid with SES failover",
    "tags": ["production", "email"],
    "routing": { "method": "single", "channels": ["email"] },
    "channels": {
      "email": {
        "providers": ["sendgrid", "aws-ses"],
        "timeouts": { "provider": 5000, "channel": 30000 }
      }
    },
    "providers": {
      "sendgrid": { "override": { "ip_pool_name": "transactional" } }
    }
  }'
```

Returns `201` with the full strategy document (including `id`, `created`, `creator`).

### Multi-channel strategy (fallback chain)

```typescript
await client.routingStrategies.create({
  name: "Transactional Fallback",
  routing: {
    method: "single",
    channels: ["email", "sms"] // try email first, SMS if email fails
  },
  channels: {
    email: { providers: ["sendgrid", "aws-ses"] },
    sms: { providers: ["twilio"] }
  }
});
```

### Fan-out strategy (all channels in parallel)

```typescript
await client.routingStrategies.create({
  name: "Critical Security Alert",
  routing: {
    method: "all",
    channels: ["email", "push", "sms", "inbox"]
  },
  channels: {
    email: { providers: ["sendgrid"] },
    push: { providers: ["apn", "firebase-fcm"] },
    sms: { providers: ["twilio"] },
    inbox: { providers: ["courier"] }
  }
});
```

---

## Use a Strategy in a Template

Pass the `rs_...` as `routing.strategy_id` when creating or replacing a notification template.

**TypeScript (end-to-end: create strategy → create template → publish → send):**
```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

// 1. Create (or look up) a routing strategy
const strategy = await client.routingStrategies.create({
  name: "Orders — email primary, SMS backup",
  routing: { method: "single", channels: ["email", "sms"] },
  channels: {
    email: { providers: ["sendgrid", "aws-ses"] },
    sms: { providers: ["twilio"] }
  }
});

// 2. Create a template that references it
const template = await client.notifications.create({
  notification: {
    name: "Order Shipped",
    tags: ["transactional", "orders"],
    brand: null,
    subscription: null,
    routing: { strategy_id: strategy.id },
    content: {
      version: "2022-01-01",
      elements: [
        { type: "meta", title: "Your order {{order_id}} has shipped" },
        { type: "text", content: "Hi {{name}}, your package is on the way." },
        { type: "action", content: "Track", href: "{{tracking_url}}" }
      ]
    }
  },
  state: "DRAFT"
});

// 3. Publish
await client.notifications.publish(template.id);

// 4. Send — routing is picked up from the linked strategy
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: template.id,
    data: { name: "Jane", order_id: "ORD-9042", tracking_url: "https://ex.co/t/ORD-9042" }
  }
});
```

**Python:**
```python
from courier import Courier

client = Courier()

strategy = client.routing_strategies.create(
    name="Orders — email primary, SMS backup",
    routing={"method": "single", "channels": ["email", "sms"]},
    channels={
        "email": {"providers": ["sendgrid", "aws-ses"]},
        "sms": {"providers": ["twilio"]},
    },
)

template = client.notifications.create(
    notification={
        "name": "Order Shipped",
        "tags": ["transactional", "orders"],
        "brand": None,
        "subscription": None,
        "routing": {"strategy_id": strategy.id},
        "content": {
            "version": "2022-01-01",
            "elements": [
                {"type": "meta", "title": "Your order {{order_id}} has shipped"},
                {"type": "text", "content": "Hi {{name}}, your package is on the way."},
                {"type": "action", "content": "Track", "href": "{{tracking_url}}"},
            ],
        },
    },
    state="DRAFT",
)

client.notifications.publish(template.id)

client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": template.id,
        "data": {"name": "Jane", "order_id": "ORD-9042", "tracking_url": "https://ex.co/t/ORD-9042"},
    }
)
```

---

## List, Retrieve, Replace, Archive

### List
Metadata only — no `routing`/`channels`/`providers` payload. Paginated.

**TypeScript:**
```typescript
const { results, paging } = await client.routingStrategies.list({ limit: 50 });
for (const s of results) console.log(s.id, s.name, s.tags);
if (paging.more) {
  const next = await client.routingStrategies.list({ cursor: paging.cursor!, limit: 50 });
}
```

**Python:**
```python
response = client.routing_strategies.list(limit=50)
for s in response.results:
    print(s.id, s.name, s.tags)
if response.paging.more:
    next_page = client.routing_strategies.list(cursor=response.paging.cursor, limit=50)
```

**curl:**
```bash
curl -s "https://api.courier.com/routing-strategies?limit=50" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

### Retrieve (full document)

**TypeScript:**
```typescript
const strategy = await client.routingStrategies.retrieve("rs_01abc123");
// strategy.routing, strategy.channels, strategy.providers are populated
```

**Python:**
```python
strategy = client.routing_strategies.retrieve("rs_01abc123")
# strategy.routing, strategy.channels, strategy.providers are populated
```

### Replace (full-document PUT)

Like `PUT /notifications/{id}`, this is a replacement. Always read → modify → write back to avoid dropping fields.

**TypeScript:**
```typescript
const current = await client.routingStrategies.retrieve("rs_01abc123");

await client.routingStrategies.replace("rs_01abc123", {
  name: current.name,
  description: current.description,
  tags: current.tags,
  routing: current.routing,
  channels: {
    ...current.channels,
    email: {
      ...current.channels.email,
      providers: ["aws-ses", "sendgrid"] // flip priority
    }
  },
  providers: current.providers
});
```

**Python:**
```python
current = client.routing_strategies.retrieve("rs_01abc123")

client.routing_strategies.replace(
    "rs_01abc123",
    name=current.name,
    description=current.description,
    tags=current.tags,
    routing=current.routing,
    channels={
        **current.channels,
        "email": {
            **current.channels["email"],
            "providers": ["aws-ses", "sendgrid"],  # flip priority
        },
    },
    providers=current.providers,
)
```

### Archive

**TypeScript:**
```typescript
await client.routingStrategies.archive("rs_01abc123");
```

**Python:**
```python
client.routing_strategies.archive("rs_01abc123")
```

**curl:**
```bash
curl -X DELETE "https://api.courier.com/routing-strategies/rs_01abc123" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

Returns `204`. Returns `409` if any template still references the strategy — repoint those templates first (set their `routing.strategy_id` to a different strategy or set `routing: null`).

---

## Message-level `routing` vs strategy-level `routing`

Two different shapes share the `routing` name; don't confuse them.

| Location | Shape | Purpose |
|----------|-------|---------|
| `send.message({ message: { routing: { method, channels } } })` | Inline `method` + `channels` | Per-send override; wins over the template's strategy for that one send |
| `notifications.create({ notification: { routing: { strategy_id } } })` | `{ strategy_id }` only | Links a template to a stored strategy |
| `routingStrategies.create({ routing: { method, channels } })` | Inline `method` + `channels` | Defines the strategy itself |

Rule of thumb: if you're reaching for `routing` in a `send.message` call repeatedly with the same values, move it into a stored strategy and link it from the template.

---

## Related

- [Templates](./templates.md) — `routing.strategy_id` on notification templates
- [Providers](./providers.md) — configure the provider integrations referenced by `channels.{channel}.providers`
- [Multi-Channel](./multi-channel.md) — routing methods (`single` vs `all`), per-use-case channel priority, failover semantics
- [Create Routing Strategy](https://www.courier.com/docs/api-reference/routing-strategies/create-routing-strategy) — official endpoint reference
- [Replace Routing Strategy](https://www.courier.com/docs/api-reference/routing-strategies/replace-routing-strategy)
- [Archive Routing Strategy](https://www.courier.com/docs/api-reference/routing-strategies/archive-routing-strategy)
- [Routing Configuration (Design Studio)](https://www.courier.com/docs/platform/content/template-designer/routing-configuration) — dashboard equivalent

<!-- Target line budget: <= 500 lines. -->

````


### `resources/guides/templates.md`

````markdown
# Templates & Elemental

## Quick Reference

### Rules
- Template IDs use the `nt_` prefix (e.g., `nt_01kmrbq6ypf25tsge12qek41r0`)
- Human-friendly aliases are optional in app code, but this skill set uses Courier-generated `nt_...` IDs as the canonical pattern for agent consistency
- Treat template IDs as opaque, workspace-specific values (they vary by environment and should not encode business meaning)
- Templates are created in **DRAFT** state by default — they must be published before sends will use them
- **Canonical create flow is DRAFT → `notifications.publish`, not `state: "PUBLISHED"` on create.** When `state: "PUBLISHED"` is passed to `notifications.create`, the response body currently echoes `name: "Untitled"` and `tags: []` even though the template is stored correctly under the hood. Creating as DRAFT and calling `publish(id)` returns a response body whose `name`/`tags` match what you sent — safer for logging, validation, and lookup.
- `PUT /notifications/{id}` is a **full replacement** — every field is required, even if unchanged; omitted fields reset to empty/null
- Elemental version string is always `"2022-01-01"`
- ElementalContentSugar (`title`/`body`) only works for inline sends — use the full Elemental format (`version` + `elements`) when creating templates via the API
- Templates created via API appear in Design Studio, and vice versa
- A template needs a `routing.strategy_id` from your workspace to route through channels. Three ways to obtain one:
  1. **Create one programmatically** via `client.routingStrategies.create({ name, routing, channels, providers })` — returns an `rs_...` you can pass to `notifications.create`. See [routing-strategies.md](./routing-strategies.md).
  2. **Reuse an existing strategy** — copy its ID from an existing template via `GET /notifications/{id}` or list them with `client.routingStrategies.list()`.
  3. **Defer it** — set `routing: null` on create and assign a `strategy_id` later via `notifications.replace`.
- Archive a template with `DELETE /notifications/{id}` (or `client.notifications.archive(id)` in the SDK). Note: `POST /notifications/{id}/archive` does **not** exist and returns 404 — the archive operation uses the `DELETE` method.

### Common Mistakes
- Forgetting to publish after creating or updating (template exists but sends use the old published version, or fail silently if never published)
- Omitting fields on `PUT` (e.g., leaving out `tags` resets them to `[]`, leaving out `brand` resets to `null`)
- Nesting `channel` elements inside other `channel` elements (they must be top-level siblings)
- Using Sugar format (`title`/`body`) in template creation payloads (only works for inline sends via the Send API)
- Missing `routing.strategy_id` on create (template will exist but sends may fail routing)
- Sending to a template that has never been published (draft content is not used at send time)

### Templates

**Create and publish a template (TypeScript):**
```typescript
// 1. Create as DRAFT so the response echoes your name/tags correctly.
const template = await client.notifications.create({
  notification: {
    name: "Order Shipped",
    tags: ["transactional", "orders"],
    brand: null,
    subscription: null,
    routing: { strategy_id: "rs_..." },
    content: {
      version: "2022-01-01",
      elements: [
        { type: "meta", title: "Your order {{order_id}} has shipped" },
        { type: "text", content: "Hi {{name}}, your package is on the way." },
        { type: "action", content: "Track Shipment", href: "{{tracking_url}}" }
      ]
    }
  },
  state: "DRAFT"
});
// template.id → "nt_...", template.name === "Order Shipped", template.tags === [...]
//   (response fields are returned at the top level)

// 2. Publish it so sends use the new content.
await client.notifications.publish(template.id);
```

> Passing `state: "PUBLISHED"` to `create` also works and stores the template correctly, but the immediate response body echoes `name: "Untitled"` and `tags: []` even though a subsequent `GET /notifications/{id}` shows the real values. Prefer the DRAFT → `publish` flow above so the response you log/assert on matches what you sent.

---

## Inline vs Templated Sending

There are two ways to define notification content when calling the Send API:

| | Inline Content | Stored Template |
|--|----------------|-----------------|
| How | Pass `content` directly in the `client.send.message()` call | Pass `template` ID referencing a stored template |
| Content lives | In your code | In Courier (API-created or Design Studio) |
| Editable in dashboard | No | Yes |
| Version history | No | Yes (draft/publish cycle) |
| Approval workflows | No | Yes (submission checks) |
| Best for | Prototyping, ad-hoc sends, AI agent workflows, simple notifications | Production notifications, team-managed content, multi-channel templates |

**Inline send (content in code):**
```typescript
await client.send.message({
  message: {
    to: { email: "jane@example.com" },
    content: {
      title: "Your order has shipped",
      body: "Hi {{name}}, your package is on the way."
    },
    data: { name: "Jane" }
  }
});
```

**Templated send (reference stored template):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbq6ypf25tsge12qek41r0",
    data: { name: "Jane", order_id: "ORD-9042", tracking_url: "https://example.com/track/ORD-9042" }
  }
});
```

Inline sends support both ElementalContentSugar (`title`/`body`) and the full Elemental format (`version` + `elements`). Stored templates always use the full Elemental format.

---

## Template Aliases (Optional)

Courier APIs send by template ID (`nt_...`). For agent-generated code, keep `nt_...` as the default.

Aliases are an application-layer convenience that map a stable human name to a real template ID.

### Why aliases can help

- Easier to read in app code (`"order-shipped"` vs long ID)
- Safer refactors (swap the mapped ID without touching call sites)
- Useful per-environment mapping (dev/staging/prod can point to different IDs)

### TypeScript alias map example

```typescript
const TEMPLATE_IDS = {
  "order-shipped": "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
  "password-reset": "nt_01kmrbzj3q6x9v2d5c8n1w4ht",
} as const;

type TemplateAlias = keyof typeof TEMPLATE_IDS;

function resolveTemplate(alias: TemplateAlias): string {
  return TEMPLATE_IDS[alias];
}

await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: resolveTemplate("order-shipped"),
    data: { order_id: "ORD-9042" },
  },
});
```

### Python alias map example

```python
TEMPLATE_IDS = {
    "order-shipped": "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    "password-reset": "nt_01kmrbzj3q6x9v2d5c8n1w4ht",
}

def resolve_template(alias: str) -> str:
    return TEMPLATE_IDS[alias]

client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": resolve_template("order-shipped"),
        "data": {"order_id": "ORD-9042"},
    }
)
```

### Agent guidance

- Prefer direct `nt_...` IDs in generated examples unless the user explicitly asks for aliases.
- If aliases are used, always resolve them to `nt_...` before calling Courier.
- Do not assume a global alias registry exists unless the user provides one.

---

## Template CRUD — the Notifications API

All template operations use the `/notifications` endpoints. Authenticate with `Authorization: Bearer $COURIER_API_KEY`.

### API Overview

| Operation | Method | Endpoint | Description |
|-----------|--------|----------|-------------|
| List | `GET` | `/notifications` | Paginated list of all templates |
| Create | `POST` | `/notifications` | Create a new template (DRAFT or PUBLISHED) |
| Get | `GET` | `/notifications/{id}` | Retrieve a template by ID |
| Replace | `PUT` | `/notifications/{id}` | Full replacement of a template |
| Archive | `DELETE` | `/notifications/{id}` | Archive (soft-delete) a template |
| Publish | `POST` | `/notifications/{id}/publish` | Publish the current draft |
| Get content | `GET` | `/notifications/{id}/content` | Get published content blocks |
| Get draft | `GET` | `/notifications/{id}/draft/content` | Get draft content blocks |
| List versions | `GET` | `/notifications/{id}/versions` | Version history |

### Create a Template

Templates require a `notification` object with `name`, `tags`, `brand`, `subscription`, `routing`, and `content` — all fields are required. Set `state` to `"PUBLISHED"` to skip the draft step, or omit/set to `"DRAFT"` (default).

**TypeScript:**
```typescript
import Courier from "@trycourier/courier";

const client = new Courier();

const response = await client.notifications.create({
  notification: {
    name: "Shipping Update",
    tags: ["transactional", "orders"],
    brand: null,
    subscription: null,
    routing: { strategy_id: "rs_..." },
    content: {
      version: "2022-01-01",
      elements: [
        { type: "meta", title: "Your order {{order_id}} has shipped" },
        {
          type: "text",
          content: "Hi {{name}}, your package is on the way. Tracking: {{tracking_url}}."
        },
        { type: "action", content: "Track Shipment", href: "{{tracking_url}}" }
      ]
    }
  },
  state: "DRAFT"
});

const templateId = response.id; // "nt_..." (response fields are at the top level)
```

**Python:**
```python
from courier import Courier

client = Courier()

response = client.notifications.create(
    notification={
        "name": "Shipping Update",
        "tags": ["transactional", "orders"],
        "brand": None,
        "subscription": None,
        "routing": {"strategy_id": "rs_..."},
        "content": {
            "version": "2022-01-01",
            "elements": [
                {"type": "meta", "title": "Your order {{order_id}} has shipped"},
                {
                    "type": "text",
                    "content": "Hi {{name}}, your package is on the way. Tracking: {{tracking_url}}.",
                },
                {"type": "action", "content": "Track Shipment", "href": "{{tracking_url}}"},
            ],
        },
    },
    state="DRAFT",
)

template_id = response.id  # "nt_..." (response fields are at the top level)
```

**curl:**
```bash
curl -X POST "https://api.courier.com/notifications" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "notification": {
      "name": "Shipping Update",
      "tags": ["transactional", "orders"],
      "brand": null,
      "subscription": null,
      "routing": { "strategy_id": "rs_..." },
      "content": {
        "version": "2022-01-01",
        "elements": [
          { "type": "meta", "title": "Your order {{order_id}} has shipped" },
          { "type": "text", "content": "Hi {{name}}, your package is on the way. Tracking: {{tracking_url}}." },
          { "type": "action", "content": "Track Shipment", "href": "{{tracking_url}}" }
        ]
      }
    },
    "state": "DRAFT"
  }'
```

**Minimal create** (empty template, DRAFT):

**TypeScript:**
```typescript
await client.notifications.create({
  notification: {
    name: "Placeholder",
    tags: [],
    brand: null,
    subscription: null,
    routing: null,
    content: { version: "2022-01-01", elements: [] }
  }
});
```

**Python:**
```python
client.notifications.create(
    notification={
        "name": "Placeholder",
        "tags": [],
        "brand": None,
        "subscription": None,
        "routing": None,
        "content": {"version": "2022-01-01", "elements": []},
    },
)
```

### Replace a Template

`PUT` replaces the entire template. You must send **all fields** — any field you omit resets to its default. This is not a partial update.

**TypeScript:**
```typescript
await client.notifications.replace("nt_01abc123", {
  notification: {
    name: "Shipping Update v2",
    tags: ["transactional", "orders"],
    brand: null,
    subscription: { topic_id: "order-updates" },
    routing: { strategy_id: "rs_..." },
    content: {
      version: "2022-01-01",
      elements: [
        { type: "meta", title: "Order {{order_id}} shipped — arriving {{eta}}" },
        { type: "text", content: "Hi {{name}}, your package shipped via {{carrier}}." },
        { type: "action", content: "Track Shipment", href: "{{tracking_url}}" }
      ]
    }
  },
  state: "DRAFT"
});
```

**Python:**
```python
client.notifications.replace(
    "nt_01abc123",
    notification={
        "name": "Shipping Update v2",
        "tags": ["transactional", "orders"],
        "brand": None,
        "subscription": {"topic_id": "order-updates"},
        "routing": {"strategy_id": "rs_..."},
        "content": {
            "version": "2022-01-01",
            "elements": [
                {"type": "meta", "title": "Order {{order_id}} shipped — arriving {{eta}}"},
                {"type": "text", "content": "Hi {{name}}, your package shipped via {{carrier}}."},
                {"type": "action", "content": "Track Shipment", "href": "{{tracking_url}}"},
            ],
        },
    },
    state="DRAFT",
)
```

**curl:**
```bash
curl -X PUT "https://api.courier.com/notifications/nt_01abc123" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "notification": {
      "name": "Shipping Update v2",
      "tags": ["transactional", "orders"],
      "brand": null,
      "subscription": { "topic_id": "order-updates" },
      "routing": { "strategy_id": "rs_..." },
      "content": {
        "version": "2022-01-01",
        "elements": [
          { "type": "meta", "title": "Order {{order_id}} shipped — arriving {{eta}}" },
          { "type": "text", "content": "Hi {{name}}, your package shipped via {{carrier}}." },
          { "type": "action", "content": "Track Shipment", "href": "{{tracking_url}}" }
        ]
      }
    },
    "state": "DRAFT"
  }'
```

### Publish

Publishing moves the current draft to live. After publishing, sends that reference this template ID will use the newly published content.

**TypeScript:**
```typescript
await client.notifications.publish("nt_01abc123");
```

**Python:**
```python
client.notifications.publish("nt_01abc123")
```

**curl:**
```bash
curl -X POST "https://api.courier.com/notifications/nt_01abc123/publish" \
  -H "Authorization: Bearer $COURIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Returns `204 No Content` on success.

### List Templates

**TypeScript:**
```typescript
const { results, paging } = await client.notifications.list();

for (const template of results) {
  // V2 templates expose `name`; legacy templates expose `title`. Fall back across both.
  console.log(template.id, (template as any).name ?? (template as any).title);
}
```

**Python:**
```python
response = client.notifications.list()

for template in response.results:
    # V2 templates expose `name`; legacy templates expose `title`. Fall back across both.
    print(template.id, getattr(template, "name", None) or getattr(template, "title", None))
```

**CLI:**
```bash
courier notifications list --format json --transform "results.#.id"
# Names: prefer `name` (V2). For workspaces with a mix of V2 and legacy templates,
# request both fields and pick whichever is populated:
courier notifications list --format json --transform "results.#.{id:id,name:name,title:title}"
```

**curl:**
```bash
curl -s "https://api.courier.com/notifications" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

Paginated — use `paging.cursor` for the next page.

### Get a Template

**TypeScript:**
```typescript
const template = await client.notifications.retrieve("nt_01abc123");
```

**Python:**
```python
template = client.notifications.retrieve("nt_01abc123")
```

**curl:**
```bash
curl -s "https://api.courier.com/notifications/nt_01abc123" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

### Get Published Content

Inspect the live content blocks of a template:

```typescript
const content = await client.notifications.retrieveContent("nt_01abc123");
```

```bash
curl -s "https://api.courier.com/notifications/nt_01abc123/content" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

### Get Draft Content

Inspect the draft before publishing:

```bash
curl -s "https://api.courier.com/notifications/nt_01abc123/draft/content" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

### List Versions

Audit what was published and when:

```bash
curl -s "https://api.courier.com/notifications/nt_01abc123/versions" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

### Archive a Template

Archiving removes the template from normal catalog flows. Returns `204 No Content`.

```typescript
await client.notifications.archive("nt_01abc123");
```

```bash
curl -X DELETE "https://api.courier.com/notifications/nt_01abc123" \
  -H "Authorization: Bearer $COURIER_API_KEY"
```

### Draft/Publish Workflow

Templates have a two-phase lifecycle:

```
Create (DRAFT) → Edit (Replace) → Publish → Live
                      ↑                        |
                      └── Edit again ──────────┘
```

1. **Create** with `state: "DRAFT"` (or omit `state`)
2. **Iterate** using `PUT /notifications/{id}` — the draft updates but the published version stays unchanged
3. **Review** the draft with `GET /notifications/{id}/draft/content`
4. **Publish** with `POST /notifications/{id}/publish` — the draft becomes the live version
5. **Verify** with `GET /notifications/{id}/content`

To skip the draft step entirely, set `state: "PUBLISHED"` on create or replace.

### Submission Checks (Approval Workflows)

Templates support approval workflows via submission checks. When enabled, publishing requires external review — Courier emits webhooks on submission, locks the draft, and publishes only after checks are resolved via the checks API (`GET/PUT/DELETE /notifications/{id}/{submissionId}/checks`). See [Template Approval Workflow](https://www.courier.com/docs/platform/content/template-approval-workflow) for setup.

---

## Elemental Content Format

Template `content` uses Courier's JSON-based templating language, **Elemental**. Every payload has two required fields (`version` and `elements`), plus an optional shorthand (`{ title, body }`) for inline sends only.

For the full element-by-element reference — all element types, properties, control flow (`if`, `loop`, `ref`, `channels`), and localization — see **[Elemental](./elemental.md)**. The example below uses Elemental; consult the Elemental guide when you need more than `meta`, `text`, `action`, and `channel`.

<!-- OLD ELEMENTAL REFERENCE REMOVED — moved to elemental.md. Keep this pointer. -->

<details>
<summary>Minimal Elemental shape (for context)</summary>

```json
{
  "version": "2022-01-01",
  "elements": [
    { "type": "meta", "title": "Order #{{order_id}} Confirmed" },
    { "type": "text", "content": "Hi {{name}}, thanks for your order.", "align": "left" },
    { "type": "action", "content": "Track", "href": "{{tracking_url}}" }
  ]
}
```

Inline sends also accept the shorthand `{ "title": "…", "body": "…" }`. **Not** valid for template creation via `POST /notifications` — the full `version` + `elements` shape is required.

</details>

<!-- ELEMENTAL_REFERENCE_START
     The detailed element reference that was here has been moved to elemental.md
     to keep this file focused on the template lifecycle. Do not re-inline it.
ELEMENTAL_REFERENCE_END -->

<details>
<summary>Skipped here: full element reference</summary>

The previous version of this file inlined ~425 lines covering every element type (`meta`, `text`, `action`, `image`, `channel`, `divider`, `quote`, `group`, `columns`/`column`, `list`/`list-item`, `html`, `jsonnet`, `comment`), all control-flow properties (`if`, `loop`, `ref`, `channels`), and the `locales` shape. That content now lives in [Elemental](./elemental.md). Fetch that file when you need element-specif
...<truncated>
````


### `resources/guides/throttling.md`

````markdown
# Notification Throttling

## Quick Reference

### Rules
- NEVER throttle: OTP, security alerts, critical system notifications
- Critical notifications bypass all throttle limits
- Respect quiet hours: 10pm-8am local time (unless urgent)
- Different limits per channel and category
- Queue excess notifications for later (don't drop critical ones)

### Recommended Limits by Channel
| Channel | Limit | Window |
|---------|-------|--------|
| Push | 5-10 | Per hour |
| Email | 3-5 | Per day |
| SMS | 2-3 | Per day |
| In-app | 20-50 | Per hour |

### Priority Levels
| Priority | Examples | Throttle? |
|----------|----------|-----------|
| Critical | Security, OTP | NEVER |
| High | DMs, mentions | Minimal |
| Medium | Comments, likes | Standard |
| Low | Digests, promos | Aggressive |

### Common Mistakes
- Throttling critical/security notifications
- Same throttle limits for all notification types
- Dropping notifications instead of queuing
- Not tracking per-user notification counts
- Ignoring user timezone for quiet hours
- No alerting when throttle rate is too high

### Templates

**Check Throttle:**
```typescript
async function shouldSend(userId: string, priority: string): Promise<boolean> {
  if (priority === 'critical') return true;
  
  const count = await getNotificationCount(userId, '1h');
  const limits = { high: 20, medium: 10, low: 5 };
  return count < limits[priority];
}
```

**Quiet Hours Check:**
```typescript
function isQuietHours(timezone: string): boolean {
  const hour = new Date().toLocaleString('en-US', { 
    timeZone: timezone, hour: 'numeric', hour12: false 
  });
  return parseInt(hour) >= 22 || parseInt(hour) < 8;
}
```

---

Control notification frequency to prevent overwhelming users and respect rate limits.

## Why Throttle?

- **Prevent fatigue:** Too many notifications = users disable them
- **Respect rate limits:** Providers have sending limits
- **Improve engagement:** Well-timed notifications perform better
- **Reduce costs:** Fewer unnecessary sends

## Throttling vs Batching

| Concept | Purpose | Mechanism |
|---------|---------|-----------|
| **Throttling** | Limit frequency | Drop or delay excess |
| **Batching** | Combine related | Aggregate into one |

Use both together for optimal notification delivery.

## Throttling Strategies

### 1. Per-User Rate Limiting

Limit notifications per user over a time window.

```typescript
// Example: Max 10 notifications per hour per user
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
const MAX_PER_WINDOW = 10;

async function shouldSendNotification(userId: string): Promise<boolean> {
  const recentCount = await getNotificationCount(userId, WINDOW_MS);
  return recentCount < MAX_PER_WINDOW;
}
```

**Recommended limits by channel:**

| Channel | Suggested Limit | Window |
|---------|-----------------|--------|
| Push | 5-10 | Per hour |
| Email | 3-5 | Per day |
| SMS | 2-3 | Per day |
| In-app | 20-50 | Per hour |

### 2. Per-Category Throttling

Different limits for different notification types:

```typescript
const THROTTLE_CONFIG = {
  'social': { maxPerHour: 10, maxPerDay: 30 },
  'marketing': { maxPerDay: 1 },
  'transactional': { maxPerHour: 20 }, // Higher limit
  'alerts': { maxPerHour: 5 }
};
```

### 3. Global Rate Limiting

Protect against system-wide spikes:

```typescript
// Circuit breaker pattern
const GLOBAL_MAX_PER_SECOND = 1000;

if (await getGlobalSendRate() > GLOBAL_MAX_PER_SECOND) {
  await queueForLater(notification);
} else {
  await sendNow(notification);
}
```

## Courier Throttling Features

### Metadata Tags for Your Own Analytics

Courier does not throttle sends server-side based on arbitrary tags — the `metadata.tags` field is primarily a grouping and filtering handle for your own analytics, message logs, and webhook consumers. Attach tags so you can later filter `courier messages list --tag ...`, slice analytics, or drive your own rate-limit decisions in the application tier.

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbuc9q3x7v1d5c8n2w6hj",
    data: { /* ... */ },
    metadata: {
      tags: ["social", "low-priority"]
    }
  }
});
```

Provider-level rate limits (e.g. SendGrid/Twilio caps) are enforced per provider — see [Provider Rate Limits](#provider-rate-limits) below for the 429 retry pattern. For product-level controls (daily/weekly caps per user, quiet hours, preference frequency), implement in your application using the patterns elsewhere in this guide, or use [Preferences](./preferences.md) topics with `frequency` settings.

### Journey Throttling (Recommended)

[Journeys](./journeys.md) have a native `throttle` node that rate-limits runs per user, globally, or by a dynamic key:

```json
{
  "id": "throttle-per-user",
  "type": "throttle",
  "scope": "user",
  "max_allowed": 5,
  "period": "PT1H"
}
```

Scope options: `user` (per recipient), `global` (across all runs), `dynamic` (custom key via `throttle_key`). See [Journeys — Node Types Reference](./journeys.md#node-types-reference) for full details.

### Legacy: Automation Throttling

If you have existing Automations:

```typescript
await client.automations.invoke.invokeByTemplate("activity-notification", {
  recipient: "user-123",
  data: { ... }
});
```

Configure in dashboard:
1. **Throttle step:** Max 5 per hour per user
2. **Delay step:** Wait if limit exceeded
3. **Send step:** Deliver notification

### Channel-Level Throttling

Set different limits per channel in your routing:

```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbtw1v4q8x2c6d9n5j7h",
    routing: {
      method: "single",
      channels: ["email"] // Email has its own throttle config
    }
  }
});
```

## Priority-Based Throttling

Not all notifications are equal. Implement priority queues:

### Priority Levels

| Priority | Examples | Throttle Behavior |
|----------|----------|-------------------|
| Critical | Security alerts, OTP | Never throttle |
| High | Direct messages, mentions | Minimal throttling |
| Medium | Comments, likes | Standard throttling |
| Low | Digests, recommendations | Aggressive throttling |

### Implementation

```typescript
type Priority = 'critical' | 'high' | 'medium' | 'low';

async function sendWithPriority(
  notification: Notification, 
  priority: Priority
) {
  // Critical always sends
  if (priority === 'critical') {
    return await sendImmediately(notification);
  }
  
  // Check throttle for other priorities
  const limits = {
    high: { perHour: 20 },
    medium: { perHour: 10 },
    low: { perHour: 5 }
  };
  
  const withinLimit = await checkThrottle(
    notification.userId, 
    limits[priority]
  );
  
  if (withinLimit) {
    await sendImmediately(notification);
  } else if (priority === 'high') {
    await queueForNextWindow(notification);
  } else {
    await dropOrDigest(notification);
  }
}
```

## Quiet Hours

Respect user time by not sending during sleep hours:

### Basic Quiet Hours

```typescript
type User = { id: string; timezone: string };
type Notification = { userId: string; template: string; data?: Record<string, unknown> };

// Your own implementations — these are the hooks you'd plug into your
// existing user store and job queue (BullMQ, Temporal, Sidekiq, etc.).
declare function getUser(userId: string): Promise<User>;
declare function queueForTime(notification: Notification, runAt: Date): Promise<void>;
declare function sendImmediately(notification: Notification): Promise<void>;

function isQuietHours(userTimezone: string): boolean {
  const hour = parseInt(
    new Date().toLocaleString("en-US", {
      timeZone: userTimezone,
      hour: "numeric",
      hour12: false,
    }),
    10,
  );
  return hour >= 22 || hour < 8;
}

function getNext8am(userTimezone: string): Date {
  // See ../growth/onboarding.md "Timezone-Aware Scheduling" for a
  // date-fns-tz-based implementation that avoids Date.toLocaleString
  // round-tripping.
  const now = new Date();
  const hour = parseInt(
    new Date().toLocaleString("en-US", { timeZone: userTimezone, hour: "numeric", hour12: false }),
    10,
  );
  const hoursUntil8am = hour < 8 ? 8 - hour : 24 - hour + 8;
  return new Date(now.getTime() + hoursUntil8am * 60 * 60 * 1000);
}

async function sendOrDefer(notification: Notification) {
  const user = await getUser(notification.userId);

  if (isQuietHours(user.timezone)) {
    await queueForTime(notification, getNext8am(user.timezone));
  } else {
    await sendImmediately(notification);
  }
}
```

### User-Configurable Quiet Hours

Let users set their own quiet hours:

```typescript
// Store in user preferences
const quietHours = {
  enabled: true,
  start: "22:00",
  end: "08:00",
  timezone: "America/New_York",
  allowCritical: true // Still send security alerts
};
```

## Provider Rate Limits

Respect provider-specific limits:

| Provider | Rate Limit | Notes |
|----------|------------|-------|
| **SendGrid** | Varies by plan | 100/sec typical |
| **Twilio SMS** | 1 msg/sec per number | Higher with short codes |
| **APNs** | ~100k/day typical | Soft limit |
| **FCM** | 500 msg/sec | Per project |
| **Slack** | 1 msg/sec per channel | 30-50 burst |

### Handling Rate Limit Responses

```typescript
async function sendWithRetry(notification: Notification) {
  try {
    await client.send.message({ message: notification });
  } catch (error) {
    if (error.status === 429) {
      // Rate limited - queue for retry
      const retryAfter = error.headers['retry-after'] || 60;
      await queueForRetry(notification, retryAfter * 1000);
    } else {
      throw error;
    }
  }
}
```

## Throttle Feedback to Users

When throttling affects user experience, be transparent:

### Notification Count Indicators

```typescript
// In-app: Show "+5 more" instead of dropping silently
{
  visible: notifications.slice(0, 10),
  hiddenCount: Math.max(0, notifications.length - 10),
  message: notifications.length > 10 
    ? `+${notifications.length - 10} more notifications` 
    : null
}
```

### Digest Fallback

When throttle limit reached, queue for digest:

```typescript
if (!withinThrottleLimit) {
  await addToDigest(userId, notification);
  // User sees "3 updates" in next digest instead of nothing
}
```

## Monitoring Throttling

Track throttle metrics to tune your limits:

### Key Metrics

- **Throttle rate:** % of notifications throttled
- **Drop rate:** % of notifications dropped entirely
- **Digest inclusion rate:** % moved to digest
- **User complaints:** Feedback about "missing" notifications

### Alerting

```typescript
// Alert if throttling too aggressively
if (throttleRate > 0.3) {
  alert('High throttle rate - users may be missing notifications');
}

// Alert if not throttling enough (fatigue signal)
if (unsubscribeRate > 0.05) {
  alert('High unsubscribe rate - consider more aggressive throttling');
}
```

## Best Practices

### Start Conservative

Begin with generous limits and tighten based on data:

```typescript
// Start here
const INITIAL_LIMITS = {
  push: { perHour: 15 },
  email: { perDay: 10 }
};

// After monitoring, adjust
const OPTIMIZED_LIMITS = {
  push: { perHour: 8 },
  email: { perDay: 3 }
};
```

### Test Throttle Behavior

Ensure critical notifications aren't accidentally throttled:

```typescript
// Test: Security alerts should never be throttled
test('security alerts bypass throttle', async () => {
  // Max out the throttle
  for (let i = 0; i < 100; i++) {
    await sendLowPriority(userId);
  }
  
  // Security alert should still send
  const result = await sendSecurityAlert(userId);
  expect(result.status).toBe('sent');
});
```

### Log Throttle Decisions

For debugging and optimization:

```typescript
await logThrottleDecision({
  userId,
  notificationType,
  decision: 'throttled', // or 'sent', 'queued', 'dropped'
  reason: 'hourly_limit_exceeded',
  currentCount: 12,
  limit: 10
});
```

## Related

- [Journeys](./journeys.md) - Native throttle nodes for journey-level rate limiting
- [Batching](./batching.md) - Combining notifications
- [Reliability](./reliability.md) - Handling rate limit errors
- [Preferences](./preferences.md) - User frequency settings
- [Multi-Channel](./multi-channel.md) - Channel-specific limits

````


### `resources/transactional/account.md`

````markdown
# Account Notifications

## Quick Reference

### Rules
- Transactional welcome: NO promotional content (or it becomes marketing)
- Email changed: Send to BOTH old and new email addresses
- Security changes: ALWAYS include "I didn't request this" option
- 2FA disabled: High urgency - send to all channels
- Mask sensitive data: email (j***@example.com), phone (•••• 4567)

### Security-Sensitive Changes
| Change | Channels | Include |
|--------|----------|---------|
| Email changed | Old email + New email | "Secure account" link |
| Password changed | Email + Push | Timestamp, device info |
| 2FA enabled | Email + Push | Backup codes link |
| 2FA disabled | Email + Push + SMS | "Secure account" link |
| New device login | Email + Push | Device, location, IP |

### Required "I Didn't Do This" Link
Include in ALL of these:
- Password changes
- Email changes
- 2FA changes
- New device logins
- Any security-sensitive setting change

### Common Mistakes
- Promotional content in welcome email (reclassifies as marketing)
- Email change only sent to new address (old address owner unaware)
- No "I didn't request this" in security emails
- Exposing full email/phone in notifications (security risk)
- 2FA disabled treated as low priority (it's critical!)
- No confirmation for significant profile changes

### Templates

**Transactional Welcome (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbvs6x9q3v7d1c5n8w2hj",
    data: {
      email: "jane@example.com",
      loginUrl: "https://acme.com/login"
    }
  }
}, {
  headers: { "Idempotency-Key": `welcome-user-123` }
});
```

**Transactional Welcome (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbvs6x9q3v7d1c5n8w2hj",
        "data": {
            "email": "jane@example.com",
            "loginUrl": "https://acme.com/login",
        },
    },
    extra_headers={"Idempotency-Key": "welcome-user-123"},
)
```

**Email Changed (send to old email):**
```typescript
await client.send.message({
  message: {
    to: { email: "old-email@example.com" },
    template: "nt_01kmrbwb8x2q6v1d4c7n5j9ht",
    data: {
      newEmailMasked: "j***@newdomain.com",
      timestamp: "January 29, 2026 at 2:30 PM",
      secureAccountUrl: "https://acme.com/security"
    }
  }
}, {
  headers: { "Idempotency-Key": `email-changed-user-123-${changeEventId}` }
});
```

**2FA Disabled (critical):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbwj3q6x9v2d5c8n1w4ht",
    data: {
      timestamp: "January 29, 2026 at 2:30 PM",
      secureAccountUrl: "https://acme.com/security"
    },
    routing: { method: "all", channels: ["email", "push", "sms"] }
  }
}, {
  headers: { "Idempotency-Key": `2fa-disabled-user-123-${changeEventId}` }
});
```

---

Best practices for welcome messages, profile updates, and settings notifications.

## Welcome Messages

### Transactional vs Growth

**Transactional welcome:** Confirms account creation, provides essential setup info. No promotional content.

**Growth welcome:** Onboarding sequences, feature discovery. See [Onboarding](../growth/onboarding.md).

### When to Send

Immediately after account creation/email verification.

### Content Requirements

- Confirmation of account creation
- Essential next steps (not promotional)
- Key links (login, settings, support)
- No upsells, discounts, or promotional content

### Email Content

Subject: Welcome to Acme

Include:
- Account email confirmation
- Quick links (sign in, settings, docs, support)
- Reply contact for questions

**What NOT to include:**
- "Use code WELCOME20 for 20% off!"
- "Upgrade to Pro for more features"
- "Check out our latest products"

## Profile Updates

### Email Changed

Send to BOTH old and new email addresses.

**To old email:**

Subject: Your Acme email address was changed

Include:
- Masked new email (e.g., j***@example.com)
- Timestamp of change
- "Secure My Account" button if they didn't make the change

**To new email:**

Subject: Email address updated for your Acme account

Include:
- Confirmation of update
- Security contact if they didn't request it

### Password Changed

Channels: Email + Push

Subject: Your Acme password was changed

Include:
- Timestamp
- Device information
- "Secure My Account" option if they didn't make the change

### Profile Updated

For significant profile changes, send confirmation with:
- What fields changed
- Timestamp
- Link to review settings

## Settings Changes

### Notification Preferences Changed

Subject: Notification preferences updated

Include:
- Confirmation of update
- Link to review settings
- Security note if they didn't make the change

### Two-Factor Authentication

**2FA Enabled:**

Channels: Email + Push

Subject: Two-factor authentication enabled

Include:
- Method enabled (authenticator app or SMS)
- Link to backup codes
- "Secure My Account" if they didn't enable it

**2FA Disabled:**

Higher urgency - Channels: Email + Push + SMS (because this reduces security)

Include:
- Timestamp
- Security concern messaging
- "Secure My Account" button

## Usage & Quota Alerts

### Approaching Limit

Subject: You've used 80% of your monthly API calls

Include:
- Current usage vs limit
- Percentage used
- Estimated time until limit
- Link to usage details
- Link to plan options (keep it informational — a bare "View plans" link is transactional; promotional copy like "Upgrade now and save 20%" crosses into marketing and requires opt-in)

### Limit Reached

Channels: Email + Push + In-app

Include:
- What limit was reached
- Reset date
- Link to plan options (same boundary as above — informational link is fine, promotional upsell is not)
- Impact on service

## Account Deletion

### Deletion Requested

Subject: Account deletion scheduled

Include:
- Deletion date (typically 30 days out)
- Export data link
- Cancel deletion link
- Warning that data cannot be recovered after deletion

### Deletion Complete

Subject: Your Acme account has been deleted

Include:
- Confirmation of deletion
- Feedback request (optional)
- Note that they can create a new account anytime

## Connected Apps / Integrations

### App Connected

Include:
- App name
- Permissions granted
- Timestamp
- Link to manage connected apps

### App Disconnected

Include:
- App name
- Timestamp

## Channel Strategy

| Notification | Email | Push | SMS | Priority |
|--------------|-------|------|-----|----------|
| Welcome | Yes | - | - | Standard |
| Email changed | Yes (both) | - | - | High |
| Password changed | Yes | Yes | - | High |
| 2FA enabled | Yes | Yes | - | High |
| 2FA disabled | Yes | Yes | Yes | Critical |
| Usage warning | Yes | - | - | Standard |
| Usage limit | Yes | Yes | - | High |
| Account deletion | Yes | - | - | High |

## Security Considerations

### Always Include "I Didn't Do This"

For security-sensitive changes:
- Password changes
- Email changes
- 2FA changes
- New device logins

Include a "Secure My Account" button and security contact.

### Mask Sensitive Data

- Email: j***@example.com
- Phone: •••• 4567
- Card: •••• 4242

## Best Practices

### Timing

- Security changes: Immediate
- Profile updates: Immediate
- Usage alerts: As threshold crossed
- Deletion: Immediate + reminder at 7 days

### Confirmation Pattern

Always confirm significant changes:
1. User takes action
2. System processes change
3. Send confirmation with details
4. Include "undo" or "report" option

### Keep Records

Log what notifications were sent for:
- Security audits
- Customer support
- Internal auditing and data-deletion requests

## Related

- [Authentication](./authentication.md) - Password reset, security alerts
- [Preferences](../guides/preferences.md) - User notification preferences

````


### `resources/transactional/appointments.md`

````markdown
# Appointment Notifications

## Quick Reference

### Rules
- Confirmation: Send within 1 minute of booking
- ALWAYS display times in user's local timezone
- ALWAYS include timezone abbreviation (e.g., "2:00 PM PST")
- Include calendar links (Google, Outlook, .ics)
- SMS reminders: Keep under 160 characters
- Virtual appointments: Include join link prominently

### Reminder Schedule
| Timing | Channels | Purpose |
|--------|----------|---------|
| 7 days before | Email | Allow rescheduling |
| 24 hours before | Email + SMS | Final preparation |
| 2 hours before | SMS + Push | Immediate reminder |
| 15 min before (virtual) | Push | Join call reminder |

### Required Fields
| Notification | Required |
|--------------|----------|
| Confirmation | Date, time, timezone, location/link, provider, calendar links |
| Reminder | Date, time, location/link, confirm/reschedule option |
| Reschedule | Old time, new time, provider, calendar links |
| Virtual join | Join link, password (if any), "join 5 min early" note |

### Common Mistakes
- Missing timezone (user in different timezone gets wrong time)
- No calendar links (user has to manually add)
- SMS reminder over 160 chars (gets split, costs more)
- No easy reschedule option
- Virtual: Join link buried in text
- No pre-appointment checklist email
- Missing no-show follow-up

### Templates

**Booking Confirmation (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbwr7x1q5v8d2c6n4w9hj",
    data: {
      date: "January 30, 2026",
      time: "2:00 PM PST",
      provider: "Dr. Smith",
      location: "123 Medical Center",
      calendarLinks: { google: "...", outlook: "...", ics: "..." }
    }
  }
}, {
  headers: { "Idempotency-Key": `appt-confirmed-user-123-appt-456` }
});
```

**Booking Confirmation (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbwr7x1q5v8d2c6n4w9hj",
        "data": {
            "date": "January 30, 2026",
            "time": "2:00 PM PST",
            "provider": "Dr. Smith",
            "location": "123 Medical Center",
            "calendarLinks": {"google": "...", "outlook": "...", "ics": "..."},
        },
    },
    extra_headers={"Idempotency-Key": "appt-confirmed-user-123-appt-456"},
)
```

**24-Hour Reminder:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbx5q8x2v6d1c4n7w9hj",
    data: {
      date: "Tomorrow",
      time: "2:00 PM PST",
      provider: "Dr. Smith",
      location: "123 Medical Center"
    },
    routing: { method: "all", channels: ["email", "sms"] }
  }
}, {
  headers: { "Idempotency-Key": `appt-reminder-24h-user-123-appt-456` }
});
```

**SMS Reminder (under 160 chars):**
```
Acme: Reminder - Appt with Dr. Smith tomorrow at 2pm. 123 Medical Center. Reply Y to confirm.
```

---

## Appointment Lifecycle

| Stage | Notification | Channels |
|-------|--------------|----------|
| Booked | Confirmation | Email |
| 7 days before | Reminder | Email |
| 24 hours before | Reminder | Email + SMS |
| 2 hours before | Reminder | SMS + Push |
| 15 min before (virtual) | Join reminder | Push |
| After | Follow-up | Email |

## Booking Confirmation

### Timing

Send **immediately** after booking (within 1 minute).

### Content Requirements

- Appointment date and time (with timezone)
- Location or video link
- Provider/staff name (if applicable)
- What to bring/prepare
- Cancellation/reschedule policy
- Add to calendar links
- Contact information

### Email Content

Subject: Appointment confirmed - January 30 at 2:00 PM

Include:
- Confirmation checkmark
- Date, time, timezone
- Location with address or video link
- Provider name
- Calendar links (Google, Outlook, .ics download)
- What to bring section
- Reschedule/Cancel buttons
- Cancellation policy

### Virtual Appointments

For video appointments, include:
- Join link prominently displayed
- Video password (if applicable)
- "Click the link 5 minutes before" instruction

## Reminders

### Reminder Strategy

| Timing | Channels | Purpose |
|--------|----------|---------|
| 7 days before | Email | Allow rescheduling |
| 24 hours before | Email + SMS | Final preparation |
| 2 hours before | SMS + Push | Immediate reminder |
| 15 minutes before | Push (virtual only) | Join call reminder |

### 24-Hour Reminder

**Email:** Full details with date, time, location, provider, and what to bring.

**SMS:** Keep under 160 characters. Example: "Acme: Reminder - Your appointment with Dr. Johnson is tomorrow at 2:00 PM. Location: 123 Main St. Reply HELP for info."

### 2-Hour Reminder

**SMS:** Brief reminder with essential details.

**Push:** Title "Appointment in 2 hours" / Body "Dr. Johnson at 2:00 PM - 123 Main St" / Action "Get Directions"

### Virtual Appointment - 15 Minutes

**Push only:** Title "Your appointment starts in 15 minutes" / Body "Tap to join your video call with Dr. Johnson" / Action opens video link.

## Rescheduling

### User Initiated

Subject: Appointment rescheduled to February 3 at 10:00 AM

Include:
- Previous date/time (crossed out)
- New date/time (highlighted)
- Location and provider
- Add to calendar link
- Reschedule/Cancel buttons for further changes

### Provider Initiated

Channels: Email + SMS + Push (higher urgency)

Subject: Your appointment needs to be rescheduled

Include:
- Apology for inconvenience
- Previous date/time
- Reason (briefly)
- Available alternative times
- Reschedule button
- Phone number to call

## Cancellation

### Confirmation

Subject: Appointment canceled - January 30 at 2:00 PM

Include:
- Canceled appointment details
- Provider name
- Refund info (if applicable)
- Rebook button

## Waitlist

### Added to Waitlist

Include:
- Requested date
- Provider
- Position on waitlist
- Estimated wait time

### Slot Available

High priority - user needs to act quickly. Channels: Email + SMS + Push.

Include:
- Available date/time
- Provider name
- Time limit to claim (e.g., "Book within 4 hours")
- Book now button

Push example: "A slot opened up! Dr. Johnson on Feb 1 at 3PM. Book now - expires in 4 hours."

## Pre-Appointment

### Preparation Instructions

Send 2-3 days before.

Include:
- Checklist of tasks (e.g., complete intake form, upload documents)
- Links to forms
- Deadline for completion

### Check-In Available

For locations with digital check-in.

Channels: Email + Push

Include:
- When check-in opens (e.g., "30 minutes before")
- Check-in link

## No-Show Handling

Subject: We missed you at your appointment

Include:
- Missed appointment details
- Understanding tone ("If something came up, we understand")
- Rebook button
- No-show fee info (if applicable)
- Contact option

## Channel Strategy

| Notification | Email | SMS | Push | Timing |
|--------------|-------|-----|------|--------|
| Booking confirmation | Yes | - | - | Immediate |
| 7-day reminder | Yes | - | - | 7 days before |
| 24-hour reminder | Yes | Yes | - | 24 hours before |
| 2-hour reminder | - | Yes | Yes | 2 hours before |
| 15-min reminder (virtual) | - | - | Yes | 15 min before |
| Reschedule needed | Yes | Yes | Yes | Immediate |
| Waitlist slot | Yes | Yes | Yes | Immediate |

## Best Practices

### Time Zones

Always display times in user's local timezone. Include timezone abbreviation (e.g., "2:00 PM PST").

### Calendar Links

Include multiple calendar options:
- Google Calendar
- Outlook/Office 365
- Apple Calendar (.ics download)

### SMS Length

Keep SMS reminders under 160 characters to avoid splitting.

### Reduce No-Shows

Best practices:
- SMS reminders are highly effective at reducing no-shows
- Multiple touchpoints (7d + 24h + 2h) are most effective
- Include easy reschedule option

## Automated Reminder Ladders

For automated reminder sequences with delays and cancel-on-reschedule (e.g., 7 day → 24 hour → 2 hour → 15 minute), build the reminder ladder as a [Journey](../guides/journeys.md). Use delay nodes for the timing gaps, branch nodes to check for reschedule/cancellation, and exit nodes to stop the sequence early.

See [Journeys](../guides/journeys.md) for the full create → template → wire → publish → invoke workflow and node type reference.

## Related

- [Journeys](../guides/journeys.md) - Build automated reminder ladders with delays and branches
- [SMS](../channels/sms.md) - SMS reminder best practices
- [Push](../channels/push.md) - Push notification timing
- [Multi-Channel](../guides/multi-channel.md) - Reminder channel strategy

````


### `resources/transactional/authentication.md`

````markdown
# Authentication Notifications

## Quick Reference

### Rules
- OTP codes: 6 digits, expire in 5-10 minutes, single use
- Password reset: 1 hour expiry, single use, secure token (UUID v4)
- Email verification: 24-48 hour expiry, allow resend after 60 seconds
- Magic links: 15 minute expiry, single use
- ALWAYS include "I didn't request this" in security emails
- NEVER batch or delay OTP, password reset, or security alert notifications — send them immediately, bypass digests/quiet hours
- Security alerts fan out based on severity — see [Security Alert Channels](#security-alert-channels) below; most events go to email + push, and the highest-severity events (password change, 2FA disabled, suspicious activity) also include SMS

### Rate Limits
| Action | Limit | Lockout |
|--------|-------|---------|
| Password reset | 3/hour | 1 hour |
| OTP request | 5/hour | 1 hour |
| Magic link | 5/hour | 1 hour |
| Verification resend | 3/hour | 30 minutes |

### Security Alert Channels
| Event | Channels |
|-------|----------|
| New device login | Email + Push |
| Password changed | Email + Push + SMS |
| Email changed | Old email + New email + SMS |
| 2FA disabled | Email + Push + SMS |
| Suspicious activity | All channels |

### Common Mistakes
- OTP expiry too long (> 10 minutes is security risk)
- No rate limiting on password reset (abuse vector)
- Missing "I didn't request this" link
- Not invalidating token after use
- Sending OTP via email as primary (SMS preferred)
- No security alert when password/email changes
- Not logging "I didn't request this" clicks

### Templates

**Send OTP (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { phone_number: "+15551234567" },
    content: {
      body: "Your Acme code is 847293. Expires in 10 min. Never share this code."
    }
  }
}, {
  // otpRequestId: row ID from your OTP attempts table. Using it in the key
  // means retries of THIS attempt collapse, while the user's "Resend code"
  // button generates a new otpRequestId → new send.
  headers: { "Idempotency-Key": `otp-user123-${otpRequestId}` },
});
```

**Send OTP (Python):**
```python
# otp_request_id is the row ID from your OTP attempts table.
client.send.message(
    message={
        "to": {"phone_number": "+15551234567"},
        "content": {
            "body": "Your Acme code is 847293. Expires in 10 min. Never share this code."
        },
    },
    extra_headers={"Idempotency-Key": f"otp-user123-{otp_request_id}"},
)
```

**Security Alert (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbv3q6x9v2d5c8n1w4ht",
    data: { device: "Chrome on Windows", location: "New York" },
    routing: { method: "all", channels: ["email", "push", "sms"] }
  }
}, {
  headers: { "Idempotency-Key": `login-alert-user-123-${sessionId}` }
});
```

**Security Alert (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbv3q6x9v2d5c8n1w4ht",
        "data": {"device": "Chrome on Windows", "location": "New York"},
        "routing": {"method": "all", "channels": ["email", "push", "sms"]},
    },
    extra_headers={"Idempotency-Key": f"login-alert-user-123-{session_id}"},
)
```

---

Best practices for password reset, OTP, verification, and security alerts. See [Transactional Overview](./index.md) for delivery timing requirements.

## Password Reset

### Flow

1. User clicks "Forgot Password"
2. Generate secure token (UUID v4) and store with expiry
3. Send reset email immediately
4. User clicks link → Verify token → Allow password change

### Email Content

Subject: Reset your password for Acme

Include:
- Reset button/link
- Expiration time (e.g., "This link expires in 1 hour")
- User's email address for context
- "I didn't request this" option with security contact

### Best Practices

- **Token expiration:** 1 hour (not too long, not too short)
- **Single use:** Invalidate token after use
- **Secure generation:** UUID v4 or crypto.randomBytes
- **Rate limiting:** Max 3 requests per hour per email
- **Include "I didn't request this":** Always
- **Use idempotency keys:** Prevent duplicate sends

## OTP / 2FA Codes

### Channel Selection

SMS is preferred for OTP because:
- Immediate delivery (< 10 seconds)
- Works without internet
- Higher visibility than email
- Users expect codes via SMS

### SMS Content

Example: "Your Acme verification code is: 847293. Expires in 10 minutes. Don't share this code with anyone."

### Best Practices

- **Code format:** 6 digits (not letters - easier to type)
- **Expiration:** 5-10 minutes
- **Rate limiting:** Max 5 codes per hour
- **Warning:** "Don't share this code"
- **Single use:** Invalidate after use or expiration

### Display in Email (Alternative)

If sending OTP via email, display the code prominently in large, monospace font. Include expiration time below.

## Email Verification

### Flow

1. User signs up with email
2. Account created (unverified)
3. Send verification email immediately
4. User clicks link → Verify token → Mark email verified

### Email Content

Subject: Verify your email for Acme

Include:
- Verify Email button
- Expiration time (24 hours)
- Note that they can ignore if they didn't create an account

### Best Practices

- **Longer expiration:** 24-48 hours (not urgent like password reset)
- **Resend option:** Allow after 60 seconds
- **Clear CTA:** Large, prominent button
- **Reminder:** Send again after 24 hours if not verified

## Magic Links (Passwordless)

### Email Content

Subject: Sign in to Acme

Include:
- Sign In button
- Expiration time (15 minutes)
- Note that link can only be used once
- "I didn't request this" option

## Security Alerts

### New Device / Location Login

**Email Content:**

Subject: New sign-in to your Acme account

Include:
- Device information (browser, OS)
- Location (city, country)
- IP address
- Timestamp
- "Secure My Account" button if it wasn't them

### Alert Types

| Event | Urgency | Channels |
|-------|---------|----------|
| New device login | High | Email + Push |
| Password changed | High | Email + Push + SMS |
| Email changed | Critical | Email (old) + Email (new) + SMS |
| 2FA disabled | Critical | Email + Push + SMS |
| Suspicious activity | Critical | All channels |

### Password Changed Alert

Subject: Your Acme password was changed

Include:
- Timestamp of change
- "If you made this change, no action is needed"
- Reset password link if they didn't make the change
- Security contact information

## Multi-Channel Patterns

### OTP with Fallback

Try SMS first, fall back to email if SMS fails. Use single-channel routing with fallback order: SMS → Email.

### Security Alert Escalation

Channel fan-out is **tiered by event severity** — see the canonical [Security Alert Channels](#security-alert-channels) table above. The highest-severity events (suspicious activity) go to every available channel (Email + Push + SMS + In-app) for maximum reach; lower-severity events like a new device login go to Email + Push only to avoid SMS fatigue. Never downgrade the tier for a specific event; if you want to include SMS on new-device-login in your product, update the table — don't silently diverge.

## Rate Limiting

Enforce the rate limits defined in [Quick Reference > Rate Limits](#rate-limits) above. Always lock out the user after exceeding the limit.

## Troubleshooting

### OTP Not Received

1. Check phone number format (E.164)
2. Verify SMS provider configuration
3. Check carrier filtering (10DLC registration)
4. Offer email alternative

### Reset Link Not Working

1. Check token hasn't expired
2. Verify token hasn't been used
3. Check for URL encoding issues
4. Provide support contact

## Related

- [SMS](../channels/sms.md) - SMS delivery
- [Email](../channels/email.md) - Email deliverability
- [Reliability](../guides/reliability.md) - Idempotency for auth flows

````


### `resources/transactional/billing.md`

````markdown
# Billing Notifications

## Quick Reference

### Rules
- Payment confirmation: Send within 1 minute of successful charge
- ALWAYS include amount, date, payment method (last 4 digits)
- Upcoming invoice: Send 3-7 days before charge
- Trial ending: Send 3 days before trial ends
- Dunning: Escalate over time (email → email+push → all channels)
- Deep link directly to payment update form

### Dunning Escalation Schedule
| Day | Action | Channels |
|-----|--------|----------|
| 0 | Initial failure notice | Email |
| 3 | Retry notification | Email |
| 7 | Urgent action required | Email + Push |
| 14 | Final notice | Email + Push + SMS |
| 15+ | Subscription canceled | Email |

### Idempotency Keys
| Notification | Key Pattern |
|--------------|-------------|
| Payment confirmation | `payment-{paymentId}` |
| Invoice | `invoice-{invoiceId}` |
| Dunning | `dunning-{invoiceId}-day-{day}` |
| Trial ending | `trial-ending-{userId}` |

### Common Mistakes
- Delayed payment confirmation (users worry)
- No PDF receipt download option
- Aggressive dunning tone (be helpful, not threatening)
- Not offering downgrade as alternative to cancel
- Missing idempotency keys (duplicate receipts)
- Hard-to-find "update payment" link
- No trial ending reminder

### Templates

**Payment Confirmation:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbr9m2t6qf8w3x5c7v1dh",
    data: {
      amount: "$99.00",
      date: "January 29, 2026",
      paymentMethod: "Visa •••• 4242",
      description: "Pro Plan - Monthly"
    }
  }
}, {
  headers: { "Idempotency-Key": `payment-pay_123abc` }
});
```

**Dunning (Payment Failed):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbret4v8n2q6x1c5d7wfj",
    data: {
      amount: "$99.00",
      reason: "Card declined",
      updateUrl: "https://acme.com/billing",
      daysUntilCancel: 14
    },
    routing: { method: "all", channels: ["email", "push"] }
  }
}, {
  headers: { "Idempotency-Key": `dunning-inv_123-day-7` }
});
```

---

Best practices for receipts, invoices, dunning, and subscription notifications.

## Payment Confirmations

### When to Send

Immediately after successful payment (within 1 minute).

### Content Requirements

- Amount charged
- Date and time
- Payment method (last 4 digits)
- What was purchased
- Invoice/receipt number
- PDF download link (if applicable)
- Support contact

### Email Content

Subject: Payment received - $99.00

Include:
- Payment success indicator
- Amount, date, payment method
- Description of purchase
- Invoice number
- Download/view receipt links
- Billing support contact

Always use idempotency keys (e.g., `payment-{paymentId}`) to prevent duplicate sends.

## Invoice Notifications

### Upcoming Invoice (Subscription)

Send 3-7 days before charge.

Subject: Your upcoming invoice - $99.00 on Feb 1

Include:
- Plan name and amount
- Billing date
- Current payment method
- Update payment method link
- Manage subscription link

### Invoice Finalized

For usage-based or finalized invoices.

Include:
- Invoice number
- Amount due
- Due date
- Line item breakdown
- PDF download link
- Payment link

## Dunning (Payment Failed)

### Strategy Overview

Payment fails → escalate over time using the schedule defined in [Quick Reference > Dunning Escalation Schedule](#dunning-escalation-schedule) above.

### Initial Payment Failed (Day 0)

Subject: Action required: Payment failed for your Acme subscription

Include:
- Amount that failed
- Plan name
- Reason (card declined, insufficient funds, etc.)
- Update payment method button
- Next retry date
- Support contact

### Escalation (Day 7)

Add push notification. Increase urgency in messaging.

Include:
- Days until cancellation
- Clear CTA to update payment

### Final Notice (Day 14)

Maximum urgency - use all channels: Email + Push + SMS.

SMS: "Acme: Your subscription will be canceled tomorrow unless you update payment. Update: acme.com/billing"

## Subscription Notifications

### Subscription Confirmed

Include:
- Plan name and price
- Billing interval (monthly/yearly)
- Start date
- Features included

### Plan Changed

Include:
- Previous plan
- New plan
- New price
- Effective date
- Proration amount (if applicable)

### Trial Ending / Renewal Reminder

A trial-ending or renewal-reminder ladder is the canonical multi-step billing flow. Build it as a [Journey](../guides/journeys.md) so the timing, status checks, and exit conditions live in one place.

**Cadence:**

| When | Channels | Purpose |
|------|----------|---------|
| 7 days before trial end / renewal | Email | First heads-up, room to change plans |
| 3 days before | Email + Push | Reminder with clear next steps |
| 1 day before | Email + Push | Final notice |
| After end | Email | Welcome (converted) or trial-expired (not converted) |

Each reminder must include trial/renewal end date, charge amount, "Continue", "Change plan", and "Cancel" actions. Skip later reminders if the user already converted, canceled, or disabled auto-renew.

**Journey DAG:**

```json
{
  "name": "Trial Ending Reminder",
  "nodes": [
    {
      "id": "trigger-1",
      "type": "trigger",
      "trigger_type": "api-invoke",
      "schema": {
        "type": "object",
        "properties": {
          "user_id": { "type": "string" },
          "subscription_id": { "type": "string" },
          "trial_end": { "type": "string" },
          "reminder_times": {
            "type": "object",
            "properties": {
              "seven_days_before": { "type": "string" },
              "three_days_before": { "type": "string" },
              "one_day_before": { "type": "string" },
              "after_end": { "type": "string" }
            }
          }
        },
        "required": ["user_id", "subscription_id", "trial_end", "reminder_times"]
      }
    },

    { "id": "wait-7d-before", "type": "delay", "mode": "until", "until": "{{reminder_times.seven_days_before}}" },
    {
      "id": "check-status-7d",
      "type": "fetch",
      "method": "get",
      "url": "https://api.yourapp.com/subscriptions/{{subscription_id}}/status",
      "merge_strategy": "overwrite"
    },
    {
      "id": "branch-status-7d",
      "type": "branch",
      "paths": [
        {
          "label": "Already converted or canceled",
          "conditions": ["data.subscription.status", "is not equal", "trialing"],
          "nodes": [{ "id": "exit-7d", "type": "exit" }]
        }
      ],
      "default": {
        "label": "Still trialing",
        "nodes": [
          { "id": "send-7d", "type": "send", "message": { "template": "<trial-7d-template-id>" } }
        ]
      }
    },

    { "id": "wait-3d-before", "type": "delay", "mode": "until", "until": "{{reminder_times.three_days_before}}" },
    {
      "id": "check-status-3d",
      "type": "fetch",
      "method": "get",
      "url": "https://api.yourapp.com/subscriptions/{{subscription_id}}/status",
      "merge_strategy": "overwrite"
    },
    {
      "id": "branch-status-3d",
      "type": "branch",
      "paths": [
        {
          "conditions": ["data.subscription.status", "is not equal", "trialing"],
          "nodes": [{ "id": "exit-3d", "type": "exit" }]
        }
      ],
      "default": {
        "nodes": [
          { "id": "send-3d", "type": "send", "message": { "template": "<trial-3d-template-id>" } }
        ]
      }
    },

    { "id": "wait-1d-before", "type": "delay", "mode": "until", "until": "{{reminder_times.one_day_before}}" },
    {
      "id": "check-status-1d",
      "type": "fetch",
      "method": "get",
      "url": "https://api.yourapp.com/subscriptions/{{subscription_id}}/status",
      "merge_strategy": "overwrite"
    },
    {
      "id": "branch-status-1d",
      "type": "branch",
      "paths": [
        {
          "conditions": ["data.subscription.status", "is not equal", "trialing"],
          "nodes": [{ "id": "exit-1d", "type": "exit" }]
        }
      ],
      "default": {
        "nodes": [
          { "id": "send-1d", "type": "send", "message": { "template": "<trial-1d-template-id>" } }
        ]
      }
    },

    { "id": "wait-after-end", "type": "delay", "mode": "until", "until": "{{reminder_times.after_end}}" },
    {
      "id": "check-status-final",
      "type": "fetch",
      "method": "get",
      "url": "https://api.yourapp.com/subscriptions/{{subscription_id}}/status",
      "merge_strategy": "overwrite"
    },
    {
      "id": "branch-final",
      "type": "branch",
      "paths": [
        {
          "label": "Converted to paid",
          "conditions": ["data.subscription.status", "is equal", "active"],
          "nodes": [
            { "id": "send-welcome-paid", "type": "send", "message": { "template": "<welcome-paid-template-id>" } },
            { "id": "exit-converted", "type": "exit" }
          ]
        }
      ],
      "default": {
        "label": "Trial expired",
        "nodes": [
          { "id": "send-expired", "type": "send", "message": { "template": "<trial-expired-template-id>" } },
          { "id": "exit-expired", "type": "exit" }
        ]
      }
    }
  ],
  "enabled": true
}
```

**Invoke when a trial starts (precompute the timestamps, mirror `user_id` into `data`):**

```typescript
function offsetISO(end: Date, days: number): string {
  return new Date(end.getTime() - days * 24 * 60 * 60 * 1000).toISOString();
}

const trialEnd = new Date("2026-06-01T00:00:00Z");

await client.journeys.invoke(JOURNEY_ID, {
  user_id: "user-123",
  profile: { email: "jane@example.com" },
  data: {
    user_id: "user-123",
    subscription_id: "sub_abc",
    trial_end: trialEnd.toISOString(),
    reminder_times: {
      seven_days_before: offsetISO(trialEnd, 7),
      three_days_before: offsetISO(trialEnd, 3),
      one_day_before: offsetISO(trialEnd, 1),
      after_end: trialEnd.toISOString(),
    },
  },
});
```

```python
from datetime import datetime, timedelta, timezone

trial_end = datetime(2026, 6, 1, tzinfo=timezone.utc)

def offset_iso(end: datetime, days: int) -> str:
    return (end - timedelta(days=days)).isoformat().replace("+00:00", "Z")

client.journeys.invoke(
    template_id=JOURNEY_ID,
    user_id="user-123",
    profile={"email": "jane@example.com"},
    data={
        "user_id": "user-123",
        "subscription_id": "sub_abc",
        "trial_end": trial_end.isoformat().replace("+00:00", "Z"),
        "reminder_times": {
            "seven_days_before": offset_iso(trial_end, 7),
            "three_days_before": offset_iso(trial_end, 3),
            "one_day_before": offset_iso(trial_end, 1),
            "after_end": trial_end.isoformat().replace("+00:00", "Z"),
        },
    },
)
```

**Why this shape:**
- `delay` `mode: "until"` schedules each reminder at an absolute time computed by your app — Courier doesn't compute "N days before" for you.
- The `fetch` node before each send is what makes this safe in production: a converted or canceled user stops getting reminders without any external cancel call.
- Each `send` node references a journey-scoped template (created via `POST /journeys/{id}/templates`). For per-template `name` vs Designer-managed copy, see [journeys.md](../guides/journeys.md) and [templates.md](../guides/templates.md).
- Use idempotent invocation keys at your application layer (one journey run per `subscription_id`) to avoid double-scheduling on retries.

### Subscription Canceled

Subject: Your Acme subscription has been canceled

Include:
- Plan that was canceled
- Access end date
- Resubscribe option
- Feedback request

## Usage Alerts

### Approaching Limit

Send when user reaches 80% of their limit.

Subject: You've used 80% of your API calls

Include:
- Current usage vs limit
- Percentage used
- Estimated time until limit reached
- View usage link
- Upgrade option

### Limit Reached

Channels: Email + Push + In-app

Include:
- What limit was reached
- When it resets
- Upgrade option
- Impact on service

## Channel Strategy

| Notification | Email | Push | SMS | Timing |
|--------------|-------|------|-----|--------|
| Payment successful | Yes | - | - | Immediate |
| Upcoming invoice | Yes | - | - | 3-7 days before |
| Payment failed (initial) | Yes | - | - | Immediate |
| Payment failed (escalation) | Yes | Yes | - | Day 7 |
| Payment failed (final) | Yes | Yes | Yes | Day 14 |
| Trial ending | Yes | Yes | - | 3 days before |
| Usage warning | Yes | Yes | - | At 80% |
| Usage limit reached | Yes | Yes | - | Immediate |

## Best Practices

### Clarity

- State amounts clearly
- Show what was purchased
- Include invoice number
- Provide easy access to PDF

### Payment Updates

- Deep link directly to payment update form
- Pre-fill what you can
- Make the process as short as possible

### Dunning

- Increase urgency gradually
- Be helpful, not threatening
- Offer alternatives (downgrade vs cancel)
- Make it easy to resolve

### Idempotency

Always use idempotency keys — see [Quick Reference > Idempotency Keys](#idempotency-keys) above for the key patterns.

## Related

- [Journeys](../guides/journeys.md) - Multi-step billing flows (trial ending, dunning, renewal reminders)
- [Email](../channels/email.md) - Email design for receipts
- [SMS](../channels/sms.md) - SMS for urgent billing alerts
- [Reliability](../guides/reliability.md) - Idempotency for billing

````


### `resources/transactional/index.md`

````markdown
# Transactional Notifications

## Quick Reference

### Rules
- Transactional = triggered by user action, expected, required for service
- NO promotional content allowed (reclassifies as marketing, requires opt-in)
- No explicit opt-in required (implied consent from action)
- Send immediately or near-immediately
- ALWAYS use idempotency keys

### Timing Requirements
| Type | Delivery Time |
|------|---------------|
| OTP/2FA | < 10 seconds |
| Password reset | < 30 seconds |
| Order confirmation | < 1 minute |
| Security alert | < 1 minute |
| Shipping notification | < 5 minutes |

### Channel Selection
| Type | Primary | Fallback |
|------|---------|----------|
| OTP | SMS | Email |
| Password reset | Email | - |
| Order confirmation | Email | - |
| Shipping | Email + Push | - |
| Security alert | Tiered by severity (see [Security Alert Channels](./authentication.md#security-alert-channels)) | - |

### Common Mistakes
- Adding promotional content ("Use code SAVE20 on your next order!")
- Not using idempotency keys (causes duplicate sends)
- Slow delivery (users expect immediate)
- Missing essential information (order number, tracking link)
- No "I didn't request this" option in security emails
- Allowing opt-out from critical transactional (security, receipts)

### Template

**Transactional Send with Idempotency:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbq6ypf25tsge12qek41r0",
    data: { orderId: "12345", items: [...] }
  }
}, {
  headers: { "Idempotency-Key": `order-confirmation-12345` }
});
```

---

Core principles for sending transactional notifications that users expect and trust.

## What Makes a Notification Transactional?

Transactional notifications are:
- **Triggered by user action** or system event (not marketing campaigns)
- **Expected by the user** (they initiated something)
- **Required for service delivery** (order confirmations, OTPs, etc.)
- **Time-sensitive** (deliver immediately or near-immediately)

## What Counts as Transactional

Transactional notifications:
- **Do not require explicit opt-in** — consent is implied from the user's action
- **Cannot contain promotional content** — adding promo reclassifies the message as marketing and pulls it under marketing rules
- **Must be primarily informational** (confirm, update, alert)

A message is transactional if it:
1. Facilitates or confirms a transaction the user agreed to
2. Provides information about an ongoing relationship
3. Delivers goods/services the user is entitled to receive
4. Provides account information (balance, usage, etc.)
5. Informs about changes to terms/features the user is using

**If you add promotional content**, treat the message as marketing and require opt-in.

## Categories

| Category | Examples | See |
|----------|----------|-----|
| Authentication | Password reset, OTP, verification, security alerts | [Authentication](./authentication.md) |
| Orders | Order confirmation, shipping, delivery, returns | [Orders](./orders.md) |
| Billing | Receipts, invoices, dunning, subscriptions | [Billing](./billing.md) |
| Appointments | Booking confirmations, reminders, rescheduling | [Appointments](./appointments.md) |
| Account | Welcome (non-promo), profile updates, settings | [Account](./account.md) |

## Core Principles

### 1. Immediate Delivery

Transactional notifications should be sent in real-time or near real-time. See the [Timing Requirements](#timing-requirements) table in Quick Reference for delivery targets by type.

Use idempotency keys to prevent duplicates while allowing safe retries (see the [Quick Reference template](#template) above for the pattern).

### 2. Clarity Over Creativity

Users need to understand and act quickly.

**Good:** Subject "Reset your password for Acme" with body "Click to reset your password. Link expires in 1 hour."

**Bad:** Subject "Important Security Update!" with body "We noticed something about your account..."

### 3. No Promotional Content

Keep transactional messages purely informational.

**Good (transactional):** "Your order #12345 has shipped. Track at acme.co/track/12345"

**Bad (now requires opt-in):** "Your order shipped! PS: Use code SAVE20 on your next order!"

### 4. Include Essential Information

| Notification Type | Must Include |
|-------------------|--------------|
| Password reset | Reset link, expiration, "I didn't request this" |
| OTP | Code, expiration, don't share warning |
| Order confirmation | Order number, items, total, estimated delivery |
| Shipping | Tracking number, carrier, estimated arrival |
| Payment receipt | Amount, date, payment method, what was purchased |

## Channel Selection

### By Urgency

| Urgency | Primary Channel | Backup |
|---------|----------------|--------|
| Critical (OTP, security) | SMS | Email |
| High (password reset) | Email | SMS |
| Medium (shipping) | Email + Push | - |
| Standard (receipts) | Email | In-app |

### By Type

- **OTP codes:** SMS primary, email fallback
- **Order shipped:** Email + Push
- **Security alerts:** Tiered by severity — lower-severity events (e.g. new device login) go to Email + Push; high-severity events (password changed, 2FA disabled, suspicious activity) add SMS for maximum reach. See [Security Alert Channels](./authentication.md#security-alert-channels) for the canonical table.

## Subject Line Patterns

### Password Reset
- Reset your password for [App]
- [App] password reset request
- Your [App] password reset link

### Order Confirmation
- Order #12345 confirmed
- Your [App] order is confirmed
- Thanks for your order (#12345)

### Shipping
- Your order has shipped
- Order #12345 is on the way
- Your package shipped - arriving Thursday

### Payment
- Payment received - $99.00
- Receipt for your purchase
- Payment confirmation for [App]

## Error States

### What Users Need

**Resend functionality:**
- Allow after 60 seconds
- Per-flow limits (see [authentication.md](./authentication.md) for OTP / magic link / password reset / verification; authentication is the authoritative source for these caps)
- Show countdown timer

**Expired links:**
- Clear "expired" message
- Offer to send new link
- Provide support contact

**"I didn't request this":**
- Include in security-related **emails** — password resets, security alerts, and magic links (SMS OTPs don't carry trackable links; include a "Didn't request? Reply STOP and contact support" line instead)
- Link to security contact
- Log clicks for monitoring (email only)

## Testing

### Test Matrix

Test each transactional notification for:
- Delivery within expected time
- Correct content/data
- Mobile rendering
- Link functionality
- Expiration behavior
- Error states

## Related

- [Reliability](../guides/reliability.md) - Idempotency and retry patterns
- [Multi-Channel](../guides/multi-channel.md) - Channel routing strategies

````


### `resources/transactional/orders.md`

````markdown
# Order Notifications

## Quick Reference

### Rules
- Order confirmation: Send within 1 minute of order placement
- ALWAYS include order number prominently
- ALWAYS use idempotency keys: `order-confirmation-{orderId}`
- Shipping: Send as soon as tracking number available
- Multi-package: Send separate notification per package
- NO promotional content in order notifications

### Required Fields by Type
| Notification | Required Fields |
|--------------|-----------------|
| Order confirmation | Order #, items, total, address, delivery estimate |
| Shipping | Order #, tracking #, carrier, items, ETA |
| Out for delivery | Order #, delivery window |
| Delivered | Order #, timestamp, proof photo (if available) |
| Refund | Order #, amount, method, processing time |

### Channel Selection
| Stage | Channels |
|-------|----------|
| Order placed | Email + In-App |
| Shipped | Email + Push |
| Out for delivery | Push + SMS |
| Delivered | Email + Push |
| Delivery exception | Email + Push + SMS |

### Common Mistakes
- Delayed order confirmation (users expect immediate)
- Missing order number in subject line
- Not including tracking link
- Promotional content ("Use SAVE20 on next order!")
- No idempotency key (duplicate confirmations)
- Not handling multi-package orders separately
- Missing "delivery exception" notification

### Templates

**Order Confirmation:**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbq6ypf25tsge12qek41r0",
    data: {
      orderNumber: "12345",
      items: [{ name: "Widget", qty: 2, price: 29.99 }],
      total: 64.97,
      estimatedDelivery: "Jan 30-31"
    }
  }
}, {
  headers: { "Idempotency-Key": `order-confirmation-12345` }
});
```

**Shipping Update (TypeScript):**
```typescript
await client.send.message({
  message: {
    to: { user_id: "user-123" },
    template: "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
    data: {
      orderNumber: "12345",
      trackingNumber: "1Z999AA10123456784",
      carrier: "UPS",
      trackingUrl: "https://acme.co/track/12345"
    },
    routing: { method: "all", channels: ["email", "push"] }
  }
}, {
  headers: { "Idempotency-Key": `shipping-12345-shipped` }
});
```

**Shipping Update (Python):**
```python
client.send.message(
    message={
        "to": {"user_id": "user-123"},
        "template": "nt_01kmrbqf7z9dn2v6w4x8cj5ht",
        "data": {
            "orderNumber": "12345",
            "trackingNumber": "1Z999AA10123456784",
            "carrier": "UPS",
            "trackingUrl": "https://acme.co/track/12345",
        },
        "routing": {"method": "all", "channels": ["email", "push"]},
    },
    extra_headers={"Idempotency-Key": "shipping-12345-shipped"},
)
```

---

Best practices for order confirmations, shipping updates, and delivery notifications.

## Order Lifecycle

| Stage | Notification | Channels |
|-------|--------------|----------|
| Order Placed | Order Confirmation | Email + In-App |
| Order Confirmed | Confirmation | Email + In-App |
| Shipped | Shipping Notification | Email + Push |
| Out for Delivery | Delivery Update | Push + SMS |
| Delivered | Delivery Confirmation | Email + Push |

> In-app is included on the order-placed and order-confirmed stages to match the Quick Reference and to give users a persistent confirmation alongside the email receipt. If your app doesn't have an in-app notification center, just send email for those two stages.

## Order Confirmation

### Timing

Send **immediately** after order is placed (within 1 minute).

### Content Requirements

- Order number (prominent)
- Items ordered with quantities and prices
- Subtotal, tax, shipping, total
- Shipping address
- Estimated delivery date
- Payment method (last 4 digits)
- Order tracking link (if available)
- Support contact

### Email Structure

Subject: Order #12345 confirmed - Thanks for your order!

Include sections for:
- Logo and order number header
- Item list with images, quantities, and prices
- Order totals breakdown
- Shipping address
- Estimated delivery
- View Order button

Always use idempotency keys (e.g., `order-confirmation-{orderId}`) to prevent duplicate sends.

## Shipping Notifications

### When to Send

- When carrier provides tracking number
- Include tracking link immediately

### Multi-Package Orders

For orders with multiple packages, send a notification for each package with:
- Package number (e.g., "Package 1 of 3")
- Items in this package
- Tracking number for this package

### Email Content

Subject: Your order has shipped - Track your package

Include:
- Carrier name
- Tracking number
- Tracking link/button
- Estimated arrival date
- List of items in shipment

### Push Notification

Title: "Your order shipped!"
Body: "Order #12345 is on its way. Tap to track."
Include deep link to tracking page.

## Delivery Updates

### Out for Delivery

High urgency - use push/SMS for real-time awareness.

Push: "Your order is out for delivery! Arriving today between 2:00 PM - 6:00 PM"

SMS: "Acme: Your order #12345 is out for delivery. Arriving today 2-6 PM. Track: acme.co/t/12345"

### Delivered

Channels: Email + Push

Include:
- Delivery timestamp
- Proof of delivery photo (if available)
- "Rate your experience" option

### Delivery Exception

Handle issues proactively. Send to all channels: Email + Push + SMS.

Include:
- What happened
- Next attempt date
- Support link

## Returns & Refunds

### Return Initiated

Include:
- Return number
- Items being returned
- Return label download
- Drop-off locations

### Return Received

Include:
- Confirmation that return was received
- Refund amount
- Refund method
- Processing time (e.g., "3-5 business days")

### Refund Processed

Include:
- Refund amount
- Payment method refunded to
- Transaction ID

## Back in Stock

For items the user requested to be notified about.

Channels: Email + Push

Include:
- Product name and image
- Current price
- Direct link to purchase

**Note:** This is transactional (user requested it), not marketing.

## Channel Strategy

| Event | Email | Push | SMS | In-App |
|-------|-------|------|-----|--------|
| Order confirmed | Yes | - | - | Yes |
| Order shipped | Yes | Yes | - | Yes |
| Out for delivery | - | Yes | Yes | Yes |
| Delivered | Yes | Yes | - | Yes |
| Delivery exception | Yes | Yes | Yes | Yes |
| Return label | Yes | - | - | - |
| Refund processed | Yes | Yes | - | Yes |

## Best Practices

### Order Numbers

- Make them prominent
- Use consistent format (e.g., #12345)
- Include in subject lines
- Make searchable in email

### Images

- Include product images (helps recognition)
- Optimize for email (< 100KB each)
- Use alt text for accessibility

### Timing

- Confirmation: Immediate (< 1 minute)
- Shipping: As soon as tracking available
- Delivery updates: Real-time from carrier

### Idempotency

Always use idempotency keys to prevent duplicate sends:
- `order-confirmation-{orderId}`
- `shipment-{shipmentId}`
- `delivery-{orderId}-{deliveryId}`

## Related

- [Email](../channels/email.md) - Email design best practices
- [Push](../channels/push.md) - Push notification patterns
- [SMS](../channels/sms.md) - SMS for delivery alerts
- [Reliability](../guides/reliability.md) - Idempotency patterns

````
