Part 2E — App actions and opt-outs
← Contents · Previous: Part 2D — Agents · Next: Part 3 — What the engine sends you
All endpoints: 🔒 Protected · Bearer API key.
K. App actions
The engine never sends SMS, takes payments or tags contacts. The client app says what it can do (its actions), flows ask for them (App action block, AI Agent tools, a campaign's fallback), and the client app does the work and answers. The round trip: Part 1 §1.9.
K1 PUT /v1/actions register the list (once, and when it changes)
flow reaches an App action block ──► webhook action.requested {actionId, name, callId, params, wait, timeoutAt}
client app does it (SMS, M-Pesa push…)
K3 POST /v1/calls/{callId}/action-result {actionId, outcome, data} ──► the flow goes on through that outcome's exit
K1. Register the actions
Purpose: Replace the client app's whole action list. Flow editors show these as the choices of the App action block.
Endpoint: PUT {base_url}/actions
Request JSON Sample:
[
{ "name": "send_sms", "label": "Send SMS",
"params": { "to": { "type": "phone", "default": "{caller}" },
"text": { "type": "text", "max": 480 } },
"wait": false },
{ "name": "tag_contact", "label": "Tag contact",
"params": { "tag": { "type": "text", "max": 40 } },
"wait": false },
{ "name": "pay", "label": "Pay now",
"params": { "amount": { "type": "money" },
"phone": { "type": "phone", "default": "{caller}" },
"wallet": { "type": "choice", "values": ["M-Pesa", "Tigo Pesa", "Airtel Money", "HaloPesa"] } },
"wait": true, "outcomes": ["paid", "failed_pin", "cancelled"], "timeoutSec": 120,
"hold": "Payment", "countAs": { "paid": "paid" }, "risky": true }
]
| Field | Type | Required | Description | Validation |
|---|---|---|---|---|
name |
string | Yes | Id used in flows ("action": "pay") |
[a-z][a-z0-9_]{0,63}, unique |
label |
string | Yes | Shown in the editor | not blank, max 120 |
params |
object | No | {paramName: {type, default?, max?, values?, optional?}} |
name [a-zA-Z][a-zA-Z0-9_]{0,39} |
params.*.type |
string | Yes | text · phone · money · number · choice |
choice needs values |
params.*.default |
string | No | Used when the flow does not set it; may be a template ({caller}) |
|
params.*.max |
integer | No | text: longest after variables are filled |
|
wait |
boolean | No | true: the caller waits (music, waiting room) until the client app answers or timeoutSec passes |
default false |
outcomes |
string[] | When wait |
The answers the client app can give; each becomes an exit of the block | [a-z][a-z0-9_]{0,39}; not next, failed, timeout (always there) |
timeoutSec |
integer | No | Longest wait for the answer | 10–1800, default 120 |
hold |
string | No | Waiting room preset while waiting (e.g. Payment) |
|
countAs |
object | No | Outcome → call outcome tag, for reports ({"paid": "paid"}) |
|
risky |
boolean | No | A flow using it needs approval (listed in riskyBlocks) |
default false |
At most 50 actions.
How flows fill the parameters:
| Type | Value in the flow | Checked |
|---|---|---|
text |
template: "Habari {name}, oda yako iko tayari" |
max length after filling |
phone |
template or E.164 ("{caller}") |
E.164 after filling |
money |
template or number ("{amount}") |
digits after filling (separators removed) |
number |
template or number | numeric |
choice |
one of values |
when the version is pushed |
A parameter without a default and not marked optional must be set by the flow, or the version is refused (422).
Success Response: 200, message "Actions saved", data = the list as stored (K2).
Errors:
{
"success": false,
"httpStatus": "422 UNPROCESSABLE_CONTENT",
"message": "The action list is not valid",
"code": "VALIDATION_FAILED",
"action_time": "2026-11-13T20:13:33.950000000",
"data": [
"pay: an action that waits needs outcomes",
"pay: timeoutSec must be 10-1800",
"pay.wallet: a choice needs values",
"Duplicate action: pay"
]
}
{
"success": false,
"httpStatus": "422 UNPROCESSABLE_CONTENT",
"message": "Validation failed",
"code": "VALIDATION_FAILED",
"action_time": "2026-11-13T20:14:41.000000000",
"data": { "[0].label": "must not be blank", "[0].name": "must match \"[a-z][a-z0-9_]{0,63}\"" }
}
{
"success": false,
"httpStatus": "409 CONFLICT",
"message": "An action that is in use can't be removed or narrowed",
"code": "ACTION_IN_USE",
"action_time": "2026-11-13T20:15:00.000000000",
"data": [
{ "script": "orders", "version": 3, "action": "pay" },
{ "callout": "4b0e…", "action": "send_sms" }
]
}
ACTION_IN_USE: removing an action, dropping an outcome, or changing wait, while a runnable flow version (App action
block or AI tool) or a scheduled / running / paused call-out (fallback) uses it. Adding actions, outcomes or optional
parameters is always allowed.
K2. Read the actions
Endpoint: GET {base_url}/actions
Success Response JSON Sample:
{
"success": true,
"httpStatus": "200 OK",
"message": "Actions",
"action": null,
"action_time": "2026-11-13T20:15:10.000000000",
"data": [
{ "name": "send_sms", "label": "Send SMS",
"params": { "to": { "type": "phone", "default": "{caller}" }, "text": { "type": "text", "max": 480 } },
"wait": false, "outcomes": null, "timeoutSec": 120, "hold": null, "countAs": null, "risky": false },
{ "name": "pay", "label": "Pay now",
"params": { "amount": { "type": "money" }, "phone": { "type": "phone", "default": "{caller}" },
"wallet": { "type": "choice", "values": ["M-Pesa", "Tigo Pesa", "Airtel Money", "HaloPesa"] } },
"wait": true, "outcomes": ["paid", "failed_pin", "cancelled"], "timeoutSec": 120, "hold": "Payment",
"countAs": { "paid": "paid" }, "risky": true }
]
}
Each client app sees only its own list; all its accounts' flows may use it.
K3. Answer an action
Purpose: Tell the engine how an action.requested turned out. For a waiting action the caller hears the result at
once and the flow goes on through that outcome's exit.
Endpoint: POST {base_url}/calls/{callId}/action-result
What the client app received (webhook, Part 3):
{ "id": "…", "type": "action.requested", "createdAt": "2026-11-20T09:12:30Z",
"data": { "actionId": "c3a9e1f0-…", "name": "pay", "callId": "4b1e…", "accountRef": "duka-juma", "block": "pay_1",
"params": { "amount": "45000", "phone": "+255712345678", "wallet": "M-Pesa" },
"wait": true, "timeoutAt": "2026-11-20T09:14:30Z" } }
Request JSON Sample:
{
"actionId": "c3a9e1f0-…",
"outcome": "paid",
"data": { "receipt": "QK7HT2LM9P", "amount": "45000" }
}
| Parameter | Type | Required | Description |
|---|---|---|---|
actionId |
uuid | Yes | From action.requested |
outcome |
string | Yes | Waiting action: one of its outcomes, or failed. Not waiting: sent, done, ok or failed (only for the call's timeline) |
data |
object | No | Saved on the call as {action.<block>.<key>} variables: the flow can say "Risiti yako ni {action.pay_1.receipt}" |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "200 OK",
"message": "Action result recorded",
"action": null,
"action_time": "2026-11-20T09:13:05.000000000",
"data": {
"actionId": "c3a9e1f0-…",
"callId": "4b1e…",
"name": "pay",
"outcome": "paid",
"callWaiting": true,
"resolvedAt": "2026-11-20T09:13:05Z"
}
}
| Field | Description |
|---|---|
callWaiting |
The caller was still waiting for this answer, and now goes on. false = the call had already moved on (or ended): the answer is recorded and sent back as a call.block webhook with late: true |
Answering twice: the same outcome again → 200 with the same data (safe to retry). A different outcome → 409.
Errors:
400"outcome is required" · "outcome must be one of [failed, paid, failed_pin, cancelled]"404"Call not found" · "Action not found on this call"409 ACTION_ALREADY_RESOLVED"This action already has the result paid"409 ACTION_TIMED_OUT—timeoutSecpassed; the call already took thetimeoutexit
If a flow names an action the client app no longer has, the block takes its
failedexit and the timeline showsERROR {code: ACTION_UNKNOWN}.
L. Opt-outs (do-not-call list)
Numbers an account must never call. The engine checks the list when people are added to a call-out, again just before each dial, and when a caller books a callback. Callers can put themselves on it with the Opt out block ("Bonyeza 9 tusikupigie tena").
L1. Add a number
Endpoint: POST {base_url}/accounts/{accountId}/opt-outs
Request JSON Sample:
{ "e164": "+255745051250" }
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
e164 |
string | Yes | The number | full E.164 with + (0745… is refused) |
Success Response: 200, message "Number opted out", data null. Adding it again is harmless.
Errors: 400 "Not an E.164 number: 0700000099".
L2. List
Endpoint: GET {base_url}/accounts/{accountId}/opt-outs
Success Response JSON Sample:
{
"success": true,
"httpStatus": "200 OK",
"message": "Opted-out numbers",
"action": null,
"action_time": "2026-11-13T20:14:50.957455654",
"data": ["+255700000099"]
}
L3. Remove a number
Purpose: The person may be called again (they asked to be, or it was a mistake).
Endpoint: DELETE {base_url}/accounts/{accountId}/opt-outs/{e164}
Encode the + in the path: /opt-outs/%2B255745051250.
Success Response: 200, message "Number can be called again", data null. Removing a number that is not on the
list is harmless.