Back to the journal
Momo Business Guides

Flow steps: Do

API calls, webhooks, SMS and email, contacts, orders, payments, refunds and your Data tables — the steps that change something outside the chat.

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

Thanks for reading.Explore more stories