Automations
Sequences as YAML: a trigger, steps, waits, and conditions, pulled and pushed as a file your assistant can write.
An automation is a sequence of steps that runs for one contact: send this, wait three days, if they have not opened it send the other one, tag them either way. It is described by a YAML document, and that document is the source of truth. The canvas in the dashboard and the file are two views of the same graph, and either can be the one you edit.
The file is the point. A sequence written as a document is one an assistant can write, a reviewer can read in a diff, and a deploy pipeline can push, which is not true of anything you drag around a canvas.
A complete automation
Steps fall through to the next one in file order unless a step says otherwise, so a linear sequence needs no wiring at all. This one is the whole language for most people.
name: Trial nudges
description: Three messages over the first week of a trial.
trigger: contact.subscribed
when:
audience: Trials
reentry: once
enabled: true
steps:
- id: welcome
do: send_template
with:
template: trial-welcome
topic: product-updates
- id: wait_3d
wait: 3 days
- id: opened_it
if:
any:
- steps.welcome.happened = true
yes: tag_engaged
no: nudge
- id: nudge
do: send_template
with:
template: trial-day-3
topic: product-updates
next: []
- id: tag_engaged
do: add_tag
with:
tag: engaged
next: []The document
| Key | What it is |
|---|---|
name | Required. What the automation is called. |
description | Optional prose. |
trigger | Required. One of the triggers below. |
when | A mapping filtering the trigger. What it accepts depends on the trigger family. |
reentry | once, re_enter, or always. Defaults to once, which is one live run per contact. |
enabled | Defaults to true. |
layout | auto or manual. auto means the document carries no coordinates and the canvas computes them. |
at | Only under layout: manual. [x, y] on every step. |
entry | Which step the trigger flows into. Defaults to the first one. |
steps | Required, at least one. |
Triggers
Seventeen of them, in four families. Everything except the last three starts a run because something happened to a contact.
| Trigger | When it fires |
|---|---|
contact.subscribed | Somebody became subscribed on an audience. |
contact.added | Somebody was added to an audience, whatever status they landed in. |
contact.removed | A membership was removed. |
contact.unsubscribed | Somebody unsubscribed. |
tag.added | A tag was added to a contact. Swept every five minutes rather than instantly. |
tag.removed | A tag was removed. |
attribute.changed | A contact attribute changed value. |
email.delivered | One of your messages was accepted by the receiving server. |
email.opened | A tracking pixel loaded. A floor, not a count. |
email.clicked | A tracked link was followed. |
email.bounced | A message was refused. |
email.complained | Somebody pressed the spam button. |
email.received | Inbound mail arrived on a domain of yours. |
broadcast.sent | A broadcast finished sending. |
date.attribute | A date attribute on a contact came due. This is how renewals and birthdays work. |
schedule.recurring | A clock, not an event. Runs on a schedule with no contact attached. |
api.call | POST /v1/automations/:id/trigger started it. |
The when: filter
For every event trigger, when takes any of audience, tag, attribute, topic, and template. Each is an exact match ignoring case, and a key you leave out matches everything. So a trigger with no when at all fires on every occurrence.
# Only when the tag "vip" is added, and only on the Customers audience.
name: VIP welcome
trigger: tag.added
when:
tag: vip
audience: Customers
steps:
- id: greet
do: send_template
with:
template: vip-welcome
next: []schedule.recurring and date.attribute take a different set, because neither is filtering an event.
| Trigger | when: keys |
|---|---|
schedule.recurring | hourUtc (0 to 23, defaults to 9), weekday (0 to 6 with Sunday as 0), dayOfMonth. Give weekday for weekly, dayOfMonth for monthly, and neither for daily. |
date.attribute | attribute (required, and it holds a YYYY-MM-DD date), offsetDays (defaults to 0, and a negative number fires before the date), hourUtc (defaults to 9), recurring (true for an anniversary that fires every year). |
# Seven days before renewal_date, at 09:00 UTC, every year.
name: Renewal reminder
description: Fires a week before the date on the contact.
trigger: date.attribute
when:
attribute: renewal_date
offsetDays: -7
hourUtc: 9
recurring: true
reentry: always
steps:
- id: warn
do: send_template
with:
template: renewal-reminder
variables:
renews_on: "{{ attributes.renewal_date }}"
next: []Steps
Every step has an id, unique within the document and never the word trigger, and exactly one of if, do, or wait. Non-branching steps wire with next, which takes an id, a list of ids, or an empty list to end the run. An if step wires with yes and no instead. Omit next and the step falls through to the next one in the file.
The eleven actions
Arguments go under with. An argument name that is not on this list is refused when the document is saved rather than ignored at run time. Notice what is not here: no arbitrary code, no database access, no way to delete anything. The blast radius of a document somebody pasted in is this table.
| do: | Arguments, required ones first |
|---|---|
send_template | template (required, a slug). topic, from, replyTo, variables. |
send_audience | audience (required), template (required). topic, from, replyTo. Sends to every mailable member. |
add_tag | tag (required). |
remove_tag | tag (required). |
add_to_audience | audience (required). status. |
remove_from_audience | audience (required). |
set_attribute | name (required). value. |
unsubscribe | audience. With none, the audience that enrolled the run. |
suppress | email, reason. With no email, the contact this run is about. |
call_webhook | url (required). event, data. POSTs a signed automation.step event. |
notify_team | to (required), body (required). subject. The address must belong to this workspace, and anything else is refused. |
Three of them put mail in front of a person: send_template, send_audience, and notify_team. Those are the ones counted against the per-run send cap of 10.
The three waits
| Form | What it does |
|---|---|
wait: 3 days | A duration. minutes, mins, hours, hrs, or days, clamped between one minute and 365 days. |
wait: { until: "09:00" } | The next occurrence of a time of day, in UTC. { until: monday 09:00 } names a weekday too. |
wait: { for: email.opened, timeout: 3 days } | Wait for an event about this contact, or give up after the timeout. The timeout defaults to 7 days. |
A wait for an event resumes early the moment it happens. Whether it happened is readable afterwards as steps.<id>.happened, which is how a branch tells "opened it" apart from "we gave up waiting".
Conditions
An if step takes all or any, with between 1 and 10 rules, and each rule is one line reading path op value. One line rather than a three-key mapping because ten mappings in a diff are unreadable and ten lines are not.
| Operator | What it does |
|---|---|
= | Equal. Strings compare without case. |
!= | Not equal. |
> | Greater than, numerically. |
< | Less than, numerically. |
>= | Greater than or equal. |
<= | Less than or equal. |
contains | Substring on a string, membership on a list. tags contains vip is the common one. |
not_contains | The negation of contains. |
is_set | The path resolves to something. Takes no value. |
is_not_set | It does not. Takes no value. |
Paths read the run context: attributes.plan, tags, contact.email, trigger.anything you passed in, and steps.<id>.happened. A quoted value stays a string, so count = "3" is the string and count = 3 is the number.
A branching example
This one waits for an open, branches on whether it arrived, and reads a value the API trigger passed in. It is a complete document and it will save as it stands.
name: Onboarding checkpoint
description: Fired by our backend when a workspace finishes setup.
trigger: api.call
reentry: re_enter
enabled: true
steps:
# trigger.plan comes from the "data" object on the trigger call.
- id: is_paid
if:
any:
- trigger.plan = pro
- trigger.plan = enterprise
yes: send_paid
no: send_free
- id: send_paid
do: send_template
with:
template: onboarding-paid
topic: product-updates
variables:
workspace: "{{ trigger.workspace_name }}"
next: await_open
- id: send_free
do: send_template
with:
template: onboarding-free
topic: product-updates
next: await_open
- id: await_open
wait:
for: email.opened
timeout: 3 days
next: check_open
- id: check_open
if:
all:
- steps.await_open.happened = true
yes: tag_engaged
no: tell_us
- id: tag_engaged
do: add_tag
with:
tag: onboarding-engaged
next: []
- id: tell_us
do: notify_team
with:
to: growth@acme.com
subject: Onboarding stalled
body: "{{ contact.email }} did not open onboarding in three days."
next: []Interpolation
Any argument value takes {{ dot.path }} against the run context. A string that is exactly one variable and nothing else keeps the value's type rather than becoming text, so a number stays a number when it is passed straight through.
Limits
| Limit | Value |
|---|---|
Nodes in one document | 80. Past that it is a program rather than a sequence. |
Nodes executed in one run | 200, counting every resume. This is what bounds a fan-out. |
Emails one run may send | 10. A run that would exceed it stops and reports rather than sending the eleventh. |
Runs started per hour | 500 per automation, across every trigger path. This is the loop guard. |
Live runs per contact | 1 under reentry once, which is the default. |
Argument size | 4000 characters of arguments and 16000 characters of payload. |
Wait length | One minute to 365 days. |
Pull and push
Two routes make the document a file you can keep in your repository. GET the .yaml form and you get the raw bytes with the version in a response header; PUT it back and you have written a new version. A round trip through those two preserves your comments and your key order exactly, so the file in git and the file on the server stay diffable.
ID=91c4e0a7-6f28-4b53-8d10-2a7e5cb93f61
# Pull, keeping the version header so you can tell whether it moved
curl -sS -D headers.txt \
"https://emails.sh/v1/automations/$ID.yaml" \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-o automations/trial-nudges.yaml
grep -i '^x-emailssh-automation-version' headers.txt
# Push it back after editing
curl -sS -X PUT "https://emails.sh/v1/automations/$ID.yaml" \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/yaml" \
--data-binary @automations/trial-nudges.yamlx-emailssh-automation-version carries the version number the document is at. Record it when you pull, and compare it before you push, and a CI job knows whether somebody edited the automation in the dashboard while the branch was open.
Every save writes a version. GET /v1/automations/:id/versions lists the last 50 with their YAML, and POSTing { version_id } to that route restores one as a new version rather than rewinding history.
Triggering one from your code
An automation whose trigger is api.call has a trigger_url on its GET response and starts a run when you post to it. idempotency_key is required rather than optional, because the caller is usually a webhook handler and a redelivered webhook must not enrol somebody twice.
curl -X POST https://emails.sh/v1/automations/91c4e0a7-6f28-4b53-8d10-2a7e5cb93f61/trigger \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"idempotency_key": "workspace-8812-setup-complete",
"data": { "plan": "pro", "workspace_name": "Acme" }
}'| Refusal | What it means |
|---|---|
409 duplicate | That idempotency_key already started a run. Nothing happened, which is the point. |
422 automation_disabled | enabled is false. |
422 already_enrolled | Under reentry once, this contact already has a run. |
422 run_in_flight | A run for this contact is executing right now. |
422 concurrency_cap | Too many runs at once. |
422 hourly_cap | Past 500 runs in the last hour. |
Reading a run
GET /v1/automations/:id/runs lists them, and GET on one run returns every step it executed, in order, with what each one did. That list is the debugger: a sequence that "did not send" has a step in it whose status says why.
{
"id": "3b8f7d21-5c04-4e96-a71d-8f26c0b4e953",
"email": "ada@example.com",
"contact_id": "b4c0e21f-7d69-4a55-8e13-2f9a6c081d77",
"status": "waiting",
"version_id": "c05a9e13-4b72-4f80-9d36-1a7c8e5b204f",
"started_at": "2026-07-28T09:14:01.882Z",
"finished_at": null,
"resume_at": "2026-07-31T09:14:01.882Z",
"steps_executed": 3,
"emails_sent": 1,
"error": null,
"steps": [
{ "nodeId": "trigger", "kind": "trigger", "status": "ok" },
{ "nodeId": "is_paid", "kind": "condition", "status": "branch_true" },
{ "nodeId": "send_paid", "kind": "action", "tool": "send_template", "status": "ok" },
{ "nodeId": "await_open", "kind": "wait", "status": "waiting" }
]
}- ok
- The step did what it said.
- skipped
- It was reached and had nothing to do.
- error
- It failed. The error is on the step.
- branch_true and branch_false
- Which way an if step went.
- waiting
- A wait that has not resumed.
resume_aton the run says when it will. - timed_out
- A wait for an event that never arrived. happened is false on it.
A run that is waiting can be cancelled with DELETE on it. A run in any other state cannot, because there is nothing left to stop.
/v1/automationsAutomations, with their trigger, version, and last error.
/v1/automationsRaw YAML, or { yaml } as JSON.
/v1/automations/:idOne automation, with its graph, its YAML, and its trigger URL if it has one.
/v1/automations/:id{ enabled?, yaml? }
/v1/automations/:idRemove it, and cancel every waiting run.
/v1/automations/:id.yamlThe document, with the version in x-emailssh-automation-version.
/v1/automations/:id.yamlReplace the document with the raw YAML body.
/v1/automations/:id/versionsThe last 50 versions, each with its YAML.
/v1/automations/:id/versions{ version_id } restores one as a new version.
/v1/automations/:id/trigger{ email | contact_id, idempotency_key, data? } starts a run.
/v1/automations/:id/runs?status=&limit= over runs.
/v1/automations/:id/runs/:runIdOne run and every step it executed.
/v1/automations/:id/runs/:runIdCancel a run that is waiting.
automations.create
Create an automation from a YAML document. It is validated whole: a refusal names the field, says what to write instead, and carries the line number.
Arguments
yamlstringrequired- The document. Post it raw with a YAML or text/plain Content-Type, or wrapped as { yaml }.
Returns{ id, name, slug, trigger, enabled, version, yaml }
automations.pull
GET /v1/automations/:id.yaml. The raw document, with the current version in the x-emailssh-automation-version response header. Comments and key order survive the round trip.
Arguments
idstringrequired
Returnstext/yaml
automations.push
PUT /v1/automations/:id.yaml. Replaces the document and writes a new version.
Arguments
idstringrequiredyamlstringrequired- The raw body. This is the write half of the pull and push pair a CI job uses.
Returnstext/yaml
automations.trigger
Start a run of an automation whose trigger is api.call.
Arguments
idstringrequiredemailstring- One of email or contact_id is required.
contact_idstringidempotency_keystringrequired- Required, not optional. A repeat answers 409 duplicate.
dataobject- Anything here is readable in the flow as {{ trigger.<name> }}.
Returns{ run_id, status }
automations.runs
Runs of one automation, with how many steps executed, how many emails went, and the error if it stopped.
Arguments
idstringrequiredstatusstring- running, waiting, completed, stopped, failed, or cancelled.
limitnumber- Defaults to 50, maximum 200.
Returns{ runs: Run[] }