Skip to main content

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 — timeoutSec passed; the call already took the timeout exit

If a flow names an action the client app no longer has, the block takes its failed exit and the timeline shows ERROR {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.