---
title: "Templates, rules and webhooks"
description: "Every `templates`, `rules` and `webhooks` command: stored bodies you send by slug, rules that file arriving mail, and signed events for your own server."
url: "https://nextmail.sh/docs/cli/automation"
area: "CLI"
category: "Commands by area"
---

# Templates, rules and webhooks

Every `templates`, `rules` and `webhooks` command: stored bodies you send by slug, rules that file arriving mail, and signed events for your own server.

## Three namespaces

These three namespaces let a mailbox run without anyone watching it. `templates` stores bodies you send many times, `rules` files mail as it arrives, and `webhooks` tells your own server what happened. Each command is an SDK method under its kebab-case name, so `webhooks.rotateSecret` is `nextmail webhooks rotate-secret`, and it reads arguments and flags like every other resource command.

| Namespace | Also | Reads need | Changes need |
| --- | --- | --- | --- |
| templates | `template` | `templates:read` | `templates:write`, and `emails:send` as well for `send` |
| rules | `rule` | `rules:read`, `test` included | `rules:write` |
| webhooks | `webhook` | `webhooks:read` | `webhooks:write`, `test` and `replay-delivery` included |

This page lists every command and what is worth knowing before you script it. For every argument and flag, with its type, the scopes it needs, its endpoint and what it returns, run `nextmail <namespace> <verb> --help`. Add `--json` to get the same page as JSON.

**Help**

```
nextmail templates --help
nextmail rules create --help
nextmail webhooks replay-delivery --help --json
```

## Templates

Bodies stored once and sent many times, with versions, previews and typed props. Every command that takes `<id-or-slug>` accepts the `tpl_` id or the slug. The slug never changes when the template is renamed, so pin the slug in scripts.

| Command | What it does |
| --- | --- |
| nextmail templates list | List templates, most recently updated first. `--status` keeps draft, active or archived ones, `--search` matches names, slugs and subjects, and `--sort` picks the order |
| nextmail templates get <id-or-slug> | Read a template with its head version in full, body included |
| nextmail templates create --name <value> | Create a template and its first version. It stays a draft unless you pass `--publish`, and `--starter` seeds it from a starter design |
| nextmail templates update <id-or-slug> | Edit the name, slug, description or status, or the draft body. Sends keep the published version until you publish |
| nextmail templates duplicate <id-or-slug> | Copy the head version into a new template, which starts as a draft |
| nextmail templates replace-content <id-or-slug> | Swap the body for a starter's (`--starter`) or another template's (`--from-template-id`). Asks you to confirm |
| nextmail templates delete <id-or-slug> | Delete a template and every version. Asks you to confirm |
| nextmail templates list-versions <id-or-slug> | List the versions, newest first, without their bodies |
| nextmail templates get-version <id-or-slug> <version> | Read one version with its body, without touching the draft |
| nextmail templates publish <id-or-slug> | Publish the draft so sends resolve to it. Publishing a head that is already live changes nothing |
| nextmail templates restore-version <id-or-slug> <version> | Bring an older version's body back as the draft. Asks you to confirm |
| nextmail templates delete-version <id-or-slug> <version> | Delete one version. The live version, the head and the only version are refused. Asks you to confirm |
| nextmail templates list-starters | List the built-in starter designs |
| nextmail templates get-starter <slug> | Read one starter in full, with its block tree and a rendered preview |
| nextmail templates list-fonts | List the web fonts a template may load |
| nextmail templates render | Render a body that is not stored anywhere, from `--html` or `--document` |
| nextmail templates preview <id-or-slug> | Render a stored template with `--props` and `--slots`, drafts included, without sending it |
| nextmail templates get-analytics <id-or-slug> | Sends, opens and clicks in a window, by day, by source and by version |
| nextmail templates list-sends <id-or-slug> | The individual messages the template sent, newest first, a page at a time |
| nextmail templates send <id-or-slug> --from <value> --to <a,b> | Send an email rendered from the published version, or from the one `--template-version` pins |
| nextmail templates list-images | List the images uploaded for templates, newest first, each with its public url |
| nextmail templates upload-image <data> | Upload an image for templates and print its public url. `--content-type` names its type |
| nextmail templates design --name <value> --brief <value> | Design a new template from a written brief and save it as a draft, or live with `--publish` |
| nextmail templates redesign <id-or-slug> --instructions <value> | Apply written changes to a template design. The change lands in the draft |

