Automations
An automation is a rule the platform runs for you: when this happens, and these conditions hold, do that. The definition is DATA — a closed vocabulary of triggers, conditions and actions, validated when you save it — so there is nothing to deploy and nothing arbitrary to execute.
The automation
One trigger, an optional condition tree, and an ordered list of actions. priority decides which rule runs first when several match the same event.
| Field | Description |
|---|---|
| id | The automation's public id. |
| trigger_type, trigger | What starts it, and the trigger's own options. |
| conditions | A tree that must hold before the actions run. |
| actions | What to do, in order. |
| priority | Lower runs first among rules matching the same event. |
| enabled | Disabled rules match nothing, immediately. |
| version | Bumped on every definition change. A run records the version it ran. |
| stats | run_count, success_count, failure_count, last_run_at, last_error_at, last_error. |
| next_run_at | For a schedule trigger only; null otherwise. |
Authentication and scopes
There is no read scope
Every route in this section — including the reads — requires automations.manage. A role that can only look at automations does not exist, so do not expect one to work.
Endpoints
The list accepts enabled and trigger_type filters and is cursor paginated; the run log is paginated too and accepts status.
The definition vocabulary
Everything below is a closed list. Anything outside it is refused on save with AUTOMATION_INVALID_DEFINITION, and a PATCH is validated against the MERGED definition rather than against the patch alone.
Triggers
| Value | Description |
|---|---|
| message_received | An inbound message. Takes match (any, equals, contains, starts_with, regex), value and case_sensitive. |
| conversation_opened | A thread opened. Takes reason (created, reopened) and opened_by (inbound, outbound). |
| conversation_closed | A thread closed. |
| conversation_assigned | A thread assigned to somebody. |
| contact_created | A contact created, from any source. |
| contact_tagged | A tag added. Takes an optional tag to narrow it. |
| contact_untagged | A tag removed. Takes an optional tag. |
| schedule | A clock, rather than an event. next_run_at reports the next one. |
Conditions
| Type | Shape | Description |
|---|---|---|
| field | field, operator, value?, key? | Compares one of the published fields — contact.*, conversation.*, message.*, channel.* — against a value. contact.attribute takes the attribute name in key. |
| tag | operator, value | Whether the contact has a tag: has or not_has. |
| time_window | operator, timezone, from, to, days? | A time of day, on named days, in a named timezone: within or outside. |
| business_hours | operator | Within or outside the workspace's business hours, evaluated server-side. |
| group | operator, conditions[] | Nests conditions under and or or. Groups may contain groups. |
A field condition takes one of equals, not_equals, contains, not_contains, starts_with, ends_with, regex, in, not_in, gt, gte, lt, lte, is_set and is_empty, plus an optional case_sensitive.
Actions
| Type | Shape | Description |
|---|---|---|
| send_message | text | A free-text reply on the conversation. |
| send_template | template_slug, variables? | An approved template, with its variables. |
| assign_conversation | assignee_type, assignee_id | Assign the thread to a user membership or to a team. |
| add_tag | tag | Add a tag to the contact. |
| remove_tag | tag | Remove a tag from the contact. |
| close_conversation | reason? | Close the thread, optionally recording a reason. |
| reopen_conversation | — | Reopen the thread. |
| add_note | body | Add an internal note. It is never sent to the contact. |
| call_webhook | url, method?, headers? | Call an HTTP endpoint of yours. GET, POST, PUT or PATCH. |
curl -X POST https://whats.azzamkh.sa/api/v1/automations \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Tag pricing questions",
"trigger_type": "message_received",
"trigger": { "match": "contains", "value": "price" },
"conditions": [
{ "type": "business_hours", "operator": "within" }
],
"actions": [
{ "type": "add_tag", "tag": "pricing" },
{ "type": "assign_conversation",
"assignee_type": "team", "assignee_id": "tm_..." }
],
"priority": 10
}'
# 201 Created
# {
# "id": "atm_...",
# "enabled": true,
# "priority": 10,
# "version": 1,
# "stats": {
# "run_count": 0, "success_count": 0, "failure_count": 0,
# "last_run_at": null, "last_error_at": null, "last_error": null
# },
# "next_run_at": null,
# ...
# }Dry-running a rule
POST …/test evaluates the REAL conditions against a real subject and executes nothing. The preview and the engine share one evaluator, so a rule that would not run here will not run in production either. It answers trigger_matched, conditions_matched, would_run and a per-action verdict, and is limited to 60 requests a minute per workspace.
curl -X POST https://whats.azzamkh.sa/api/v1/automations/atm_.../test \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "conversation_id": "cnv_...", "message_text": "what is the price" }'
# 200 OK
# {
# "automation_id": "atm_...",
# "trigger_matched": true,
# "conditions_matched": false,
# "would_run": false,
# "reason": "condition_not_met",
# "subject": {
# "conversation_id": "cnv_...", "contact_id": "cnt_...",
# "message_id": null, "channel_id": "ch_..."
# },
# "actions": [
# { "index": 0, "type": "add_tag", "would": "skip",
# "reason": "conditions_not_met" }
# ],
# "evaluated_at": "2026-08-14T09:00:00.000Z"
# }The run log
Every execution is recorded, whether it did anything or not. A run carries the automation_version it ran, the subject it ran against, an outcome per action, and duration_ms.
| State | Description |
|---|---|
| pending | Claimed and not started. |
| running | In progress. |
| completed | Every action succeeded. |
| partial | Some actions succeeded and some did not. |
| failed | The run failed. error carries the reason. |
| skipped | The trigger matched but the conditions did not. |
| blocked | Refused by a loop guard or an execution limit. |
chain_depthandcausation_run_idexist because an action can trigger another rule. The engine bounds the chain rather than letting two rules answer each other forever.- Each entry in
actionsreportsstatus(succeeded, skipped, failed) with areason, so a partial run tells you exactly which step gave up. - A run is credited to the definition version it executed, so editing a rule does not rewrite the history of what already ran.
Errors that matter here
| Code | HTTP | When it happens |
|---|---|---|
| AUTOMATION_NOT_FOUND | 404 | No automation with that id in this workspace. |
| AUTOMATION_RUN_NOT_FOUND | 404 | No such run on that automation. |
| AUTOMATION_INVALID_DEFINITION | 422 | The definition uses something outside the vocabulary, or contradicts itself. |
| AUTOMATION_LIMIT_REACHED | 409 | An execution limit or loop guard stopped it. |
| ENTITLEMENT_LIMIT_REACHED | 409 | The plan does not include automations, or a limit is reached. |
The welcome and away replies are automations too — see business hours and auto-replies. Every code and its status is on the errors page.