# Flows

URL: /docs/guides/flows

Automation flows from a developer's point of view, including triggers, actions, the webhook action, loop protection, consent and the merchant API.

A flow is an automation the merchant builds in the store admin under **CRM → Automation → Flows**: a trigger, then checks (yes and no paths), waits and actions. Most stores never touch flows through code. This page is for developers who connect flows to their own systems, or create flows through the merchant API.

## Triggers [#triggers]

A flow starts on one of 17 events. Filters narrow it down, for example to one form or one tag. Filter values are compared as strings.

| Event | Starts when | Filters |
|---|---|---|
| `form.submitted` | A store form is submitted. | `form_handle` |
| `tag.added` | A tag is added to a contact (only when it is new to them). | `tag` |
| `message.received` | A message arrives in the inbox. | `channel` (`sms`, `web`) |
| `order.placed` | An order is placed. | |
| `order.cancelled` | An order is cancelled. | |
| `order.dispatched` | An order is dispatched. | |
| `order.delivered` | An order is marked delivered. | |
| `call.completed` | An AI voice call ends (coming soon, with AI voice). | |
| `analytics.alert_fired` | An analytics alert fires (coming soon, with Analytics). | `alert_id`, `metric`, `rule` |
| `crm.stage_changed` | A contact's or company's lifecycle stage changes. | `object`, `from`, `to` |
| `crm.field_changed` | A CRM write changes fields (one run per write). | `object`, `field`, `value` |
| `crm.owner_assigned` | A record gets an owner. | `object`, `owner` |
| `task.overdue` | A task becomes overdue. | `type`, `queue` |
| `crm.no_reply` | No reply N hours after the store's last message, or a lead's response target passes. | `after`, `hours`, `channel`, `source` |
| `company.created` | A company is created. | |
| `list.joined` | A contact joins a smart list. | `list` |
| `list.left` | A contact leaves a smart list. | `list` |

The `order.*` triggers need the Shop. Every other trigger, apart from `analytics.alert_fired`, needs the CRM, which every store has.

## Actions [#actions]

| Action | Does | Main settings |
|---|---|---|
| `send_email` | Emails the contact, or for an order trigger the order's contact address. | `subject`, `body`, `recipient` (`customer` or `order`) |
| `send_sms` | Texts the contact through the store's Twilio number. | `body` |
| `send_template` | Sends one of the store's message templates. | `template` (its key) |
| `webhook` | Posts the run's data to your HTTPS endpoint. | `url` |
| `create_task` | Creates a CRM task. | `title`, `type`, `priority`, `due_in_days`, `assignee`, `queue` |
| `set_stage` | Moves the contact or company to a lifecycle stage. | `stage`, `object`, `only_forward` |
| `set_field` | Sets a standard or custom field. | `field`, `value`, `object` |
| `set_owner` | Assigns an owner. | `owner`, `object`, `only_if_unowned` |
| `add_tag` | Adds a tag. | `tag` |
| `remove_tag` | Removes a tag. | `tag` |
| `add_note` | Adds a note to the timeline. | `body`, `object` |
| `vapi_call` | Phones the customer with an AI assistant (coming soon). | |

Text in emails, texts, tasks and notes can use placeholders such as `{{ crm.company_record.name }}`. WhatsApp templates in `send_template` are coming soon, with WhatsApp in the inbox.

## The webhook action [#the-webhook-action]

The `webhook` action posts JSON to a URL you choose. Use it to tell your own system that something happened: a quote request to price, a VIP to call back.

- The URL must be `https://` and must resolve to a public address. Private, loopback and reserved addresses are refused, as is a host that does not resolve.
- Redirects are not followed. A `3xx` answer counts as a failure, like a `4xx` or `5xx`.
- The body has `event` (`flow.webhook`), `customer_id`, `trigger` (the event and its data) and `vars` (what earlier steps produced). For a form trigger, the trigger data includes the form's answers, so treat the body as personal data.
- The request carries `X-ElaraOne-Signature`: the lower-case hex HMAC-SHA256 of the raw body, keyed with the store's server secret (the `X-Tenant-Secret` value). There is no timestamp in this signature.
- A staging store's requests also carry `X-ElaraOne-Environment: staging`.