> A template has a head version, which is a draft while it has unpublished edits, and a published version, which is what a send without `--template-version` uses. `create` without `--publish`, a body edit with `update`, `replace-content` and `restore-version` all write the draft, so recipients see nothing new until `publish`.

- An archived template refuses to send with `template_archived`. `publish` makes it active again.
- A workspace holds at most 200 templates, archived ones included, so deleting is the only way to make room.
- `delete` is refused with `template_in_use` while a scheduled or queued broadcast still names the template.

## Rules

Conditions and actions evaluated on arriving mail, in the order `rules list` shows. A rule acts only on mail that arrives while it is enabled. No command applies a rule to mail already in the mailbox, and `rules test` is how you see what it would catch. Rule ids start with `rul_`.

| Command | What it does |
| --- | --- |
| nextmail rules list | List rules in the order they run. `--enabled` or `--no-enabled` keeps one kind |
| nextmail rules get <id> | Read one rule, with `matchCount` and `lastMatchedAt` |
| nextmail rules create --name <value> --conditions <json\|@file\|-> --actions <json\|@file\|-> | Create a rule at the end of the order. It is enabled unless you pass `--no-enabled` |
| nextmail rules update <id> | Change a rule. `--conditions` and `--actions` replace the whole list, and `--position` moves only this rule |
| nextmail rules delete <id> | Delete a rule. What it already did stays in `list-runs`. Asks you to confirm |
| nextmail rules reorder <rule-ids...> | Set the order of every rule at once, naming each rule exactly once |
| nextmail rules test <id> | Dry run a rule against mail already in the mailbox. It changes nothing, and works on a disabled rule |
| nextmail rules list-runs | What rules actually did to arriving mail, newest first. `--rule-id` and `--thread-id` narrow it |

`--conditions` is a list of `{ field, op, value }` objects, joined by `--match all` or `--match any`, where `value` is always a string and `negate: true` inverts one condition. `--actions` is a list of `{ type, value }` objects, applied in order. A rule takes 1 to 20 conditions and 1 to 10 actions, and a mailbox holds at most 100 rules.

- Condition fields: `from`, `from_domain`, `envelope_from`, `to`, `cc`, `bcc`, `recipient`, `reply_to`, `delivered_to`, `subject`, `body`, `header`, `list_id`, `attachment_name`, `attachment_type`, `has_attachment`, `attachment_size`, `message_size`, `spam`, `bounced`, `hour` and `weekday`.
- Operators: `matches`, `contains`, `equals`, `starts_with`, `ends_with`, `gt` and `lt`. `gt` and `lt` work only on the number fields, and `has_attachment`, `spam` and `bounced` take only `equals` with `true` or `false`.
- Action types: `label`, `remove_label`, `archive`, `mark_read`, `star`, `spam`, `trash`, `forward`, `reply`, `block_sender` and `reject`. `label` and `remove_label` take a label id such as `USER_RECEIPTS`, `forward` takes an address and `reply` takes a template id or slug.
- `from_domain` also matches subdomains, and `hour` and `weekday` are read in UTC, with `0` for Sunday.
- A rule with a `reject` action must also test `envelope_from`, or it is refused with `reject_needs_envelope`.

## Webhooks

Endpoints on your own server that receive signed mailbox events, with their signing secrets, their delivery log and an audit log of every change. Endpoint ids start with `whe_` and delivery ids with `whd_`.

