---
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://openemail.uk/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 `openemail 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 `openemail <namespace> <verb> --help`. Add `--json` to get the same page as JSON.

**Help**

```
openemail templates --help
openemail rules create --help
openemail 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 |
| --- | --- |
| openemail 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 |
| openemail templates get <id-or-slug> | Read a template with its head version in full, body included |
| openemail 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 |
| openemail templates update <id-or-slug> | Edit the name, slug, description or status, or the draft body. Sends keep the published version until you publish |
| openemail templates duplicate <id-or-slug> | Copy the head version into a new template, which starts as a draft |
| openemail 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 |
| openemail templates delete <id-or-slug> | Delete a template and every version. Asks you to confirm |
| openemail templates list-versions <id-or-slug> | List the versions, newest first, without their bodies |
| openemail templates get-version <id-or-slug> <version> | Read one version with its body, without touching the draft |
| openemail templates publish <id-or-slug> | Publish the draft so sends resolve to it. Publishing a head that is already live changes nothing |
| openemail templates restore-version <id-or-slug> <version> | Bring an older version's body back as the draft. Asks you to confirm |
| openemail 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 |
| openemail templates list-starters | List the built-in starter designs |
| openemail templates get-starter <slug> | Read one starter in full, with its block tree and a rendered preview |
| openemail templates list-fonts | List the web fonts a template may load |
| openemail templates render | Render a body that is not stored anywhere, from `--html` or `--document` |
| openemail templates preview <id-or-slug> | Render a stored template with `--props` and `--slots`, drafts included, without sending it |
| openemail templates get-analytics <id-or-slug> | Sends, opens and clicks in a window, by day, by source and by version |
| openemail templates list-sends <id-or-slug> | The individual messages the template sent, newest first, a page at a time |
| openemail 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 |

> 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 |
| --- | --- |
| openemail rules list | List rules in the order they run. `--enabled` or `--no-enabled` keeps one kind |
| openemail rules get <id> | Read one rule, with `matchCount` and `lastMatchedAt` |
| openemail 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` |
| openemail rules update <id> | Change a rule. `--conditions` and `--actions` replace the whole list, and `--position` moves only this rule |
| openemail rules delete <id> | Delete a rule. What it already did stays in `list-runs`. Asks you to confirm |
| openemail rules reorder <rule-ids...> | Set the order of every rule at once, naming each rule exactly once |
| openemail rules test <id> | Dry run a rule against mail already in the mailbox. It changes nothing, and works on a disabled rule |
| openemail 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`, `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` and `spam` 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 |
| --- | --- |
| openemail webhooks list | List the endpoints in the workspace, newest first, with their health |
| openemail webhooks get <id> | Read one endpoint. The signing secret is never part of a read |
| openemail webhooks create --url <value> | Register an HTTPS endpoint. It prints the signing secret, the only time you see that secret |
| openemail webhooks update <id> | Change the URL, the events, the allowlists or whether it is enabled. Each list replaces the stored one |
| openemail webhooks delete <id> | Delete an endpoint and its delivery log. Asks you to confirm |
| openemail webhooks rotate-secret <id> | Issue a new signing secret. The old one stops working at once. Asks you to confirm |
| openemail webhooks test <id> | Send a signed synthetic `email.sent` event and report how delivery went |
| openemail webhooks list-deliveries <id> | One endpoint's delivery attempts, newest first. `--status`, `--since` and `--until` narrow it |
| openemail 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 |
| openemail webhooks replay-delivery <id> <delivery-id> | Send one stored event to the endpoint again, now |
| openemail webhooks list-workspace-deliveries | Delivery attempts across every endpoint, or the ones `--endpoint-ids` names |
| openemail webhooks list-activity <id> | The audit log of one endpoint: who created, changed, tested, replayed or removed it |
| openemail webhooks list-workspace-activity | The audit log of every endpoint, removed ones included |

Leave `--event-types` out and an endpoint receives the default set, the `email.*` events other than `email.replied`. `email.replied`, the `domain.*` events and the `suppression.*` events 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=$(openemail templates get order-shipped --json | jq .latestVersion)
openemail templates preview order-shipped --template-version "$draft" \
  --props '{"orderId":"AC-4192","customer":"Ada"}' --json | jq -e '.warnings == []'
openemail 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**

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

```
openemail verify
rule=$(openemail rules create --name 'Stripe receipts' \
  --conditions @conditions.json --actions @actions.json --no-enabled --json | jq -r .id)
openemail rules test "$rule" --days 30 --limit 100
openemail 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
openemail rules reorder "$first" $(openemail rules list --all --ndjson \
  | jq -r --arg first "$first" 'select(.id != $first) | .id')
openemail 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**

```
openemail verify
openemail webhooks create --url https://hooks.acme.com/openemail \
  --event-types email.received,email.bounced,email.complained \
  --description 'Support desk sync' --json > endpoint.json
jq -r .secret endpoint.json
openemail 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**

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

```
$ openemail webhooks delete whe_3f9c2a7b1e4d8f60a5c7b92d --no-input
✗ Refusing to run unattended. Pass --yes to confirm.
$ openemail 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://openemail.uk/docs/cli/commands.md): The grammar, the global flags and every namespace.
- [Authentication](https://openemail.uk/docs/cli/authentication.md): Browser sign-in, API keys and verification codes.
- [Scripting](https://openemail.uk/docs/cli/scripting.md): JSON output, streams, exit codes and CI.
- [Verifying a delivery](https://openemail.uk/docs/sdk/webhooks/verify.md): Check the signature on each request your server receives, with the SDK.
