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
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
| 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 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
3xxanswer counts as a failure, like a4xxor5xx. - The body has
event(flow.webhook),customer_id,trigger(the event and its data) andvars(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 (theX-Tenant-Secretvalue). There is no timestamp in this signature. - A staging store's requests also carry
X-ElaraOne-Environment: staging.
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 instead.
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
Flows respect the store's do-not-contact list and the contact's marketing consent.
send_emailto the contact andsend_smsare automated messages. An address or number on the do-not-contact list is skipped: the step succeeds withskipped: "recipient_suppressed", so the rest of the flow still runs. Every automated email carries a one-click unsubscribe.send_emailwithrecipient: "order"is a service message about an order the person placed, like a dispatch notice. It is allowed only onorder.*triggers.send_templatefollows 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
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
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.
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{
"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.
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.
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.