Automations
An automation is a graph: one trigger event, then conditions and actions wired together by edges.
Shape
{
"name": "Welcome new signups",
"trigger_event": "contact.created",
"graph": {
"nodes": [
{ "id": "t1", "type": "trigger", "position": { "x": 0, "y": 0 } },
{ "id": "c1", "type": "condition", "position": { "x": 0, "y": 120 },
"conditions": [
{ "field": "data.contact.properties.plan", "operator": "equals", "value": "pro" }
] },
{ "id": "a1", "type": "action", "position": { "x": 0, "y": 240 },
"action": {
"type": "send_email",
"template_id": "…",
"subject": "Welcome",
"to": "{{ data.contact.email }}",
"from": "hello@example.com"
} }
],
"edges": [
{ "id": "e1", "source": "t1", "target": "c1" },
{ "id": "e2", "source": "c1", "target": "a1", "branch": "true" }
]
}
}Edges use source and target, and a node's settings sit at its top level under conditions, action or wait — there is no settings wrapper. The API reference has the full field tables.
Execution walks breadth-first from the single trigger node. Condition nodes route to their true or false edges; action nodes run and fan out over all outgoing edges. A diamond join runs once — revisits are suppressed — and a failing action does not stop the nodes downstream of it.
Nodes
| Type | What it does |
|---|---|
trigger | The entry point. Exactly one per graph. |
condition | 1–20 rows, ANDed, routing to the true or false branch. |
wait | Pauses the branch — { "amount": 2, "unit": "hours" }. |
action | Does something. See below. |
Actions
| Type | What it does |
|---|---|
send_email | Renders a template and sends it |
send_channel_message | Posts to a configured Discord, Telegram or ntfy channel |
call_url | Makes an HTTP request |
String settings support {{ dot.path }} placeholders resolved against the triggering event, so {{ data.values.email }} reaches a form submission's email field.
The loop guard
Events caused by an automation carry the originating automation's id, and the engine skips those. That is what makes it safe to trigger an automation on email.delivered without it feeding itself forever.
Draft and published
status is draft or published, and it decides two things at once:
- A draft does not run. The worker only ever loads published automations, so a draft is inert whatever
is_activesays — running takes both. - A draft is not held to the rules a runnable graph is (see Validation below), which is the point: a flow can be saved half-built and finished later.
POST /v1/automations defaults to published — unlike templates, whose create default is draft — so a client that posts a complete automation gets one that runs. Ask for "status": "draft" to put unfinished work down; the dashboard's editor does exactly that, and its Publish button is the transition.
An update that omits status keeps the stored one, and publishing is judged on the automation as it ends up: publishing without re-sending graph validates the stored graph, and an edit that breaks a published automation is refused with 422 (notifyzr.automation.error.incomplete_graph) rather than quietly unpublishing it.
Validation
A published graph is validated when saved — a single trigger, at least one action, correct branch edges, reachability, filled-in node settings, no cycles, and size caps — and validated again defensively when executed.
A draft is held to the graph's integrity only: unique ids, edges that land on nodes that exist, correct branch labels, no cycles, and the same size caps. So a draft is always a graph the editor can read back — it simply is not one the worker would run.
Seeing what happened
curl http://api.localhost/v1/automations/$ID/runs -H "X-API-Key: $KEY"A run is recorded whenever an action node was reached. Its detail carries one entry per visited node with the node's id, type, status and reason — so a run that did nothing tells you which condition sent it down the false branch.
Aggregate counts are at /v1/automations/metrics.