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.

automations/trial-nudges.yaml
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

KeyWhat it is
nameRequired. What the automation is called.
descriptionOptional prose.
triggerRequired. One of the triggers below.
whenA mapping filtering the trigger. What it accepts depends on the trigger family.
reentryonce, re_enter, or always. Defaults to once, which is one live run per contact.
enabledDefaults to true.
layoutauto or manual. auto means the document carries no coordinates and the canvas computes them.
atOnly under layout: manual. [x, y] on every step.
entryWhich step the trigger flows into. Defaults to the first one.
stepsRequired, at least one.

Triggers

Seventeen of them, in four families. Everything except the last three starts a run because something happened to a contact.

TriggerWhen it fires
contact.subscribedSomebody became subscribed on an audience.
contact.addedSomebody was added to an audience, whatever status they landed in.
contact.removedA membership was removed.
contact.unsubscribedSomebody unsubscribed.
tag.addedA tag was added to a contact. Swept every five minutes rather than instantly.
tag.removedA tag was removed.
attribute.changedA contact attribute changed value.
email.deliveredOne of your messages was accepted by the receiving server.
email.openedA tracking pixel loaded. A floor, not a count.
email.clickedA tracked link was followed.
email.bouncedA message was refused.
email.complainedSomebody pressed the spam button.
email.receivedInbound mail arrived on a domain of yours.
broadcast.sentA broadcast finished sending.
date.attributeA date attribute on a contact came due. This is how renewals and birthdays work.
schedule.recurringA clock, not an event. Runs on a schedule with no contact attached.
api.callPOST /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.

Filtering an event trigger
# 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.

Triggerwhen: keys
schedule.recurringhourUtc (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.attributeattribute (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).
A date-driven sequence with no cron of your own
# 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_templatetemplate (required, a slug). topic, from, replyTo, variables.
send_audienceaudience (required), template (required). topic, from, replyTo. Sends to every mailable member.
add_tagtag (required).
remove_tagtag (required).
add_to_audienceaudience (required). status.
remove_from_audienceaudience (required).
set_attributename (required). value.
unsubscribeaudience. With none, the audience that enrolled the run.
suppressemail, reason. With no email, the contact this run is about.
call_webhookurl (required). event, data. POSTs a signed automation.step event.
notify_teamto (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

FormWhat it does
wait: 3 daysA 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.

OperatorWhat it does
=Equal. Strings compare without case.
!=Not equal.
>Greater than, numerically.
<Less than, numerically.
>=Greater than or equal.
<=Less than or equal.
containsSubstring on a string, membership on a list. tags contains vip is the common one.
not_containsThe negation of contains.
is_setThe path resolves to something. Takes no value.
is_not_setIt 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.

A document with a branch, a wait, and trigger data
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

LimitValue
Nodes in one document80. Past that it is a program rather than a sequence.
Nodes executed in one run200, counting every resume. This is what bounds a fan-out.
Emails one run may send10. A run that would exceed it stops and reports rather than sending the eleventh.
Runs started per hour500 per automation, across every trigger path. This is the loop guard.
Live runs per contact1 under reentry once, which is the default.
Argument size4000 characters of arguments and 16000 characters of payload.
Wait lengthOne 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.

The pull and push pair
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.yaml

x-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.

POST /v1/automations/:id/trigger
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" }
  }'
RefusalWhat it means
409 duplicateThat idempotency_key already started a run. Nothing happened, which is the point.
422 automation_disabledenabled is false.
422 already_enrolledUnder reentry once, this contact already has a run.
422 run_in_flightA run for this contact is executing right now.
422 concurrency_capToo many runs at once.
422 hourly_capPast 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.

GET /v1/automations/:id/runs/:runId
{
  "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_at on 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.

GET /v1/automations

Automations, with their trigger, version, and last error.

POST /v1/automations

Raw YAML, or { yaml } as JSON.

GET /v1/automations/:id

One automation, with its graph, its YAML, and its trigger URL if it has one.

PATCH /v1/automations/:id

{ enabled?, yaml? }

DELETE /v1/automations/:id

Remove it, and cancel every waiting run.

GET /v1/automations/:id.yaml

The document, with the version in x-emailssh-automation-version.

PUT /v1/automations/:id.yaml

Replace the document with the raw YAML body.

GET /v1/automations/:id/versions

The last 50 versions, each with its YAML.

POST /v1/automations/:id/versions

{ version_id } restores one as a new version.

POST /v1/automations/:id/trigger

{ email | contact_id, idempotency_key, data? } starts a run.

GET /v1/automations/:id/runs

?status=&limit= over runs.

GET /v1/automations/:id/runs/:runId

One run and every step it executed.

DELETE /v1/automations/:id/runs/:runId

Cancel 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

yaml string required
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

id string required

Returnstext/yaml

automations.push

PUT /v1/automations/:id.yaml. Replaces the document and writes a new version.

Arguments

id string required
yaml string required
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

id string required
email string
One of email or contact_id is required.
contact_id string
idempotency_key string required
Required, not optional. A repeat answers 409 duplicate.
data object
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

id string required
status string
running, waiting, completed, stopped, failed, or cancelled.
limit number
Defaults to 50, maximum 200.

Returns{ runs: Run[] }