| Command | What it does |
| --- | --- |
| nextmail webhooks list | List the endpoints in the workspace, newest first, with their health |
| nextmail webhooks get <id> | Read one endpoint. The signing secret is never part of a read |
| nextmail webhooks create --url <value> | Register an HTTPS endpoint. It prints the signing secret, the only time you see that secret |
| nextmail webhooks update <id> | Change the URL, the events, the allowlists or whether it is enabled. Each list replaces the stored one |
| nextmail webhooks delete <id> | Delete an endpoint and its delivery log. Asks you to confirm |
| nextmail webhooks rotate-secret <id> | Issue a new signing secret. The old one keeps signing for 24 hours. Asks you to confirm |
| nextmail webhooks test <id> | Send a signed synthetic `email.sent` event and report how delivery went |
| nextmail webhooks list-deliveries <id> | One endpoint's delivery attempts, newest first. `--status`, `--since` and `--until` narrow it |
| nextmail webhooks get-delivery <id> <delivery-id> | One attempt in full: the body sent, your server's answer, every try of the event, and whether a replay would be accepted |
| nextmail webhooks replay-delivery <id> <delivery-id> | Send one stored event to the endpoint again, now |
| nextmail webhooks list-workspace-deliveries | Delivery attempts across every endpoint, or the ones `--endpoint-ids` names |
| nextmail webhooks list-activity <id> | The audit log of one endpoint: who created, changed, tested, replayed or removed it |
| nextmail webhooks list-workspace-activity | The audit log of every endpoint, removed ones included |
| nextmail webhooks list-events | Every event an endpoint can subscribe to, with when it fires, and the limits an endpoint is held to |
| nextmail webhooks stats | How the deliveries went inside a window: attempts, delivered and failed, a series, the events and the response codes. `--endpoint-ids`, `--since`, `--until` and `--grain` narrow it |

Leave `--event-types` out and an endpoint receives the default set, the `email.*` events other than `email.replied`. `email.replied` and every other family, `domain.*`, `suppression.*`, `file.*`, `form.*`, `automation.*`, `contact.*`, `sending.*`, `connector.*`, `task.*`, `approval.*`, `booking.*` and `broadcast.*`, reach it only when you name them. `--address-allowlist` and `--domain-allowlist` narrow an endpoint to some addresses or domains, the way they narrow an API key.

- A workspace holds 10 endpoints unless support has raised its limit.
- An endpoint that fails 100 deliveries in a row is switched off by the server, and `webhooks update <id> --enabled` brings it back.
- With a browser sign-in, only the workspace owner can read a delivery with `get-delivery`. Anyone else gets `owner_only` and exit code `4`.

## Check a template, then publish it

`templates preview` renders exactly what a send with the same values would produce, drafts included, and needs only `templates:read`, so even a read-only key can run it. It reports a missing required prop as a warning where `send` would refuse it, so fail the build on any warning. `publish` is safe on every deploy, because publishing a head that is already live changes nothing.

**CI**

```
draft=$(nextmail templates get order-shipped --json | jq .latestVersion)
nextmail templates preview order-shipped --template-version "$draft" \
  --props '{"orderId":"AC-4192","customer":"Ada"}' --json | jq -e '.warnings == []'
nextmail templates publish order-shipped
```

## Send from a template

Pin the version, so a rewrite published tomorrow does not change what this code sends, and pass an idempotency key taken from what caused the send, so a retry after a lost answer replays the first message instead of sending a second. `--dry-run` prints the method, the URL, the headers with your credential redacted and the body, sends nothing and exits with code `0`. Run it again without `--dry-run` to send.

**Terminal**

```
nextmail templates send order-shipped \
  --from 'Acme <orders@acme.com>' \
  --to ada@example.com \
  --template-version 5 \
  --props '{"orderId":"AC-4192","customer":"Ada"}' \
  --idempotency-key order-shipped:AC-4192 \
  --dry-run
```

## Test a rule before it runs

Create the rule switched off, dry run it against recent mail, and switch it on once it catches what you meant. With a browser sign-in, `rules create` and `rules update` ask for a verification code, which a script cannot type, so run `nextmail verify` first. For the next 60 minutes that profile runs them without asking.

**conditions.json**

```
[
  { "field": "from_domain", "op": "equals", "value": "stripe.com" },
  { "field": "has_attachment", "op": "equals", "value": "true" }
]
```