```ts title="Verify a flow webhook (Node)"
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyFlowWebhook(rawBody: string, header: string | null, serverSecret: string): boolean {
  if (!header) return false
  const expected = createHmac('sha256', serverSecret).update(rawBody, 'utf8').digest('hex')
  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(header, 'utf8')
  return a.length === b.length && timingSafeEqual(a, b)
}
```

A post with no answer, or an answer outside `2xx`, fails the step, and the run's page in the admin shows the error under "For developers". Answer quickly and do the work afterwards. For logged delivery of CRM changes with retries and a "Send again" button, use [CRM webhooks](/docs/webhooks) instead.

## Loop protection [#loop-protection]

A flow that changes a field could start itself again for ever, and two flows could set each other off. The engine stops both:

- a flow never starts from a change it made itself, or from a change made by a flow earlier in its chain (A → B → A stops at the second A);
- a chain of flows stops at a depth of 3, however many different flows take part.

A blocked start is logged and skipped. Nothing fails.

## Consent [#consent]

Flows respect the store's do-not-contact list and the contact's marketing consent.

- `send_email` to the contact and `send_sms` are automated messages. An address or number on the do-not-contact list is skipped: the step succeeds with `skipped: "recipient_suppressed"`, so the rest of the flow still runs. Every automated email carries a one-click unsubscribe.
- `send_email` with `recipient: "order"` is a service message about an order the person placed, like a dispatch notice. It is allowed only on `order.*` triggers.
- `send_template` follows the template's purpose. A **Marketing** template needs a current "yes" to marketing consent and an address not on the do-not-contact list; a contact who never answered gets nothing. A **Service** template skips the marketing checks.
- A text that answers the person's own incoming SMS, sent back into the same conversation soon after, counts as a reply, not automation.

## Test with a contact [#test-with-a-contact]

In the flow builder, **Test with a contact** walks the draft for a real contact, using their latest form answers or the trigger's own details. Each check is worked out, each action says what it would do, and each wait says when the run would carry on. Nothing is changed, sent or queued, and no run is recorded.

Each published version is kept, and a run always finishes on the version it started with, so publishing a change never disturbs runs already under way.

## Merchant API [#merchant-api]

A merchant API key with the `manage_flows` ability can list and save flows.

| Method | Path | Notes |
|---|---|---|
| `GET` | `/v1/merchant/flows` | Paginated. `filter[handle]`, `filter[is_enabled]`, `sort=name\|handle\|created_at`. |
| `POST` | `/v1/merchant/flows` | Create or update by `handle`. |

A flow document has a `trigger` (`event` and `filters`) and a `graph` of `nodes`. Each node has an `id`, a `type` (`trigger`, `condition`, `action`, `delay` or `end`), a `config` and a `next`. A `condition` node's `next` is `{ "true": "…", "false": "…" }`; every other node's `next` is a node id or `null`. There must be exactly one `trigger` node and no cycles.

```bash tab="cURL"
curl -X POST https://api.elaraone.app/api/v1/merchant/flows \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  -H "Authorization: Bearer $ELARAONE_MERCHANT_KEY" \
  -H "Content-Type: application/json" \
  -d @flow.json
```

```json title="flow.json"
{
  "handle": "quote-to-crm",
  "name": "Quote requests to our pricing tool",
  "is_enabled": false,
  "trigger": { "event": "form.submitted", "filters": { "form_handle": "quote-request" } },
  "graph": {
    "nodes": [
      { "id": "trigger", "type": "trigger", "config": {}, "next": "tag" },
      { "id": "tag", "type": "action", "config": { "action": "add_tag", "tag": "quote" }, "next": "notify" },
      { "id": "notify", "type": "action", "config": { "action": "webhook", "url": "https://pricing.example.com/hooks/quote" }, "next": "end" },
      { "id": "end", "type": "end", "config": {}, "next": null }
    ]
  }
}
```

The answer is the saved flow: `id`, `handle`, `name`, `trigger`, `graph`, `is_enabled`, `version`, `created_at` and `updated_at`.

<Callout type="warn" title="A save through the API is live at once">
A flow saved through the API is published immediately and kept as a version. `is_enabled` defaults to `true`, so send `false` until you have tested it in the builder.
</Callout>

The document is checked by the same validator as the flow builder. A problem is a `422 VALIDATION_FAILED` with `details` keyed by where it is: `trigger`, `graph`, `graph.<node id>` or `graph.<node id>.config.<setting>`. A key without the ability gets `403 FORBIDDEN`.
