Automations
An automation is a graph: one trigger event, then conditions, waits and actions wired together by edges.
The automation object
{
"id": "…",
"name": "Welcome new signups",
"description": "",
"trigger_event": "form.submitted",
"graph": { "nodes": [ ], "edges": [ ] },
"is_active": true,
"status": "published",
"created_at": "…",
"updated_at": "…"
}Two flags, and both are required to run
| Flag | Question |
|---|---|
status — draft / published | Is it finished? |
is_active — true / false | Is it switched on? |
The worker requires both. They are independent: publishing does not switch an automation on, and pausing does not unpublish it.
The graph
{
"nodes": [
{ "id": "t1", "type": "trigger", "position": { "x": 0, "y": 0 } },
{ "id": "c1", "type": "condition", "position": { "x": 0, "y": 120 },
"conditions": [
{ "field": "data.values.plan", "operator": "equals", "value": "pro" }
] },
{ "id": "w1", "type": "wait", "position": { "x": 0, "y": 240 },
"wait": { "amount": 2, "unit": "hours" } },
{ "id": "a1", "type": "action", "position": { "x": 0, "y": 360 },
"action": {
"type": "send_email",
"template_id": "…",
"subject": "Welcome",
"to": "{{ data.values.email }}",
"from": "hello@example.com"
} }
],
"edges": [
{ "id": "e1", "source": "t1", "target": "c1" },
{ "id": "e2", "source": "c1", "target": "w1", "branch": "true" },
{ "id": "e3", "source": "w1", "target": "a1" }
]
}Field names to get right
Edges use source and target, not from/to. A node's settings sit at the node's top level under conditions, action or wait — there is no settings wrapper.
position persists the dashboard canvas layout. It is required structurally, so send something even if you never open the editor.
Node types
| Type | Settings key | Contents |
|---|---|---|
trigger | — | Exactly one per graph. |
condition | conditions | 1–20 rows, ANDed. |
wait | wait | { "amount": <positive number>, "unit": "seconds" | "minutes" | "hours" | "days" } |
action | action | See below. |
Conditions
{ "field": "data.values.plan", "operator": "equals", "value": "pro" }field is a dot-path into the event envelope. operator is equals (the default if omitted), not_equals, contains or exists. exists takes no value; the others need a scalar one.
Finding the right dot-path
Paths start at data, which is the event's own payload. A form.submitted event carries data.form, data.submission_id, data.fields (the ordered list) and data.values — a convenience map so you can write data.values.Email without scanning the list. values is keyed by the field's label, which defaults to its key. A contact.* event carries data.contact, so a custom property is data.contact.properties.plan.
The quickest way to see the real shape is to point a webhook at a request-inspection endpoint and fire the trigger once.
All rows on a node must match to take its branch: "true" edge; otherwise the branch: "false" edge runs.
Actions
type | Required settings |
|---|---|
send_email | template_id, subject, to, from |
send_channel_message | channel_id, content |
call_url | url (absolute http(s)) |
to and every other string setting accept {{ dot.path }} placeholders resolved against the triggering event.
from must be an address on a verified domain. Only the shape is checked when you save — whether the domain is verified is settled at execution time, where the action is recorded as skipped if it is not. The same is true of a template_id that names a draft.
Traversal
Execution walks breadth-first from the trigger, children in edge order. Condition nodes route to their branch; action nodes execute and fan out over all outgoing edges; wait nodes pause the branch and resume later. Revisits are suppressed, so a diamond join runs once, and a failing action does not stop the nodes downstream of it.
Caps
40 nodes, 80 edges, 20 conditions per condition node, and 100 traversal steps per run.
Create
POST /v1/automations| Field | Required | Notes |
|---|---|---|
name | yes | Up to 255 characters. |
trigger_event | yes | An event from the catalog. |
graph | no | Defaults to empty. |
description | no | |
status | no | Defaults to published. |
is_active | no | Defaults to true. |
The create default is the opposite of a template's
A client that POSTs a complete automation expects it to run, so create defaults to published. It is the dashboard editor that asks for a draft. Templates default to draft for the opposite reason.
Trigger events
form.submitted · contact.created · contact.updated · contact.deleted · contact.unsubscribed · contact.resubscribed · contact_import.completed · email.queued · email.scheduled · email.delivered · email.failed · broadcast.sent · domain.created · domain.verified · domain.verification_lost · domain.deleted · channel_message.sent · channel_message.failed
An unrecognised value is refused with the full list in the message.
Update
PATCH /v1/automations/{id}Every field is optional, and omission means "keep the stored value" — which matters more here than elsewhere:
- Omitting
statuslands on the stored one. - Omitting
graphpublishes the stored graph.
So an update that publishes an incomplete automation is refused with 422 notifyzr.automation.error.incomplete_graph rather than quietly unpublishing it.
Validation: draft versus published
AutomationGraph is validated at two levels.
| Level | Applies to | Checks |
|---|---|---|
| Integrity | Always, drafts included | Unique ids, edges landing on nodes that exist, valid branch labels, no cycles, the size caps |
| Completeness | Published only | Exactly one trigger, at least one action, every node reachable, and every node's settings filled in |
That is the point of a draft: half-built work has somewhere to live, while still being a graph the editor can read back and the worker could not choke on.
The engine re-validates defensively when it runs, so a row edited outside the API cannot reach the executor.
Runs
GET /v1/automations/{id}/runsPaginated. A run is recorded whenever an action node was reached — a trigger that fell down a false branch to nothing leaves no run.
{
"runs": [{
"id": "…",
"trigger_event": "form.submitted",
"event_id": "…",
"status": "success",
"actions_total": 2,
"actions_failed": 0,
"error": null,
"actions": [
{ "node_id": "c1", "node_type": "condition", "type": null, "status": "matched", "reason": null },
{ "node_id": "a1", "node_type": "action", "type": "send_email", "status": "sent", "reason": null }
],
"created_at": "…"
}],
"pagination": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 }
}actions carries one entry per visited node, which is what makes a run diagnosable: a run that did nothing tells you which condition sent it down the false branch, and a skipped action tells you why — a draft template, an unverified sender, a suppressed recipient.
Metrics
GET /v1/automations/{id}/metrics?range=7d
GET /v1/automations/metrics?range=7drange is 24h, 7d or 30d. The first is one automation; the second is across all of them.
{
"range": "7d",
"granularity": "day",
"series": [ ],
"totals": { "runs": 340, "runs_success": 331, "messages": 340, "messages_delivered": 336 },
"actions": [ ],
"failures": [ ]
}The loop guard
Events caused by an automation action carry the originating automation's id, and the engine skips them. That is what makes it safe to trigger on email.* without an automation feeding itself forever.
It is a blanket rule: no automation triggers on any automation's output, so you cannot chain two automations by having one react to what the other sent.
Quota
Automation runs count against a monthly quota, checked per automation rather than once per event — so one event fanning out to several automations stops at the ceiling rather than overshooting it. Once over, matched automations are skipped with no actions and no run recorded until the count resets.