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.
openemail templates --helpopenemail rules create --helpopenemail webhooks replay-delivery --help --jsonTemplates
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.publishmakes it active again. - A workspace holds at most 200 templates, archived ones included, so deleting is the only way to make room.
deleteis refused withtemplate_in_usewhile 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,hourandweekday. - Operators:
matches,contains,equals,starts_with,ends_with,gtandlt.gtandltwork only on the number fields, andhas_attachmentandspamtake onlyequalswithtrueorfalse. - Action types:
label,remove_label,archive,mark_read,star,spam,trash,forward,reply,block_senderandreject.labelandremove_labeltake a label id such asUSER_RECEIPTS,forwardtakes an address andreplytakes a template id or slug. from_domainalso matches subdomains, andhourandweekdayare read in UTC, with0for Sunday.- A rule with a
rejectaction must also testenvelope_from, or it is refused withreject_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> --enabledbrings it back. - With a browser sign-in, only the workspace owner can read a delivery with
get-delivery. Anyone else getsowner_onlyand exit code4.
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.
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-shippedSend 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.
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-runTest 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.
[ { "field": "from_domain", "op": "equals", "value": "stripe.com" }, { "field": "has_attachment", "op": "equals", "value": "true" }][ { "type": "label", "value": "USER_RECEIPTS" }, { "type": "archive" }]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" --enabledRead 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.
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.
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.jsonPut 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.
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--sinceand--untiltake an ISO 8601 instant.- A failed row whose
nextAttemptAtholds a time still has an automatic retry coming. replayRefusalisnullwhen a replay would go out, and otherwise names why it would be refused, such aswebhook_disabledwhile 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
--jsonor--no-input, in CI or without a terminal, nobody can type the code, so the command stops with exit code4and changes nothing. Runopenemail verifyfirst, and the profile needs no code for 60 minutes. --yesconfirms 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.
$ 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-runsand everywebhooks list…command read one page at a time, 25 rows unless--limitasks for up to 100. A terminal shows the--cursorto pass for the next page.--allreads every page,--max <n>stops after that many rows, and--ndjsonprints one JSON object per line. With--json, a list prints one{ items, hasMore, nextCursor }document,--allincluded.- Pass a cursor back with the same filters and sort it came with. Anything else is refused as
invalid_cursor, with exit code7. templates list-sendspages by number instead, with--pageand--page-size, reportstotal, and has no--all. Page numbers shift while mail is going out, so narrow the window with--daysor--minutesrather than paging deep.templates list-startersandtemplates list-fontsreturn the whole catalogue at once, andrules reorderreturns every rule as a plain list in its new order.- A mailbox holds at most 100 rules, so
rules list --limit 100always returns every rule in one page.
Flags worth a second look
--template-versionis the body fieldversion, renamed because--versionprints the CLI version. The<version>argument ofget-version,restore-versionanddelete-versionis a version number, not atplv_id.--conditions,--actions,--document,--slots,--propsand the other JSON flags take JSON inline, from a file with@path, or from stdin with-.--datatakes the whole body the same way, and any flag you pass as well overrides its key.--htmltakes the markup itself, not a file, so--html @page.htmlsends the text@page.html. Pass--html "$(cat page.html)", or puthtmlin the file you give--data.rules update --conditionsand--actionsreplace the whole list, and so dowebhooks update --event-types,--address-allowlistand--domain-allowlist. Read the current value, change it, and send all of it.- An empty
--event-typesis a usage error. To put an endpoint back on the default set, send--data '{"eventTypes":[]}', and to stop its deliveries, pass--no-enabled. --expected-versionontemplates update,replace-contentandrestore-versiontakes the head version you read. When someone else has moved the head since, the command stops with exit code6andversion_conflict, and writes nothing.rules update <id> --no-enabledswitches a rule off and keeps its place in the order, which is the way to pause a rule without deleting it.