Skip to the documentation
CLI

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.

NamespaceAlsoReads needChanges need
templatestemplatetemplates:readtemplates:write, and emails:send as well for send
rulesrulerules:read, test includedrules:write
webhookswebhookwebhooks:readwebhooks: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 --helpopenemail rules create --helpopenemail 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.

CommandWhat it does
openemail templates listList 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-startersList 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-fontsList the web fonts a template may load
openemail templates renderRender 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_.

CommandWhat it does
openemail rules listList 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-runsWhat 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_.

CommandWhat it does
openemail webhooks listList 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-deliveriesDelivery 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-activityThe 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 <[email protected]>' \  --to [email protected] \  --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 verifyrule=$(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 100openemail 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_4f1c9a2b7d3e8f6a0b5c1d2eopenemail 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 verifyopenemail webhooks create --url https://hooks.acme.com/openemail \  --event-types email.received,email.bounced,email.complained \  --description 'Support desk sync' --json > endpoint.jsonjq -r .secret endpoint.jsonopenemail webhooks test "$(jq -r .id endpoint.json)" --json | jq .deliveryrm 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 .replayRefusalopenemail 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.

Your inbox,
on your own terms.

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

OpenEmail

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

© 2026 OpenEmail. All rights reserved.