**actions.json**

```
[
  { "type": "label", "value": "USER_RECEIPTS" },
  { "type": "archive" }
]
```

**Terminal**

```
nextmail verify
rule=$(nextmail rules create --name 'Stripe receipts' \
  --conditions @conditions.json --actions @actions.json --no-enabled --json | jq -r .id)
nextmail rules test "$rule" --days 30 --limit 100
nextmail rules update "$rule" --enabled
```

Read the warnings of `rules test` before its matches. `field_unevaluable` means a condition reads something stored mail no longer carries, so the test could not judge it, and `forward_unverified` means a forward target is not hosted here. `wouldApply` lists what the rule declares: a forward to an address that has not confirmed still fails when real mail arrives.

## Put a rule first, and see why a message moved

`rules reorder` takes every rule on the mailbox exactly once. A rule left out or named twice is refused and nothing moves. `rules list` returns the ids in the order they run, so put the one you want first in front of the rest.

**Terminal**

```
first=rul_4f1c9a2b7d3e8f6a0b5c1d2e
nextmail rules reorder "$first" $(nextmail rules list --all --ndjson \
  | jq -r --arg first "$first" 'select(.id != $first) | .id')
nextmail rules list-runs --thread-id CAHk7pQ2x9LmZ4 --json | jq '.items[] | {ruleName, actions, failures}'
```

`list-runs` is the record of what really happened. Each row is one rule matching one message, with the actions that took effect and, in `failures`, the ones the mailbox declined, such as a reply to a sender already answered that day. Each row keeps the name the rule had at the time, so `--rule-id` works for a rule you have since deleted.

## Register a webhook and prove it works

`webhooks create` shows the signing secret once, and no later command shows it again. With `--json` it is in the JSON on stdout, while the reminder to store it goes to stderr, so the output still parses. `webhooks test` sends a signed synthetic `email.sent` event whatever the endpoint subscribes to, and no mail is sent.

**Terminal**

```
nextmail verify
nextmail webhooks create --url https://hooks.acme.com/nextmail \
  --event-types email.received,email.bounced,email.complained \
  --description 'Support desk sync' --json > endpoint.json
jq -r .secret endpoint.json
nextmail webhooks test "$(jq -r .id endpoint.json)" --json | jq .delivery
rm endpoint.json
```

Put the secret in your secret store before you delete the file. `test` exits with code `0` even when your server fails, so read `delivery.status`: `delivered` for a 2xx answer and `failed` for anything else, a redirect included, since redirects are never followed. A `responseCode` of `null` means no answer arrived at all.

## Find failed deliveries and send one again

After an outage on your side, list what failed across every endpoint, check that a replay would be accepted, and send the event again. A replay carries the same event id, so a receiver that drops ids it has already handled treats it as the event it knows.

**Terminal**

```
nextmail webhooks list-workspace-deliveries --status failed --since 2026-09-26T00:00:00Z --all --ndjson \
  | jq -r '[.endpointId, .id, .eventType, (.responseCode // "no answer")] | @tsv'
nextmail webhooks get-delivery whe_3f9c2a7b1e4d8f60a5c7b92d whd_8c1e4a7f2b9d3e6a0c5f1b28 --json | jq .replayRefusal
nextmail webhooks replay-delivery whe_3f9c2a7b1e4d8f60a5c7b92d whd_8c1e4a7f2b9d3e6a0c5f1b28
```

- `--since` and `--until` take an ISO 8601 instant.
- A failed row whose `nextAttemptAt` holds a time still has an automatic retry coming.
- `replayRefusal` is `null` when a replay would go out, and otherwise names why it would be refused, such as `webhook_disabled` while the endpoint is switched off.
- Replays go one event at a time. No command sends every failed delivery again.

## Verification codes

With a browser sign-in, four of these commands ask for a verification code before they change anything, as the web app does: `rules create`, `rules update`, `webhooks create` and `webhooks update`. An API key is never asked. Every other command on this page runs without a code, deletions and `webhooks rotate-secret` included.

