Message flows run on WhatsApp in this release. Other channels are on the roadmap; a flow bound to any other channel is refused at publish.
Do steps change something outside the conversation: an API call, a webhook, an SMS or email, a contact, an order, a payment, a record in your Data tables. Each has a failed branch, because the world does not always answer.
This is the reference for the Do group of the flow builder's palette. Every step below is listed with what it does, the settings it takes, the branches it can leave by and the one thing to keep in mind. Settings marked "templated" accept {{vars.x}}, {{contact.name}} and {{last.text}}.
HTTP request
Call an API and store the response.
| Setting | What it does |
|---|---|
method |
GET, POST, PUT, PATCH or DELETE. |
url |
The URL to call (templated). |
headers |
[{name, value}] request headers. |
body_fields |
[{name, value}] sent as the JSON body (templated). |
store_as |
Variable to store the parsed response in. |
store_raw |
true to store a non-JSON body as text; by default only a JSON body is stored. Bodies over 256 KB are refused. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: ok failed
Keep in mind: Private and internal addresses are refused, bodies over 256 KB are refused, and credentials belong in Flow secrets, not in the URL.
Webhook
Fire-and-forget notification to a URL.
| Setting | What it does |
|---|---|
url |
The public https URL to POST to. |
event |
The event name in the payload. |
fields |
Optional list of variable names to include; blank posts every variable the author set (never the runner's own bookkeeping). Body: {event, tenant_id, conversation_id, session_id, flow_id, node_id, contact{name, identifier}, variables, sent_at}. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: next
Keep in mind: Only the variables the flow set are posted, signed with the workspace's signing key.
Send SMS
A text message out of band, to anyone. Branch on refusal, or wait for the delivery report.
| Setting | What it does |
|---|---|
to |
Phone number or template; blank sends to this customer. |
body |
The SMS text (templated). |
tenant_channel_id |
Optional SMS channel (sender) id; blank uses the default. |
wait_for_delivery |
true to hold for the delivery report and gain the delivered / undelivered / timeout outs. |
delivery_timeout_minutes |
When waiting: give up after this many minutes (default 10). |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: next failed delivered undelivered timeout — next and failed always; delivered, undelivered and timeout only when config.wait_for_delivery is true.
Keep in mind: Priced per message from the wallet; an empty balance leaves by failed.
Send email
Tell somebody internally what just happened. Branch on a refused send, or hold for a bounce.
| Setting | What it does |
|---|---|
to |
Recipient address (templated). |
subject |
Subject line (templated). |
body |
Plain-text body (templated). |
wait_for_delivery |
true to hold for a bounce and gain the delivered / undelivered outs. |
delivery_window_minutes |
When holding: how long to wait for a bounce (default 30). |
transactional |
true for a receipt or a notice the recipient asked for: skips the "sent on behalf of" footer and the unsubscribe header. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: next failed delivered undelivered — next and failed always; delivered and undelivered only when config.wait_for_delivery is true.
Keep in mind: Goes out from the platform address with the workspace as Reply-To and a footer, unless transactional.
Save contact
Put this person in the contact book, or update them.
| Setting | What it does |
|---|---|
contact_group_id |
Optional contact group id to put the person in. |
phone |
Phone to save (templated); blank uses the customer's WhatsApp number. |
name |
Name to save (templated); blank uses their WhatsApp name. |
fields |
Optional [{name, value}] extra fields on the contact (templated). |
store_as |
Optional variable to store the saved contact id in. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: next
Keep in mind: Matches on the phone number; a second save updates rather than duplicates.
Create order
Turn what the conversation gathered into a real order, stamped with this flow.
| Setting | What it does |
|---|---|
items_from |
Variable holding the order lines, e.g. items (each {name, sku?, quantity}). Lines from a variable are PRICED FROM THE CATALOGUE: each is matched to a product by sku (retailer id) or by name, and a line the catalogue does not know is dropped and named in vars.__order_error. The price the conversation carried is never charged. |
items |
Alternative to items_from: [{name, price, quantity}] typed on the node (templated). Lines typed here are the author's own and are charged as written. |
trust_prices |
true to charge the price a variable line carries when the catalogue does not know the product (the line is flagged untrusted). Off by default; publish warns when on. |
max_quantity |
The most of one line a flow may order (default 100). A larger quantity drops the line. |
currency |
Three-letter currency, default TZS. |
store_as |
Variable to store the created order in (default order). |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: ok short empty failed
Keep in mind: Lines from a variable are priced from the catalogue; a line the catalogue does not know is dropped unless trust_prices.
Collect payment
Ask any amount, hold the record it is paying for, and hold the conversation until the money settles — or does not.
| Setting | What it does |
|---|---|
amount |
How much to collect, in whole money (templated) — 12500, or {{vars.total}}. Never minor units: the node converts. |
currency |
Three letters; blank uses the workspace currency. One flow should not ask in two currencies without saying so. |
method |
ussd_push (a prompt on their phone), link (a checkout page), lipa (a lipa number they pay from any app), or ask — which offers all three as buttons and charges whichever they pick. |
msisdn |
The number to charge (templated); blank uses the customer's WhatsApp number. |
ask_body |
The question put above the three buttons when method is ask. Blank asks "Utalipaje ?". |
ask_number |
With a push, ask whether to charge the WhatsApp number they are chatting on or another one they type. Off by default — a second question is a step to abandon at. |
preparing_body |
What the customer is told BEFORE the gateway is called (templated), because that call takes seconds. Blank sends "Naandaa ombi la malipo ya … subiri kidogo." Deliberately promises nothing: there is no PIN prompt or lipa number yet. |
pending_body |
What the customer is told once the ask is out (templated). Blank sends the default for the method: where the prompt went, the lipa number, or the checkout link. |
failed_body |
What the customer is told when the ask could not be started at all (templated). Blank says nothing was charged and to try again. The provider's own error is never repeated to the customer — it goes to vars.__data_error for your "failed" branch. |
notify |
Set false to say nothing when the ask goes out. Only for a flow that sends its own wording — silence here is a conversation that stops dead with a live checkout behind it. |
payer_name |
Who is paying (templated); blank uses the contact name on the conversation. |
payer_email |
The payer's email (templated), for a gateway that wants one. Optional. |
hold_table_id |
Pay-before-confirm: the data table id (uuid) of the record this payment is holding. |
hold_record_id |
The record being held, usually {{vars.booking.$id}}. Binding it is what lets the reconciler sweep a booking whose payment never settled. |
expires_in_minutes |
How long the ask stays open before it expires, 1 minute to 7 days (default 180). |
store_as |
Variable receiving {payment_id, reference, state, amount, amount_minor, currency, checkout_url?, payment_token?, next_step} (default payment). |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: paid failed expired cancelled
Keep in mind: The conversation waits until the money settles, expires or is cancelled; the gateway is the platform's unless the workspace has its own approved one.
Find records
Look a record up in one of your tables and keep it in a variable.
| Setting | What it does |
|---|---|
table_id |
The data table id (uuid) from list_data_tables. |
mode |
one (first match) or many (a list plus vars.<store_as>_count). |
conditions |
{all|any: [{column, op, value}]} — see data_nodes.conditions. |
sort |
{column, dir} — dir asc or desc. |
limit |
For many: at most this many records (1–50, default 10). |
store_as |
Variable to store the record (or list) in. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: found not_found failed
Keep in mind: Needs a Data table in this workspace; table_id is the uuid from Data.
Save record
Create, update or upsert a record in one of your tables.
| Setting | What it does |
|---|---|
table_id |
The data table id (uuid). |
mode |
create, update or upsert. |
record_id |
For update: the record to change, usually {{vars.x.$id}}. |
match_column |
For upsert (or update without an id): the unique column to match on. |
fields |
[{key, value}] — the columns to write (templated). |
store_as |
Optional variable to store the saved record in. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: saved invalid rate_limited failed
Keep in mind: Needs a Data table in this workspace; a save that breaks a unique or required rule leaves by invalid.
Delete record
Soft-delete a record by id.
| Setting | What it does |
|---|---|
table_id |
The data table id (uuid). |
record_id |
The record to soft-delete, usually {{vars.x.$id}}. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: deleted not_found failed
Keep in mind: A soft delete — the record can be restored from Data.
Send report
Build a CSV, XLSX or PDF from a table or a report and send it as a document, an email, or a link.
| Setting | What it does |
|---|---|
source |
{kind: records|report|saved, table_id?, group_id?, saved_report_id?} — what the file is built from. |
table_id |
Fallback for source.table_id when source is not given. |
filter |
Condition tree over the records (leaf values templated); records only. |
conditions |
Alias of filter, the data_find spelling. |
sort |
{column, dir} for a records export; dir asc or desc. |
columns |
Column keys to include in a records export; blank = every column. |
definition |
A report definition (metrics, dimension, filters, date_range) when source.kind is report. |
range |
Optional date_range override for a report, e.g. {relative: last_30_days}. |
title |
The file title (templated); blank = the table or report name. |
format |
pdf, xlsx or csv (default pdf; a PDF over 2,000 rows becomes xlsx). |
deliver |
{via: whatsapp|email|sms|variable, to?, caption?, subject?, expires_hours?} (templated); whatsapp with no to = this customer. to must be the EXACT number of a thread (no look-alike matching). A file a flow sends is rendered for nobody: every sensitive column is masked. expires_hours: how long the link lives (default 24 for a customer delivery, max 720). |
max_rows |
Cap the rows, up to 50,000. |
store_as |
Variable receiving {url, filename, rows, format, expires_at} (default report). |
timeout_minutes |
Give up waiting for a queued render after this many minutes (default 15). |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: sent empty failed
Keep in mind: A file a flow sends is masked for every sensitive column, and the link expires.
Move state
Move a record on to its next state — draft → confirmed → paid — and branch when the move is not allowed.
| Setting | What it does |
|---|---|
table_id |
The data table id (uuid) holding the record. |
record_id |
The record to move, usually {{vars.booking.$id}}. |
match_column |
Instead of an id: the column to find the record by, usually a unique one. |
match_value |
The value that column must hold (templated), e.g. {{contact.phone}}. |
to |
The state key to move to, e.g. paid. It must be a state of the status field. |
reason |
Why the record moved (templated); kept on the history row beside the move. |
column |
Which status field, when the table has more than one. Blank uses its only one. |
store_as |
Optional variable to store the moved record in. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: moved blocked not_found failed
Keep in mind: The move must be one the table's status field allows; blocked says which rule refused it.
Allocate
Take one free row out of a table and mark it taken — a seat, a slot, a queue number, a promo code.
| Setting | What it does |
|---|---|
table_id |
The data table id (uuid) the pool of rows lives in. |
pool |
Condition tree choosing the FREE rows, same shape as data_find.conditions — e.g. {"all":[{"column":"taken_by","op":"is_empty"}]}. Blank means every row is free. |
claim |
The patch that marks the winner taken: [{key, value}] (templated), or a plain {key: value} map. An EMPTY claim hands the same row to the next caller too. |
sort |
{column, dir} — who wins when several are free. Default is oldest first, which is the fair order and the one the index already answers. |
store_as |
Variable receiving the claimed record; read {{vars..seat}} and {{vars..$id}}. |
ttl_seconds |
How long the allocation LOCK may live, 1–900 (default 15). It is not a reservation: a claimed row stays claimed until something changes it. |
wait_seconds |
Queue this long for the lock instead of leaving by failed straight away, 0–60 (default 0). |
lock_name |
Share one queue across tables by naming it; blank locks per table. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: allocated unavailable failed
Keep in mind: The lock is short; a claimed row stays claimed until something changes it.
Generate value
Make an id, reference, code, OTP, token or random number and keep it in a variable.
| Setting | What it does |
|---|---|
as |
What to make: uuid, number, decimal, code, otp, text, token, reference or password. Every draw is cryptographic (random_int / random_bytes), so a code cannot be guessed from the last one. IT IS A DRAW, NOT A RESERVATION: nothing checks the value has never been handed out, so two sessions can get the same one. A reference that MUST be unique belongs in a Daftari column with a unique index (the save then fails loudly on a clash), or in an auto_number column — a number the platform hands out, never the caller, from a counter that cannot collide. |
store_as |
Variable to put the value in (default generated). |
version |
uuid only: 4 (random, the default) or 7 (time-ordered, so ids sort by when they were made). Both are RFC 9562. |
case |
uuid: lower (default) or upper. text: lower, upper or mixed. |
dashes |
uuid only: keep the 8-4-4-4-12 dashes (default true); false gives 32 hex characters. |
min |
number / decimal: the lowest value, inclusive (default 0). |
max |
number / decimal: the highest value, inclusive (default 999999). |
decimals |
decimal only: how many decimal places, 0–6 (default 2). |
length |
code (default 6), otp (4–12, default 6), text (default 8), password (8–64, default 12). |
alphabet |
code only: digits, letters, alphanumeric or upper_alphanumeric (default). |
avoid_ambiguous |
code only: leave out 0, O, 1, I and l so nobody misreads it down a phone line (default true). |
bytes |
token only: how many random bytes to encode, 8–128 (default 32); the token is base64url, so ~4 characters per 3 bytes. |
prefix |
reference only: what it starts with, e.g. ORD (up to 16 characters, templated). |
date_part |
reference only: none, date (20260908, the default), yearmonth (202609) or year (2026), read in the account's timezone. |
random_length |
reference only: how many random digits on the end, 0–12 (default 4). |
separator |
reference only: what joins the parts (default -). |
upper |
reference only: upper-case the whole thing (default true). |
symbols |
password only: include punctuation as well as letters and digits (default true). |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: next
Keep in mind: A draw, not a reservation — a value that must be unique belongs in a unique Data column.
Format value
Write a value the way a customer should read it — money, a date, a phone number — into a second variable.
| Setting | What it does |
|---|---|
value |
The value to write down (templated). It is read with its type intact, so {{vars.total}} arrives as a number and {{vars.names}} as a list. |
as |
The style: money, number, percent, date, datetime, time, phone, text, mask or list. Anything else is refused at publish rather than printed raw. |
store_as |
Variable to put the formatted text in (default formatted). The original value is left alone, so it stays a number for the sums that follow. |
currency |
money: three-letter code, default the workspace's own currency. Money is printed by the same class a Daftari currency column uses, so the two always agree. |
decimals |
money (default 0, as in Daftari), number (blank keeps what the value has), percent (blank = up to two, trailing zeros dropped). |
symbol |
money: code (default, "TZS 12,500") or none ("12,500"). |
compact |
money / number: true prints 12.5K, 1.5M, 2.3B instead of every digit. |
thousands |
number: what separates the thousands (default ","; use " " for 12 500 or "" for none). |
decimal_point |
number: what separates the decimals (default "."). |
of |
percent: a second value, making the answer the first as a share of it — value 3 of 12 is 25%. Without it the value is already a percentage. |
preset |
date / datetime / time: short, medium (the default), long, day_month or iso. 08 Sep 2026 · 8/9/2026 · 08 September 2026 · 08 Sep · 2026-09-08. |
pattern |
date / datetime / time: a PHP date() pattern such as d/m/Y H:i, used instead of a preset. |
timezone |
date / datetime / time: the clock to read it on; blank uses the workspace timezone. |
locale |
date / datetime / time / list: en or sw, for month and day names and for the "na" that joins a list. Blank follows the platform language. |
style |
phone: e164 (+255712345678), international (+255 712 345 678, the default) or local (0712 345 678). |
country |
phone: the two-letter region a leading 0 belongs to, default TZ. A number from elsewhere never prints as local. |
case |
text: upper, lower, title or sentence. |
trim |
text: cut leading and trailing spaces (default true). |
truncate |
text: the longest it may be, counting the … that replaces what was cut. |
pad |
text: {side: left|right, char: "0", length: 6} — how a 7 becomes 000007. |
slug |
text: true turns "Asha Juma & Co" into asha-juma-co. |
keep_last |
mask: how many characters stay readable at the end (default 4). |
keep_first |
mask: how many stay readable at the start (default 0). |
char |
mask: what hides a character (default *). One mask character per hidden character, so the length still tells the truth. |
separator |
list: what joins all but the last item (default ", "). |
last_separator |
list: what joins the last one (default " na " in Kiswahili, " and " in English). |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: next
Keep in mind: Writes text into a second variable; the original keeps its type for the sums that follow.
Run an operation
Do one of the business's named operations — take a booking, register a customer, close a case — the same way every other channel does it.
| Setting | What it does |
|---|---|
operation |
The operation's key from /app/operations, e.g. create_booking (templated). It is refused at publish if this workspace has not written it. |
inputs |
What to call it with: {name: value} or [{key, value}], templated. Values keep their type, and the operation checks every one of them against its own declared kind before any step runs — so this node never validates them a second time. |
store_as |
Variable receiving the operation's OUTPUTS on ok (default operation). The envelope — status, run id, and what a failure could not take back — is always in vars.__operation. |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: ok invalid failed
Keep in mind: Needs an operation written under Operations; its inputs are checked by the operation, not by this step.
Refund payment
Give back some or all of a payment collected earlier. A person approves it; the flow waits for the decision, or leaves by pending_approval if that exit is drawn.
| Setting | What it does |
|---|---|
payment_id |
The payment to give back (templated). Blank uses {{vars.payment.payment_id}} — the one collect_payment stored. |
amount |
How much to refund, in whole money (templated) — 5000, or {{vars.kiasi}}. Blank refunds everything still refundable. Never minor units: the node converts. |
reason |
Why (templated). Kept on the record and shown to whoever approves it. |
policy |
Who approves: {mode: any_one|all, approvers: {users: [ids] | roles: [owner, manager]}}. Blank asks any one owner or manager. |
notify |
Set false to say nothing to the customer while the refund is pending or done. |
store_as |
Variable receiving {refund_id, reference, state, amount, amount_minor, currency, refund_of, settled_at, message} (default refund). |
retry |
Optional {max_attempts: 1–5 (1 = no retry), backoff_seconds: 0–300 (doubles per attempt, capped at 300), on: [temporary_failure, provider_failure, rate_limited, conflict, not_found, quota_exceeded, permission_denied, not_supported, validation_error]} — the runner re-runs this node instead of taking its failed exit while those failures keep happening. |
Branches: refunded pending_approval failed
Keep in mind: Always goes to a person for approval; the flow waits unless pending_approval is drawn.
Where next
- Open the builder at Message flows and drag any of these onto the canvas — the panel on the right shows the same settings.
- New to flows? Start with Build your first WhatsApp flow.
- The other groups: Say · Ask · Think · Decide · Wait · Route.
- The rules every flow lives under: the 24-hour window, payments, what we keep and why a flow stops.