- In a terminal, the CLI emails you a six-digit code, or asks for one from your authenticator app when two-factor sign-in is on, then runs the command once.
- Unattended, with `--json` or `--no-input`, in CI or without a terminal, nobody can type the code, so the command stops with exit code `4` and changes nothing. Run `nextmail verify` first, and the profile needs no code for 60 minutes.
- `--yes` confirms a deletion, but never skips a code.

## Confirmations and dry runs

Seven commands here remove or overwrite something, so they ask you to confirm first: `templates delete`, `templates delete-version`, `templates replace-content`, `templates restore-version`, `rules delete`, `webhooks delete` and `webhooks rotate-secret`. Unattended, each one stops with exit code `2` unless you pass `--yes`.

**Terminal**

```
$ nextmail webhooks delete whe_3f9c2a7b1e4d8f60a5c7b92d --no-input
✗ Refusing to run unattended. Pass --yes to confirm.
$ nextmail webhooks delete whe_3f9c2a7b1e4d8f60a5c7b92d --yes
```

`--dry-run` prints the first request that would change something and exits with code `0`, without sending it or asking you to confirm. With `--json` it prints one `{ dryRun, request }` document. `rules test`, `templates render` and `templates preview` change nothing, but they are `POST` requests, so a dry run prints them instead of running them.

## Paging

- `templates list`, `templates list-versions`, `rules list`, `rules list-runs` and every `webhooks list…` command read one page at a time, 25 rows unless `--limit` asks for up to 100. A terminal shows the `--cursor` to pass for the next page.
- `--all` reads every page, `--max <n>` stops after that many rows, and `--ndjson` prints one JSON object per line. With `--json`, a list prints one `{ items, hasMore, nextCursor }` document, `--all` included.
- Pass a cursor back with the same filters and sort it came with. Anything else is refused as `invalid_cursor`, with exit code `7`.
- `templates list-sends` pages by number instead, with `--page` and `--page-size`, reports `total`, and has no `--all`. Page numbers shift while mail is going out, so narrow the window with `--days` or `--minutes` rather than paging deep.
- `templates list-starters` and `templates list-fonts` return the whole catalogue at once, and `rules reorder` returns every rule as a plain list in its new order.
- A mailbox holds at most 100 rules, so `rules list --limit 100` always returns every rule in one page.

## Flags worth a second look

- `--template-version` is the body field `version`, renamed because `--version` prints the CLI version. The `<version>` argument of `get-version`, `restore-version` and `delete-version` is a version number, not a `tplv_` id.
- `--conditions`, `--actions`, `--document`, `--slots`, `--props` and the other JSON flags take JSON inline, from a file with `@path`, or from stdin with `-`. `--data` takes the whole body the same way, and any flag you pass as well overrides its key.
- `--html` takes the markup itself, not a file, so `--html @page.html` sends the text `@page.html`. Pass `--html "$(cat page.html)"`, or put `html` in the file you give `--data`.
- `rules update --conditions` and `--actions` replace the whole list, and so do `webhooks update --event-types`, `--address-allowlist` and `--domain-allowlist`. Read the current value, change it, and send all of it.
- An empty `--event-types` is a usage error. To put an endpoint back on the default set, send `--data '{"eventTypes":[]}'`, and to stop its deliveries, pass `--no-enabled`.
- `--expected-version` on `templates update`, `replace-content` and `restore-version` takes the head version you read. When someone else has moved the head since, the command stops with exit code `6` and `version_conflict`, and writes nothing.
- `rules update <id> --no-enabled` switches a rule off and keeps its place in the order, which is the way to pause a rule without deleting it.

## Related pages

- [Commands](https://nextmail.sh/docs/cli/commands.md): The grammar, the global flags and every namespace.
- [Authentication](https://nextmail.sh/docs/cli/authentication.md): Browser sign-in, API keys and verification codes.
- [Scripting](https://nextmail.sh/docs/cli/scripting.md): JSON output, streams, exit codes and CI.
- [Verifying a delivery](https://nextmail.sh/docs/sdk/webhooks/verify.md): Check the signature on each request your server receives, with the SDK.
