# Part 2 — Setups, flows, calls, agents,

# Part 2A — Setup: basics, accounts, numbers

[← Contents](README.md) · Next: [Part 2A — Setup](2a-setup.md)

Read this page before the endpoints. Ringmaster is the **engine** of an intelligent call centre; your service is a
**client app** built on it. This page explains the engine's objects, how a call runs, and what the client app must do
at each step.

---

## 1.1 Who's who

| Who | What they are | Example |
|---|---|---|
| **Ringmaster owner** | Runs the engine; creates client apps, buys phone numbers, gives numbers to client apps (`ringmaster-admin`, see [Appendix](appendix.md)) | QBIT SPARK ops |
| **Client app** | A product that uses the engine through this API. Has one API key, a webhook URL and a set of features | a messaging or business platform |
| **Account** | One business inside a client app. Everything (numbers, flows, calls, agents) belongs to an account | PMK Chemicals, Kliniki ya Afya Bora |
| **Number** | A phone number the account answers on and calls from | `+255677000010` |
| **Call flow (script)** | What a caller hears and can do, as wired blocks. Stored in numbered, unchangeable **versions** | `orders` v3 |
| **Agent** | A person who answers calls in the browser (or on a mobile). Ringmaster keeps only a ref and a name | `asha` |
| **Department** | A group of agents a flow can send callers to | Mapokezi, Uhasibu |
| **Caller / person called** | Anyone on a phone. Ringmaster keeps only the number and what happened on the call | `+255745051250` |

```
Ringmaster owner
 └── Client app  (API key, webhook URL, features)
      ├── Account: PMK Chemicals  (hours, quiet hours, recording, limits)
      │    ├── Numbers ── each plays one pinned flow version
      │    ├── Call flows ── versions v1, v2, v3 …
      │    ├── Departments ── agents
      │    ├── Media (audio files), knowledge base (for the AI), opt-outs
      │    └── Calls, call-outs, reports
      └── Account: Kliniki ya Afya Bora …
```

A client app maps its own objects to these: one of its businesses = an account (`externalRef` = its business id), one
of its users who answers calls = an agent (`ref` = its user id).

---

## 1.2 Getting access

The Ringmaster owner creates the client app once and gives it:

| Item | What it is | Where it's used |
|---|---|---|
| **API key** | `vk_live_…`, shown once | Every request: `Authorization: Bearer <key>` |
| **Webhook URL** | The client app's HTTPS endpoint, e.g. `https://app.example.co.tz/hooks/ringmaster` | The engine POSTs events there ([Part 3](3-webhooks.md)) |
| **Webhook secret** | Shown once; signs every webhook | The client app checks each event's signature |
| **Features** | What the app may do | See below |
| **Rate limit** | Requests per second for the app (fixed one-second window) | Over it → `429 RATE_LIMITED` |

| Feature | Allows |
|---|---|
| `INBOUND` | Answering incoming calls. Without it every incoming call is refused (`REJECTED`, `FEATURE_OFF`) |
| `CALLOUTS` | Calling people: quick calls and call-outs (`403 FEATURE_OFF` without it); callers booking a callback (refused without it) |
| `OTP` | Voice OTP calls (`POST /v1/otp`); without it → `403 FEATURE_OFF` |
| `AI` | AI Agent blocks and "smart" (AI) speech matching in Menu, Poll and Pick; a flow using them is refused (`403 FEATURE_OFF`) |
| `NUMBERS` | Adding and removing numbers through the API; without it → `403 FEATURE_OFF` |

Features are checked when something is made **and again when it runs**: if the owner takes a feature away later,
running call-outs pause (`FEATURE_OFF`), AI Agent blocks take their `err` exit, and smart speech matching falls back
to the options' own words. Agent click-to-call (`POST /v1/calls/agent`) needs no feature.

A client app only ever sees its own accounts, numbers and calls. Another app's id gives `404 NOT_FOUND`.

---

## 1.3 Setting up a business, in order

```
1. POST  /v1/accounts                         {externalRef: "pmk-chemicals"}        → accountId
2. PATCH /v1/accounts/{accountId}             {businessName, hours, quietHours, …}
3. GET   /v1/numbers?status=ALLOCATED          free numbers the owner gave the client app
   POST  /v1/numbers/{numberId}/assign         {accountId, label}
4. PUT   /v1/accounts/{accountId}/scripts/orders            {name, direction: "INCOMING"}
   POST  /v1/accounts/{accountId}/scripts/orders/versions   {version: 1, definition, runnable: true}  → versionId
5. PUT   /v1/numbers/{numberId}/script         {scriptVersionId}
```
From step 5 on, calls to the number play the flow. Optional: departments and agents (to connect callers to people),
media (recorded greetings, music), knowledge base (for AI agents), App actions (SMS, payments done by the client app).

---

## 1.4 An incoming call, step by step

```
Caller dials +255677000010
   │  phone network → SIP trunk → LiveKit room → voice worker joins
   ▼
Engine: which account and which flow version is pinned on the number?   (none → call refused, NO_SCRIPT)
   │  checks: app active, account active, INBOUND on, caller under the per-caller limit
   ▼
call.started webhook ──────────────────────────────────────────► client app
   │
   ▼  blocks run one after another, starting at the flow's start block
   say "Karibu PMK Chemicals…" → menu "Bonyeza 1 kuagiza…" → (caller presses 1 or says "oda")
   │      every step is written to the call's timeline (BLOCK_ENTER, SAY, KEYS, SPEECH, …)
   │      call.block webhooks for the steps clients care about (menu choice, App action, …)
   ▼
   … collect → pick → confirm → goodbye
   ▼
Call ends ─► call.ended webhook (full summary: result, durations, keys, outcomes, cost)
         └─► recording.ready / summary.ready later, if anything was recorded
```

What the caller hears is played **in order, block after block**: a block's sound finishes before the next block's
sound starts. Blocks that make something happen for a *person* wait for that too: a Connect block rings agents only
after the caller has heard everything before the waiting music.

**Statuses of a call** (`status`): `QUEUED` → `DIALING` → `RINGING` (outgoing only) → `IN_SCRIPT` → `WAITING`
(waiting room) → `CONNECTING` → `LIVE` (with an agent) → `ENDED`. After it ends, `result` says how it went
(`HANDLED_BY_SCRIPT`, `HANDLED_BY_AGENT`, `HANDLED_BY_AI`, `VOICEMAIL`, `MISSED`, `NO_ANSWER`, `BUSY`, `DECLINED`…),
`endReason` why it ended and `endedBy` who ended it. All values: [Part 4](4-reference.md#calls).

**Guards that protect every account** (account `limits`, changeable with `PATCH /v1/accounts/{id}`):

| Limit | Default | What happens |
|---|---|---|
| `maxCallMinutes` | 15 | The call is ended (`MAX_CALL_LENGTH`) |
| `maxWaitSeconds` | 300 | Longest wait in a waiting room, whatever the flow says |
| `callerCallsPer10Min` | 5 | The same caller calling more often is refused (`REJECTED`, `CALLER_LIMIT`) |
| `otpPerDestinationPer10Min` | 3 | More OTP calls to one number → refused |
| `voicemailMaxSeconds` | 120 | Longest voicemail |
| `maxBlockVisits` | 25 | Blocks run without any input from the caller before the call is ended (`LOOP_GUARD`) — stops endless loops |

---

## 1.5 Calling people (call-outs)

The engine places calls for an account and plays an **Outgoing** flow when the person answers.

| Way | Endpoint | Use |
|---|---|---|
| **Quick call** | `POST /v1/calls` | "Your order is ready" to 1–1000 people, now. Created and started in one request |
| **Broadcast** | `POST /v1/callouts` `kind: BROADCAST` | The same, but built in steps (create → add people → start), optionally at `startsAt` |
| **Campaign** | `POST /v1/callouts` `kind: CAMPAIGN` | Calling a list over days: a daily window (`windowFrom`/`windowTo`), week days, pace per minute, up to 5 tries, a fallback App action (e.g. SMS) after the last failed try |
| **Scheduled callback** | made by a caller during a call (Schedule call block or AI `schedule_call`) | "Nipigie baada ya saa moja": a one-person call-out with ref `schedule:<flow>:<number>`; the client app sees it in `GET /v1/callouts` and the `call.scheduled` webhook |
| **OTP** | `POST /v1/otp` | Read a code to a number |

Each person on a call-out is a **recipient** with their own `vars` (e.g. `{"name": "Asha", "amount": "45000"}`) that
the flow says as `{name}`, `{amount}`.

**Rules the engine applies to every call-out** (the client app doesn't need to):
- **Quiet hours** (account `quietHours`, default 21:00–07:00): nobody is called inside them; calls wait for the end.
- **Opt-outs**: a number on the account's opt-out list is skipped (`SKIPPED`), checked again just before dialling.
- **Retries**: busy, no answer, unreachable or failed → tried again after `retryGapSec` (≥ 300 s) up to `maxTries`
  (quick call ≤ 3, campaign ≤ 5). **Declined** (the person rejected the call) is never retried.
- **Pace**: campaigns dial at most `pacePerMin`.

Call-out statuses: `DRAFT` → `SCHEDULED` → `RUNNING` ⇄ `PAUSED` → `COMPLETED` / `STOPPED` / `CANCELLED`.
Recipient: `PENDING` → `DIALING` → `DONE` (with a `result`) or `SKIPPED`.

---

## 1.6 A call flow's life

```
PUT  …/scripts/{ref}                    create the flow (name, INCOMING or OUTGOING); direction never changes
POST …/scripts/{ref}/versions           push version 1, 2, 3 … — each version is frozen once stored
         │  the engine checks it (Part 5 §5.9): errors → 422 with the list, nothing stored
         │                                       warnings → stored, warnings returned
         ▼
      runnable?  ── no (you pushed runnable:false: it has risky blocks and waits for approval)
         │              POST …/versions/{n}/runnable  after the client app's approval step
         ▼ yes
PUT  /v1/numbers/{id}/script            put the version on a number (Incoming)     — or —
POST /v1/calls · /v1/callouts           use it for calls out (Outgoing)
POST …/versions/{n}/revoke              stop a version for good: a number still playing it refuses calls,
                                        call-outs using it pause (VERSION_REVOKED) — move them first
DELETE …/scripts/{ref}                  delete the flow (refused while anything uses it; archived if it ever ran)
```

- **Versions never change.** To change a flow, push the next version and move the number to it. Calls keep pointing
  at the exact version they ran, so reports stay true.
- **Pushing the same version number with the same document again** is safe: `200 "Version already stored"`.
- **Approval is the client app's policy.** Some blocks are risky: `collect` (callers' digits), `request` (calls a URL),
  `ai_agent`, and App actions the client app registered as risky (e.g. pay). The engine lists them in `riskyBlocks`
  when a version is checked or stored, and trusts the `runnable` flag it is given: push such a version with
  `runnable: false`, let the business's reviewer approve it in the client app, then `POST …/versions/{n}/runnable`.
  A not-runnable version can't be put on a number. A call-out may already use it: it is saved and waits
  (`waitingApproval: true`), then starts by itself once the version is approved.
- **`testOnly` versions** are for test calls only and never become runnable.
- **Reading a flow back**: `GET …/scripts/{ref}/versions/{n}` returns the full document. Secrets stored in Send request
  blocks come back as `"(hidden)"`.
- **Layout** (block positions on the client app's canvas) belongs to the client app: the engine drops a `layout` key on push.
- **Texts are turned into speech when the version is stored**, so the first caller doesn't wait.

---

## 1.7 Agents in the browser

```
Client app (once): POST departments {name, strategy, ringTimeoutSec…}  ·  PUT agents/{ref} {displayName}
                 PUT departments/{id}/members {agentRefs: [...]}      (list order = round-robin order)

Agent's browser:
  "Start shift"  → client app backend → POST agents/{ref}/lobby-token → {url, room, token}
                 → browser joins the lobby room (LiveKit JS SDK, data only, no mic)
                 → client app backend → PUT agents/{ref}/status {AVAILABLE}
  A caller reaches a Connect block → hears "tunakuunganisha…", then music
                 → engine sends {type:"ring", callId, from, department} into the lobby
  "Accept"       → client app backend → POST /v1/calls/{callId}/accept {agentRef} → {url, room, token}
                 → browser joins the call room with the microphone on → they talk
  "Hold" / "Transfer" / "Hang up" → POST /v1/calls/{callId}/hold · /transfer · /hangup {agentRef}
  Call ends      → agent is WRAP_UP for the department's wrapUpSec, then AVAILABLE (or POST …/ready)
```

**Agent statuses**: `OFFLINE` → `AVAILABLE` → `RINGING` → `BUSY` → `WRAP_UP` → `AVAILABLE`; `AWAY` = on a break
(online, no calls). One call at a time per agent. Missing `maxMissedBeforeAway` rings in a row (default 5) moves the
agent to `AWAY`. Every change is sent as an `agent.status` webhook (for a wallboard).

**Ring modes** (per number, `PUT /v1/numbers/{id}/ring-mode`): `BROWSER` (agents' browsers), `MOBILE` (agents'
mobiles), `BROWSER_AND_MOBILE`.

The browser needs HTTPS (or `localhost`) for the microphone, and the "Start shift" click is what allows the ring
sound. Details of every signal: [Part 3](3-webhooks.md#33-agent-desk-signals).

---

## 1.8 Webhooks

Every event is a signed `POST` to the client app's webhook URL:

```
POST https://app.example.co.tz/hooks/ringmaster
Content-Type: application/json
X-Ringmaster-Event: call.ended
X-Ringmaster-Delivery: 5b0c7f8e-…            (same as the body's id; use it to drop repeats)
X-Ringmaster-Timestamp: 1794588244
X-Ringmaster-Signature: 9f2c…                 hex HMAC-SHA256(secret, "<timestamp>.<raw body>")

{ "id": "5b0c7f8e-…", "type": "call.ended", "createdAt": "2026-11-13T16:44:04Z", "data": { … } }
```

- Answer **2xx quickly** (do the work after). Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h,
  then every 12 h until 24 h have passed; then the event is marked failed.
- Events are written in the same database step as the change they describe, so none is lost; a repeat can arrive —
  drop it by `id`.
- Check the signature on the **raw body** before parsing, and refuse timestamps more than 5 minutes old.

All events and their data: [Part 3](3-webhooks.md).

---

## 1.9 App actions (the client app does something during a call)

Ringmaster never sends SMS, takes payments or tags contacts itself. The client app **registers** what it can do; a
flow's **App action** block asks for it; the client app does it and answers.

```
PUT /v1/actions  [ {name: "pay", label: "Pay now", params: {...}, wait: true, outcomes: ["paid","failed"], timeoutSec: 120}, … ]

Caller in a flow reaches  "pay_1": {type: "app_action", action: "pay", params: {amount: "{amount}"}, x: {paid: …, failed: …, timeout: …}}
   engine ─► webhook action.requested {actionId, name: "pay", callId, params: {amount: "45000", phone: "+2557…"}, wait: true, timeoutAt}
   caller waits (music, "Thibitisha malipo kwenye simu yako")
   client app pushes M-Pesa … PIN entered …
   client app ─► POST /v1/calls/{callId}/action-result {actionId, outcome: "paid", data: {receipt: "QK7…"}}
   engine: the flow goes on through the "paid" exit; {action.pay_1.receipt} can be said
```

`wait: false` actions (send SMS, tag a contact) are fired and the call goes on at once.

---

## 1.10 Recordings, transcripts, summaries

| Recorded | When |
|---|---|
| **Voicemail** | Always, when a flow reaches a Voicemail block (up to `voicemailMaxSeconds`) |
| **Live parts** | Talk with an agent, when the account's `recording.mode` is `LIVE_PARTS` (default) and the department / block allows it. Callers hear the `noticeText` first |
| **Callers' spoken answers** | Short clips used for speech recognition; not kept as recordings |

After a recording: `recording.ready` webhook → `GET /v1/calls/{id}/recording` (MP3). If `recording.transcribe` is on
(default), a transcript and a short summary follow: `summary.ready` → `GET /v1/calls/{id}/transcript`.
Recordings are encrypted at rest and deleted after the account's `retentionDays` (default 90).

---

## 1.11 Costs

Every call carries `providerCost`: what it cost Ringmaster in phone minutes and AI, line by line.

```json
"providerCost": { "tzs": 68.61, "usd": 0.000334, "items": [
  { "provider": "AT", "tzs": 68.61, "lines": [
      { "item": "AT_OUTBOUND", "qty": 1, "unit": "MINUTE", "rate": 60, "currency": "TZS", "amount": 60, "source": "RATE" },
      { "item": "AT_SIP_TRUNK", "qty": 1, "unit": "MINUTE", "rate": 8.61, "currency": "TZS", "amount": 8.61, "source": "RATE" } ] },
  { "provider": "OPENAI", "usd": 0.000334, "lines": [
      { "item": "OPENAI_TRANSCRIBE", "qty": 0.0742, "unit": "MINUTE", "rate": 0.0045, "currency": "USD", "amount": 0.0003339, "source": "MEASURED", "model": "gpt-transcribe" } ] } ] }
```
The client app decides what to charge its customers; these are inputs, not prices.

---

## 1.12 Formats

| What | Format |
|---|---|
| Phone numbers | E.164 with `+`: `+255745051250` |
| IDs | UUID strings; your own ids go in `externalRef` (accounts), `ref` (scripts, agents) |
| `ref` / `externalRef` | Your text id, up to 120 characters; letters, digits, `-`, `_` recommended |
| Times in responses | UTC ISO 8601 (`2026-11-13T14:41:00.802627Z`) |
| Times inside an account | `HH:mm` in the account's `timezone` (opening hours, quiet hours, campaign windows) |
| Languages | `sw` (Kiswahili, default), `en` |
| Lists | `GET /v1/calls` pages with `page` (from 0) and `size` (default 50) → `{calls, page, size, total}`; other lists return everything |
| Money in costs | TZS with 2 decimals, USD with up to 7 |

# Part 2B — Call flows, media, knowledge base

[← Contents](README.md) · Previous: [Part 2A — Setup](2a-setup.md) · Next: [Part 2C — Calls](2c-calls.md)

A **call flow** (API name: *script*) is identified by its `ref` inside an account (`orders`, `account-balance`). Its
content lives in numbered **versions** that never change once stored. How to write the document itself (blocks,
settings, exits, messages, variables): [Part 5](5-flow-reference.md). Life of a flow: [Part 1 §1.6](1-how-it-works.md#16-a-call-flows-life).

All endpoints: 🔒 Protected · Bearer API key. `{accountId}` must be one of the client app's accounts (else `404`).

---

# D. Call flows (scripts)

## D1. List flows
**Purpose**: Every flow of an account with all its versions (deleted flows are not listed).

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/scripts`

**Success Response**: `data` = array of flows, sorted by `ref`, each as in [D3](#d3-get-a-flow).

---

## D2. Create or rename a flow
**Purpose**: Make the flow (before its first version), or change its display name. Calling it again with the same
`ref` only renames.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/scripts/{ref}`

**Request JSON Sample**:
```json
{ "name": "Account balance", "direction": "INCOMING" }
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `{ref}` (path) | string | Yes | The client app's id for the flow | max 120; letters, digits, `-`, `_` |
| `name` | string | Yes | Display name | not blank |
| `direction` | string | Yes | `INCOMING` (plays on a number) · `OUTGOING` (plays when a called person answers) | fixed once created |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script saved",
  "action": null,
  "action_time": "2026-11-13T20:00:15.876328395",
  "data": {
    "id": "d3c063a6-1d76-415e-b623-c5769c1f6e69",
    "ref": "docs-sample",
    "name": "Account balance",
    "direction": "INCOMING",
    "versions": []
  }
}
```

**Errors**: `409 DIRECTION_FIXED` "A script keeps its direction; duplicate it to use the other direction".
A flow that was deleted after taking calls (archived) comes back with its old versions, which stay stopped.

---

## D3. Get a flow
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/scripts/{ref}`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script",
  "action": null,
  "action_time": "2026-11-13T20:00:42.882898745",
  "data": {
    "id": "d3c063a6-1d76-415e-b623-c5769c1f6e69",
    "ref": "docs-sample",
    "name": "Account balance",
    "direction": "INCOMING",
    "versions": [
      {
        "versionId": "65a926f5-9fdc-49ed-95a7-93a70e562460",
        "version": 1,
        "runnable": true,
        "testOnly": false,
        "revoked": false,
        "vars": [],
        "texts": ["Bonyeza namba yako ya akaunti.", "Asante, kwaheri."],
        "riskyBlocks": ["acct"],
        "warnings": [],
        "createdAt": "2026-11-13T17:00:15.991897Z"
      },
      {
        "versionId": "15baa310-3997-4d14-a66e-a9c024f909ec",
        "version": 2,
        "runnable": false,
        "testOnly": false,
        "revoked": false,
        "vars": [],
        "texts": ["Bonyeza namba yako ya akaunti.", "Asante, kwaheri."],
        "riskyBlocks": ["acct"],
        "warnings": [],
        "createdAt": "2026-11-13T17:00:42.855216Z"
      }
    ]
  }
}
```

| Field | Description |
|-------|-------------|
| `versions[].versionId` | What numbers (`PUT /v1/numbers/{id}/script`) and call-outs (`scriptVersionId`) point at |
| `versions[].version` | The number the client app gave it |
| `runnable` | May run: be put on a number or used for calls out |
| `testOnly` | For test calls only; never runnable |
| `revoked` | Stopped for good |
| `vars` | Variables a call-out must give each person (`name`, `amount`); system variables are not listed |
| `texts` | Fixed sentences, already turned into audio. Sentences with variables (`Akaunti {input.account}…`) are spoken during the call |
| `riskyBlocks` | Blocks that make the version need approval ([D7](#d7-approve-a-version-make-it-runnable)) |
| `warnings` | Only in the answer to a push ([D5](#d5-push-a-version)); empty when read later |

**Errors**: `404 NOT_FOUND` "Script not found: docs-sample".

---

## D4. Delete a flow
**Purpose**: Remove a flow. Refused while anything uses it. A flow that never ran is erased; a flow that ran calls is
**archived**: hidden from lists, all its versions stopped, its call history kept. Creating the same `ref` again
([D2](#d2-create-or-rename-a-flow)) brings it back.

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/scripts/{ref}`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script deleted",
  "action": null,
  "action_time": "2026-11-13T20:00:43.15694553",
  "data": { "ref": "docs-sample", "outcome": "DELETED" }
}
```

| Field | Description |
|-------|-------------|
| `outcome` | `DELETED` (never ran: gone) · `ARCHIVED` (ran calls: hidden, history kept) |

**Error Response JSON Sample** (still in use):
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "The script is still in use: move these to another script first",
  "code": "SCRIPT_IN_USE",
  "action_time": "2026-11-13T18:09:38.582694597",
  "data": {
    "numbers": ["+255677000003"],
    "callouts": ["Callback +255745051250"],
    "scripts": ["account v4"]
  }
}
```

| `data` key | Means |
|---|---|
| `numbers` | Numbers playing one of its versions → put another flow on them ([2A C7](2a-setup.md#c7-put-a-flow-on-a-number)) |
| `callouts` | Call-outs not finished (draft, scheduled, running, paused) using it → cancel or let them finish |
| `scripts` | Live versions of other flows that transfer to it or schedule callbacks with it → push new versions without that |

---

## D5. Push a version
**Purpose**: Store a new version. The engine checks it first ([Part 5 §5.9](5-flow-reference.md#59-check-codes)): errors → nothing stored.
Fixed sentences are turned into audio straight away, and an AI Agent's knowledge is prepared in the background.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions`

**Request JSON Sample**:
```json
{
  "version": 2,
  "runnable": false,
  "definition": {
    "direction": "INCOMING",
    "start": { "call": "acct" },
    "blocks": {
      "acct": { "type": "collect", "msg": { "src": "text", "text": "Bonyeza namba yako ya akaunti." },
                "saveAs": "account", "minDigits": 4, "maxDigits": 4,
                "x": { "ok": "read", "failed": "bye", "later": "bye" } },
      "read": { "type": "say", "msg": { "src": "text", "text": "Akaunti {input.account} ina salio la shilingi elfu kumi." },
                "x": { "next": "bye" } },
      "bye":  { "type": "goodbye", "msg": { "src": "text", "text": "Asante, kwaheri." } }
    }
  }
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `version` | integer | Yes | The version number; the client app counts | ≥ 1, unique per flow |
| `definition` | object | Yes | The flow document | must pass the check; `direction` must match the flow; a `layout` key is dropped |
| `runnable` | boolean | No | `true` = may run at once. Push `false` when the version has `riskyBlocks` and waits for approval | default `false` |
| `testOnly` | boolean | No | For test calls only (never runnable) | default `false` |

**Success Response JSON Sample** (`201 Created`):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Version stored",
  "action": null,
  "action_time": "2026-11-13T20:00:16.018139374",
  "data": {
    "versionId": "65a926f5-9fdc-49ed-95a7-93a70e562460",
    "version": 1,
    "runnable": true,
    "testOnly": false,
    "revoked": false,
    "vars": [],
    "texts": ["Bonyeza namba yako ya akaunti.", "Asante, kwaheri."],
    "riskyBlocks": ["acct"],
    "warnings": [
      { "level": "WARNING", "block": "acct", "code": "EXIT_UNCONNECTED", "message": "Exit 'later' is not connected: the call ends there" }
    ],
    "createdAt": "2026-11-13T17:00:15.991897467Z"
  }
}
```

**Sending the same version again** with the same document (e.g. a retry after a timeout) is safe: `200 OK`,
`message` "Version already stored", same `data`. Secrets in Send request blocks are encrypted before storing and do not
change the comparison.

**Error Response JSON Samples**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Script version is not valid",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:00:16.178937467",
  "data": [
    { "level": "ERROR", "block": "a", "code": "EXIT_TARGET_MISSING", "message": "Exit 'next' points to a missing block: zz" }
  ]
}
```
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "Version 1 already exists with a different definition; versions are immutable",
  "code": "VERSION_EXISTS",
  "action_time": "2026-11-13T20:00:16.123223371",
  "data": null
}
```
Also `400` "definition must be a JSON object" · `404` "Script not found" (create it with [D2](#d2-create-or-rename-a-flow) first) ·
`403 FEATURE_OFF` "AI is not enabled for this client app; these blocks use it: ai" (an AI Agent block or smart speech
matching without the `AI` feature). Check codes: [Part 4](4-reference.md#script-check-codes).

---

## D6. Get a version (with its blocks)
**Purpose**: Read a version back: to show it, edit it into the next version, or see what a call ran.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script version",
  "action": null,
  "action_time": "2026-11-13T20:00:42.918636988",
  "data": {
    "ref": "docs-sample",
    "name": "Account balance",
    "direction": "INCOMING",
    "version": {
      "versionId": "15baa310-3997-4d14-a66e-a9c024f909ec",
      "version": 2,
      "runnable": false,
      "testOnly": false,
      "revoked": false,
      "vars": [],
      "texts": ["Bonyeza namba yako ya akaunti.", "Asante, kwaheri."],
      "riskyBlocks": ["acct"],
      "warnings": [],
      "createdAt": "2026-11-13T17:00:42.855216Z"
    },
    "definition": {
      "direction": "INCOMING",
      "start": { "call": "acct" },
      "blocks": {
        "acct": { "type": "collect", "msg": { "src": "text", "text": "Bonyeza namba yako ya akaunti." },
                  "saveAs": "account", "minDigits": 4, "maxDigits": 4,
                  "x": { "ok": "read", "later": "bye", "failed": "bye" } },
        "read": { "type": "say", "msg": { "src": "text", "text": "Akaunti {input.account} ina salio la shilingi elfu kumi." },
                  "x": { "next": "bye" } },
        "bye":  { "type": "goodbye", "msg": { "src": "text", "text": "Asante, kwaheri." } }
      }
    }
  }
}
```

| Field | Description |
|-------|-------------|
| `version` | Same as in [D3](#d3-get-a-flow) |
| `definition` | The stored document. Keys come back sorted. Secrets of Send request blocks (`auth.token`, `auth.password`, `auth.value`) come back as `"(hidden)"`. Pushed back unchanged in a new version, `"(hidden)"` keeps the stored secret of the same block |

**Errors**: `404` "Version not found: 9".

---

## D7. Approve a version (make it runnable)
**Purpose**: After the client app's reviewer approves a version pushed with `runnable: false`. Call-outs that were
waiting for it start by themselves.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}/runnable`

**Success Response**: `200`, `message` "Version is runnable", `data` = the version (`runnable: true`).

**Errors**:
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "A revoked version cannot become runnable",
  "code": "VERSION_REVOKED",
  "action_time": "2026-11-13T20:00:43.054366231",
  "data": null
}
```

---

## D8. Stop a version (revoke)
**Purpose**: Stop a version for good (it was wrong, or the business rejected it). It can never run again.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}/revoke`

**Success Response**: `200`, `message` "Version revoked", `data` = the version (`revoked: true`, `runnable: false`).
Revoking twice is harmless.

> **Move things off it first.** A number still playing a revoked version refuses calls (callers hear that the
> service is unavailable), and running call-outs using it are paused with `pauseReason: "VERSION_REVOKED"`.

---

## D9. Block stats of a version
**Purpose**: Where callers go and where they get stuck, per block — for the flow editor's heat map.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}/stats`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `from` | date | No | First day, `YYYY-MM-DD` (account's time zone) | 6 days before `to` |
| `to` | date | No | Last day | today |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Block stats",
  "action": null,
  "action_time": "2026-11-13T20:01:13.597312525",
  "data": [
    { "key": "acct", "values": { "visits": 5, "hangups": 0, "noInput": 0, "retries": 2,
                                  "reqOk": 0, "reqFail": 0, "reqTimeout": 0, "reqMsP50": null, "gaveUp": 0 } },
    { "key": "when", "values": { "visits": 4, "hangups": 0, "noInput": 0, "retries": 1,
                                  "reqOk": 0, "reqFail": 0, "reqTimeout": 0, "reqMsP50": null, "gaveUp": 0 } }
  ]
}
```

| Field | Description |
|-------|-------------|
| `key` | Block id |
| `visits` | Times a call entered the block |
| `hangups` | Callers who hung up in it |
| `noInput` | Times nobody answered its question |
| `retries` | Questions asked again (wrong or no answer) |
| `reqOk` / `reqFail` / `reqTimeout` / `reqMsP50` | Send request blocks: results and median time in ms |
| `gaveUp` | Waiting rooms the caller left after the give-up time |

Stats are rolled up a few minutes after calls end.

---

# E. Media (audio files)

Recorded greetings, menu prompts and hold music. Upload once, then use the id in a flow message:
`{"src": "upload", "media": "<mediaId>"}` (a waiting room's `music` takes the id directly).

## E1. Upload audio
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/media` · `multipart/form-data`

| Part / query | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `file` (part) | file | Yes | MP3, WAV, OGG, M4A… | an audio file; ≤ 10 MB; ≤ 5 minutes |
| `name` (query) | string | No | Display name | default: the file name |
| `lang` (query) | string | No | `sw` · `en` (for the library filter) | |

```bash
curl -X POST "$BASE/accounts/$ACCOUNT/media?name=Salamu%20ya%20duka&lang=sw" \
  -H "Authorization: Bearer $RINGMASTER_KEY" -F "file=@salamu.mp3"
```

**Success Response**: `202 Accepted`, `message` "Audio received; processing", `data` = the media item with `status: "PENDING"`. The engine converts it for the
phone line in the background, then sends **`media.ready`** (`{mediaId, durationMs}`) or **`media.failed`**
(`{mediaId, error}`) — [Part 3](3-webhooks.md). Use the id in a flow only once it is `READY`.

**Errors**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Upload rejected",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:01:13.708314472",
  "data": [ { "field": "file", "message": "Not an audio file" } ]
}
```
Other messages: "File is larger than 10 MB" · "Only audio files are accepted" · "Audio is longer than 5 minutes".
`409 STORAGE_QUOTA` — the account has used its 500 MB of audio.

---

## E2. List audio
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/media`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Audio library",
  "action": null,
  "action_time": "2026-11-13T20:01:13.551808448",
  "data": [
    {
      "id": "eebb855e-2a03-4af7-a8c8-301f85877308",
      "name": "Muziki wa kusubiri",
      "kind": "UPLOAD",
      "status": "READY",
      "durationMs": 24192,
      "lang": "sw",
      "error": null,
      "previewUrl": "https://<ringmaster-host>/media/eebb855e-…/eda4fdcb….mp3?t=f702ec7bb40e96291e689462aa8319c9",
      "createdAt": "2026-11-13T09:46:03.541625Z"
    }
  ]
}
```

| Field | Description |
|-------|-------------|
| `kind` | `UPLOAD` (uploaded) · `RECORDING` (recorded in the editor) · `SYSTEM` (engine's own: beeps, default hold music) |
| `status` | `PENDING` → `PROCESSING` → `READY` · `FAILED` (see `error`) |
| `durationMs` | Length once ready |
| `previewUrl` | Signed link to play it in a browser (`<audio src>`), no API key needed; it expires — read it again rather than storing it |

---

## E3. Get one audio item
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/media/{mediaId}`

**Success Response**: one item as in [E2](#e2-list-audio). **Errors**: `404` "Audio not found".

---

## E4. Delete audio
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/media/{mediaId}`

**Success Response**: `200`, `message` "Audio deleted", `data` null.

**Errors**: `404` "Audio not found" · `409 MEDIA_IN_USE` "Audio is used by live script versions", `data: {"usedBy": ["orders v3", "reception v2"]}`
— push versions without it (or revoke those) first.

---

# F. Knowledge base (for AI Agents)

Documents an AI Agent may answer from. Upload them, then list their ids in the AI Agent block's `kb.docs`
([Part 5 §5.7](5-flow-reference.md#57-the-ai-agent)). Each flow version builds its own searchable copy when it is stored, so a version keeps
answering the same way even if documents change later; push a new version to use new documents.

## F1. Upload a document
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/kb-docs` · `multipart/form-data`

| Part | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `file` | file | Yes | `.txt`, `.md` or `.pdf` with text | ≤ 10 MB; text is cut at 200,000 characters; scanned PDFs (images) have no text |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Document stored",
  "action": null,
  "action_time": "2026-11-13T20:05:00.000000000",
  "data": { "id": "7c2e…", "name": "bei_2026.pdf", "contentType": "application/pdf", "chars": 18420,
            "status": "READY", "createdAt": "2026-11-13T17:05:00Z" }
}
```

**Errors** (`400 BAD_REQUEST`): "A file is required" · "Documents are limited to 10 MB" · "Upload a .txt, .md or .pdf document" ·
"No text found in this document (scanned PDFs are not read)".

---

## F2. List documents
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/kb-docs`

**Success Response**: `data` = array of documents as in [F1](#f1-upload-a-document).

## F3. Delete a document
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/kb-docs/{documentId}`

**Success Response**: `200`, `message` "Document removed", `data` null. Versions already stored keep their copy of its
text. **Errors**: `404` "Document not found".

# Part 2C — Calls, call-outs, OTP, reports

[← Contents](README.md) · Previous: [Part 2B — Call flows](2b-flows.md) · Next: [Part 2D — Agents](2d-agents.md)

All endpoints: 🔒 Protected · Bearer API key. A call or call-out of another client app gives `404`.

---

# G. Calls and their reports

Every call (in or out) is one record with a summary, a timeline of what happened, and — when something was recorded —
audio and a transcript. The same summary arrives in the `call.ended` webhook ([Part 3](3-webhooks.md)).

## G1. List calls
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `accountId` | uuid | No | One account (without it: all the client app's calls) | |
| `from` / `to` | ISO instant | No | Started between (`2026-11-13T00:00:00Z`) | |
| `type` | string | No | `INCOMING` · `BROADCAST` · `CAMPAIGN` · `OTP` · `AGENT_CALL` · `TEST` | |
| `result` | string | No | e.g. `MISSED`, `VOICEMAIL` ([Part 4](4-reference.md#calls)) | |
| `status` | string | No | e.g. `LIVE` for calls in progress | |
| `number` | string | No | Calls from or to this E.164 number | |
| `calloutId` | uuid | No | Calls placed by one call-out | |
| `page` / `size` | integer | No | Page (from 0) and page size | 0 / 50 |

**Success Response JSON Sample** (one call shown; each item is a [G2](#g2-get-a-call) summary):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Calls",
  "action": null,
  "action_time": "2026-11-13T20:05:32.490000000",
  "data": {
    "calls": [ { "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96", "type": "INCOMING", "result": "VOICEMAIL", "…": "…" } ],
    "page": 0,
    "size": 50,
    "total": 65
  }
}
```
Newest first. A wrong value for a filter → `400 Invalid value for parameter: <name>`.

---

## G2. Get a call
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/{callId}`

**Success Response JSON Sample** (a caller left a voicemail while waiting for an agent):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Call",
  "action": null,
  "action_time": "2026-11-13T20:05:32.497509178",
  "data": {
    "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96",
    "accountRef": "softphone",
    "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
    "type": "INCOMING",
    "direction": "INBOUND",
    "from": "+255745051250",
    "to": "+255677000001",
    "status": "ENDED",
    "result": "VOICEMAIL",
    "endReason": "SCRIPT_END",
    "endedBy": "SYSTEM",
    "handledBy": "VOICEMAIL",
    "startedAt": "2026-11-13T16:45:27.909648Z",
    "answeredAt": "2026-11-13T16:45:27.909648Z",
    "endedAt": "2026-11-13T16:46:20.533012Z",
    "durations": { "ringSeconds": null, "totalSeconds": 53, "scriptSeconds": 16, "waitSeconds": 35,
                   "talkSeconds": 0, "holdSeconds": 0, "aiSeconds": 0 },
    "keys": null,
    "lastBlock": "vm",
    "outcomes": [],
    "usage": { "pstnSeconds": 53, "trunkSeconds": 53, "waitSeconds": 35, "aiSeconds": 0, "ttsChars": 0, "sttSeconds": 0 },
    "providerCost": {
      "tzs": 25.61,
      "usd": 0.000893,
      "items": [
        { "provider": "AT", "tzs": 25.61, "usd": 0.0, "lines": [
          { "item": "AT_INBOUND", "qty": 1, "unit": "MINUTE", "rate": 17, "currency": "TZS", "amount": 17, "source": "RATE", "model": null },
          { "item": "AT_SIP_TRUNK", "qty": 1, "unit": "MINUTE", "rate": 8.61, "currency": "TZS", "amount": 8.61, "source": "RATE", "model": null } ] },
        { "provider": "OPENAI", "tzs": 0.0, "usd": 0.000893, "lines": [
          { "item": "OPENAI_CHAT_IN", "qty": 474, "unit": "TOKEN", "rate": 1e-07, "currency": "USD", "amount": 4.74e-05, "source": "OPENAI", "model": "gpt-6-luna" },
          { "item": "OPENAI_CHAT_OUT", "qty": 42, "unit": "TOKEN", "rate": 5e-07, "currency": "USD", "amount": 2.1e-05, "source": "OPENAI", "model": "gpt-6-luna" },
          { "item": "OPENAI_TRANSCRIBE", "qty": 0.1833, "unit": "MINUTE", "rate": 0.0045, "currency": "USD", "amount": 0.00082485, "source": "MEASURED", "model": "gpt-transcribe" } ] }
      ]
    },
    "recorded": true,
    "script": { "versionId": "e183c7f1-3137-4848-95c1-11a7e8bef17a", "version": 1, "ref": "desk-test" },
    "calloutId": null,
    "hangupCause": null,
    "sipStatus": null,
    "meta": null
  }
}
```

| Field | Description |
|-------|-------------|
| `type` / `direction` | What kind of call ([Part 4](4-reference.md#calls)); `INBOUND` = someone called us |
| `from` / `to` | E.164 numbers. Incoming: caller → our number. Outgoing: our number → person |
| `status` | Live state; `ENDED` once over |
| `result` | How it went, set when it ends: `HANDLED_BY_SCRIPT` · `HANDLED_BY_AGENT` · `HANDLED_BY_AI` · `VOICEMAIL` · `MISSED` · `ABANDONED` · `HUNG_UP_EARLY` · `REJECTED`; outgoing: `ANSWERED` · `NO_ANSWER` · `BUSY` · `DECLINED` · `UNREACHABLE` · `INVALID_NUMBER` · `FAILED` · `NOT_DIALED` |
| `endReason` / `endedBy` | Why and by whom it ended (`CALLER_HUNG_UP` / `CALLER`, `SCRIPT_END` / `SYSTEM`…) |
| `handledBy` | Who dealt with the caller in the end: `SCRIPT` · `AGENT` · `AI` · `VOICEMAIL` |
| `answeredAt` | Incoming: when the engine picked up (= start). Outgoing: when the person answered; `null` if never |
| `durations` (s) | `ringSeconds` (outgoing, before answer) · `totalSeconds` · `scriptSeconds` (in blocks) · `waitSeconds` (waiting room) · `talkSeconds` (with an agent) · `holdSeconds` · `aiSeconds` |
| `keys` | Every key the caller pressed, in order (`"1234"`) |
| `lastBlock` | The block the call ended in |
| `outcomes` | Tags the flow set on the way (`callback_scheduled`, `opted_out`, an App action's outcome…) |
| `usage` | Measured units: phone seconds (`pstnSeconds`, `trunkSeconds`), `ttsChars` (new text made into audio for this call), `sttSeconds` (caller speech transcribed) |
| `providerCost` | Cost to run it, per provider and line ([Part 1 §1.11](1-how-it-works.md#111-costs)) |
| `recorded` | Something was recorded: `GET …/recording` works |
| `script` | The flow version it ran |
| `calloutId` | Set when a call-out placed it |
| `hangupCause` / `sipStatus` | Outgoing calls that failed: short network reason (`603 Decline`, `486 Busy here`) and SIP code |
| `meta` | What the client app attached (call-out `meta`, OTP `meta`, scheduled callbacks' `{said, scheduledBy}`) |

---

## G3. Timeline of a call
**Purpose**: Everything that happened, step by step: blocks entered, sentences said, keys, speech heard, rings,
transfers, recordings. For a call detail screen and for support.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/{callId}/timeline`

**Success Response JSON Sample** (first events):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Timeline",
  "action": null,
  "action_time": "2026-11-13T20:05:32.529813711",
  "data": [
    { "seq": 1, "type": "CALL_RECEIVED", "block": null, "at": "2026-11-13T16:45:27.912895Z", "offsetMs": 3,
      "data": { "to": "+255677000001", "from": "+255745051250" } },
    { "seq": 2, "type": "SAY", "block": null, "at": "2026-11-13T16:45:27.915036Z", "offsetMs": 5,
      "data": { "text": "Habari, unazungumza na huduma ya kidijitali ya Ringmaster Lab.", "voice": "marin" } },
    { "seq": 4, "type": "BLOCK_ENTER", "block": "hi", "at": "2026-11-13T16:45:27.915108Z", "offsetMs": 5,
      "data": { "type": "say" } },
    { "seq": 9, "type": "QUEUED", "block": "desk", "at": "2026-11-13T16:45:40.577Z", "offsetMs": 12668,
      "data": { "department": "Mapokezi" } },
    { "seq": 12, "type": "WAIT_OPTION", "block": "desk", "at": "2026-11-13T16:46:03.869Z", "offsetMs": 35960,
      "data": { "key": "9", "exit": "vm" } },
    { "seq": 16, "type": "VOICEMAIL", "block": "vm", "at": "2026-11-13T16:46:17.441Z", "offsetMs": 49532,
      "data": { "recorded": true } }
  ]
}
```

| Field | Description |
|-------|-------------|
| `seq` | Order (gaps are possible) |
| `type` | What happened ([Part 4 — timeline events](4-reference.md#timeline-events)) |
| `block` | The block it happened in, if any |
| `offsetMs` | Milliseconds since the call started |
| `data` | Details for that type (texts said, keys, speech, department, exit taken…) |

A `SAY` event is written when the sentence is queued to play; the audio itself follows in order.

---

## G4. Recording of a call
**Purpose**: The MP3 of the call's latest recording (a voicemail, or the recorded part with an agent).

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/{callId}/recording`

**Success Response**: `200`, `Content-Type: audio/mpeg`, the audio bytes (not JSON). Stream it to the client app's
users through its own backend (the API key must not reach a browser).

**Errors** (JSON): `404` "No recording for this call" — nothing recorded, or not ready yet (wait for
`recording.ready`); `404` "Recording file missing" — deleted after `retentionDays`.

---

## G5. Transcript and summary
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/{callId}/transcript`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Transcript",
  "action": null,
  "action_time": "2026-11-13T20:05:32.551771069",
  "data": {
    "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96",
    "status": "READY",
    "language": "sw",
    "text": "Huu ndio ujumbe wangu, na shukrani sana.\n",
    "summary": "Mpigaji amesema huo ndio ujumbe wake na ametoa shukrani nyingi.",
    "sentiment": "positive",
    "unanswered": [],
    "fields": {},
    "model": "gpt-6-luna",
    "updatedAt": "2026-11-13T16:46:27.176884Z"
  }
}
```

| Field | Description |
|-------|-------------|
| `status` | `READY` · `FAILED` (transcription failed; `text` is empty) |
| `language` | Detected language (`sw`, `en`) |
| `text` | What was said |
| `summary` | One or two sentences, in the call's language |
| `sentiment` | `positive` · `neutral` · `negative` |
| `unanswered` | Questions the caller asked that the AI could not answer (for the knowledge base) |
| `fields` | Values the flow asked the summary to pick out (e.g. `{"amount": "45000"}`), with fixed English keys |

Ready when the `summary.ready` webhook arrives. Before that, and when nothing was recorded or `recording.transcribe`
is off: `404` "No transcript for this call".

---

## G6. Export calls (CSV)
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/export.csv`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `accountId` | uuid | Yes | The account | |
| `from` / `to` | date | No | Days `YYYY-MM-DD`, account time; at most 400 days | the last 7 days |

**Success Response**: `200`, `text/csv` attachment, up to 100,000 rows, oldest first. Header row:
```
callId,type,direction,fromE164,toE164,status,result,endReason,endedBy,handledBy,startedAt,answeredAt,endedAt,totalSeconds,waitSeconds,talkSeconds,aiSeconds,pstnSeconds,trunkSeconds,ttsChars,sttSeconds,hangupCause,sipStatus,keys,lastBlock,calloutId,agentRef,outcomes,meta
```

---

## G7. Agent calls a number (click to call)
**Purpose**: An agent calls a customer from the browser, from one of the account's numbers. The engine dials; the
agent joins the call room with the token returned and hears it ring.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/agent`

**Request JSON Sample**:
```json
{
  "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
  "numberId": "8a8d9c13-bda2-4a18-8410-c5ec49ed2c66",
  "to": "+255745051250",
  "agentRef": "asha",
  "meta": { "ticket": "T-1042" }
}
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `accountId` | uuid | Yes | The account |
| `numberId` | uuid | Yes | Calling from (one of the account's numbers) |
| `to` | string | Yes | Tanzanian number |
| `agentRef` | string | Yes | The agent who calls |
| `meta` | object | No | Kept on the call, returned in its summary and webhooks |

**Success Response JSON Sample** (`201 Created`):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Calling",
  "action": null,
  "action_time": "2026-11-13T20:10:00.000000000",
  "data": {
    "callId": "6a1f…",
    "room": { "url": "wss://<livekit-host>", "room": "call-6a1f…", "identity": "agent-0ea57aeb-…", "token": "eyJhbGciOi…" }
  }
}
```
The browser joins `room` with `token` (LiveKit JS SDK, microphone on). The agent is `BUSY` until the call ends.

**Errors**: `404` "Agent not found: asha" · `409 AGENT_BUSY` · `403 CAN_CALL_REFUSED` (the client app's can-call
check said no, [Part 3](3-webhooks.md#32-the-can-call-check)) · `400` "Only Tanzanian numbers can be called".

---

# H. Call-outs (calling people)

Rules applied to every call-out (quiet hours, opt-outs, retries, pace): [Part 1 §1.5](1-how-it-works.md#15-calling-people-call-outs).
Call-outs play an **Outgoing** flow; its `vars` must be given for every person.

```
Quick call:  POST /v1/calls ─────────────────────────────── (created + people added + started in one go)
Step by step: POST /v1/callouts → POST …/{id}/recipients (one or more times) → POST …/{id}/start
              DRAFT ─start─► SCHEDULED (startsAt later) ─► RUNNING ⇄ PAUSED ─► COMPLETED
                │                  │                          │
                └──cancel──► CANCELLED ◄──cancel──┘           └──stop──► STOPPED
```

## H1. Quick call
**Purpose**: Call 1–1000 people now with one request ("Habari Asha, oda yako iko tayari").

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls`

**Request JSON Sample**:
```json
{
  "accountId": "51f815ba-e9eb-4e29-9297-3c56033d32cd",
  "scriptVersionId": "9f0d2a77-…",
  "fromNumberId": "2c4e…",
  "name": "Order ready",
  "to": [
    { "e164": "+255745051250", "vars": { "product": "Acetic Acid", "qty": "20" }, "meta": { "orderId": "ORD-88" } },
    { "e164": "+255712345678", "vars": { "product": "Zinc Dust", "qty": "5" } }
  ],
  "maxTries": 3,
  "retryGapSec": 300,
  "meta": { "batch": "evening" }
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `accountId` | uuid | Yes | The account | |
| `scriptVersionId` | uuid | Yes | A version of an **Outgoing** flow of the account | not revoked |
| `fromNumberId` | uuid | Yes | Caller ID: one of the account's numbers | |
| `to` | array | Yes | People: `{e164, vars?, meta?}` | 1–1000 |
| `to[].vars` | object | When the flow has `vars` | Values the flow says (`{product}`) | every flow var, not blank |
| `to[].meta` | object | No | Kept on the person and their calls | |
| `name` | string | No | Shown in lists | default "API broadcast" |
| `maxTries` | integer | No | Tries per person (busy / no answer / unreachable / failed) | 1–3, default 1 |
| `retryGapSec` | integer | No | Wait between tries | 300–86400, default 300 |
| `meta` | object | No | Kept on the call-out | |

**Success Response**: `201 Created`, `message` "Calling", `data` = the call-out ([H7](#h7-get-a-call-out)), already
`RUNNING` (or `SCHEDULED` in quiet hours: it waits).

**Errors**:
- `403 FEATURE_OFF` "Call-outs are not enabled for this client app" (no `CALLOUTS` feature)
- `400` "Call-outs run Outgoing scripts only" · "Script version belongs to another account"
- `409 VERSION_REVOKED`
- `422 VALIDATION_FAILED` "Some people have no value for a script variable", `data: [{"e164": "+2557…", "missing": "product"}]`
- `422 VALIDATION_FAILED` "Nobody can be called", `data` = the skipped list ([H3](#h3-add-people))

---

## H2. Create a call-out
**Purpose**: A broadcast built in steps, or a **campaign** (a list called over days, in a window, at a pace).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/callouts`

**Request JSON Sample** (campaign):
```json
{
  "accountId": "51f815ba-e9eb-4e29-9297-3c56033d32cd",
  "externalRef": "debt-reminder-2026-11",
  "name": "Debt reminder November",
  "kind": "CAMPAIGN",
  "scriptVersionId": "9f0d2a77-…",
  "fromNumberId": "2c4e…",
  "startsAt": "2026-11-14T06:00:00Z",
  "endsAt": "2026-11-21T15:00:00Z",
  "windowFrom": "09:00",
  "windowTo": "17:00",
  "days": [1, 2, 3, 4, 5],
  "maxTries": 3,
  "retryGapSec": 3600,
  "pacePerMin": 30,
  "fallback": { "action": "send_sms", "params": { "text": "Habari {name}, tafadhali lipa deni lako la {amount}." } },
  "meta": { "segment": "overdue-30" }
}
```

| Parameter | Type | Required | Description | Validation / default |
|-----------|------|----------|-------------|------------|
| `accountId` | uuid | Yes | | |
| `name` | string | Yes | | not blank |
| `kind` | string | Yes | `BROADCAST` · `CAMPAIGN` | |
| `scriptVersionId` | uuid | Yes | Outgoing flow version. May still wait for approval: the call-out shows `waitingApproval: true` and starts once approved | not revoked |
| `fromNumberId` | uuid | Yes | Caller ID | the account's number |
| `externalRef` | string | No | The client app's id | |
| `startsAt` | instant | No | Don't call before (`start` makes it `SCHEDULED`) | default: at start |
| `endsAt` | instant | No | Campaign: stop for good after this (`STOPPED`) | |
| `windowFrom` / `windowTo` | `HH:mm` | No | Campaign: only call between these each day (account time) | both or neither |
| `days` | int[] | No | Campaign: week days, 1 = Monday … 7 = Sunday | |
| `quietOverride` | boolean | No | Call even in quiet hours (urgent notices only) | default `false` |
| `maxTries` | integer | No | Tries per person | 1–5, default 1 |
| `retryGapSec` | integer | No | Wait between tries | ≥ 300, default 3600 |
| `pacePerMin` | integer | No | New calls per minute | 1–600, default 60 |
| `fallback` | object | No | Campaign: after a person's last failed try, ask the client app to run one of its registered actions (`{action, params}`); not one that waits | |
| `meta` | object | No | | |

**Success Response**: `201 Created`, `message` "Call-out created", `data` = the call-out, `status: "DRAFT"`.

**Errors**: `400` "Give both windowFrom and windowTo" · "Window times must be HH:mm" · "Days are 1 (Monday) to 7 (Sunday)" ·
"fallback.action must be one of the client app's registered actions" · "A fallback action can't be one that waits for an outcome" ·
`403 FEATURE_OFF` · the same flow errors as [H1](#h1-quick-call).

---

## H3. Add people
**Purpose**: Add people to a `DRAFT` call-out (call it several times for long lists).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/callouts/{id}/recipients`

**Request JSON Sample**:
```json
{
  "recipients": [
    { "e164": "+255745051250", "vars": { "name": "Asha", "amount": "45000" }, "meta": { "customerId": "C-77" } },
    { "e164": "0712345678", "vars": { "name": "Juma", "amount": "12000" } },
    { "e164": "+14155550100", "vars": { "name": "Tom", "amount": "1" } }
  ]
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `recipients` | array | Yes | `{e164, vars?, meta?}` | ≤ 10,000 per request; every flow var given |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Recipients added",
  "action": null,
  "action_time": "2026-11-13T20:12:00.000000000",
  "data": {
    "added": 2,
    "skipped": [ { "e164": "+14155550100", "reason": "NOT_TANZANIAN" } ]
  }
}
```

| `skipped[].reason` | Means |
|---|---|
| `INVALID_NUMBER` | Not a phone number |
| `NOT_TANZANIAN` | Only `+255` numbers are called |
| `DUPLICATE` | Already on this call-out (or twice in the request) |
| `OPTED_OUT` | On the account's opt-out list: kept as `SKIPPED` with result `OPTED_OUT`, never called |

Local numbers (`0712…`) are turned into E.164.

**Errors**: `409 LIST_LOCKED` "The list is locked once a call-out starts; create a new call-out" ·
`422 VALIDATION_FAILED` "Some people have no value for a script variable" (nobody added).

---

## H4. Start, pause, resume, stop, cancel
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/callouts/{id}/{action}`

| `{action}` | From → to | Refused with |
|---|---|---|
| `start` | `DRAFT` → `RUNNING` (or `SCHEDULED` when `startsAt` is later). Locks the list | `409 ALREADY_STARTED` · `409 NO_RECIPIENTS` "Nobody to call" |
| `pause` | `RUNNING` / `SCHEDULED` → `PAUSED`; calls in progress finish. Optional body `{"reason": "…"}` (default `PAUSED_BY_CLIENT`) | `409 NOT_RUNNING` |
| `resume` | `PAUSED` → `RUNNING` | `409 NOT_PAUSED` |
| `stop` | any unfinished → `STOPPED`; people not yet called are not called. Stopping a finished call-out changes nothing | — |
| `cancel` | `DRAFT` / `SCHEDULED` → `CANCELLED` (nothing was dialled) | `409 ALREADY_RUNNING` "A running call-out is stopped, not cancelled" |

**Success Response**: `200`, `message` "Call-out start" (… "pause", "resume", "stop", "cancel"), `data` = the call-out.
Unknown action → `400` "Unknown action: explode".

The engine itself also pauses call-outs, with a `pauseReason`: `ACCOUNT_SUSPENDED`, `VERSION_REVOKED`, `FEATURE_OFF`
(the owner took `CALLOUTS` away), … and sends
`callout.paused` ([Part 3](3-webhooks.md)). It never resumes them by itself.

---

## H5. List call-outs
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts?accountId={accountId}`

| Query | Type | Required | Description |
|-----------|------|----------|-------------|
| `accountId` | uuid | **Yes** | Missing → `400 Missing parameter: accountId` |

**Success Response**: `data` = array of call-outs, newest first. Scheduled callbacks booked by callers are here too,
with `externalRef` `schedule:<flow ref>:<number>`.

---

## H6. Get a call-out's people
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts/{id}/recipients`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `status` | string | No | `PENDING` · `DIALING` · `DONE` · `SKIPPED` | all |
| `page` / `size` | integer | No | Page from 0; size up to 500 | 0 / 100 |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Recipients",
  "action": null,
  "action_time": "2026-11-13T20:05:37.426862727",
  "data": [
    {
      "id": 11,
      "e164": "+255745051250",
      "status": "DONE",
      "result": "ANSWERED",
      "attempts": 1,
      "lastCallId": "fff4aa3f-8ed5-44e6-947c-c82bb3628c41",
      "answeredSeconds": 28,
      "keys": "1234",
      "outcomes": null,
      "meta": { "said": "After five minutes, baada ya dakika tano.", "scheduledBy": "ca10b2f7-a5a3-41bb-be8b-2e486b2b5dbf" },
      "nextAttemptAt": null
    }
  ]
}
```

| Field | Description |
|-------|-------------|
| `status` | `PENDING` (waiting or between tries) · `DIALING` · `DONE` · `SKIPPED` |
| `result` | The last try's result (`ANSWERED`, `NO_ANSWER`, `BUSY`, `DECLINED`, `OPTED_OUT`…) |
| `attempts` / `nextAttemptAt` | Tries so far; when the next try is due (while `PENDING`) |
| `lastCallId` | The last call to this person ([G2](#g2-get-a-call)) |
| `answeredSeconds` / `keys` / `outcomes` | From the answered call |

---

## H7. Get a call-out
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts/{id}`

**Success Response JSON Sample** (a callback a caller booked for "baada ya dakika tano"):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Call-out",
  "action": null,
  "action_time": "2026-11-13T20:05:37.397238463",
  "data": {
    "id": "510fca7b-b226-46ad-b80a-0082ab4ab0d5",
    "externalRef": "schedule:account-callback:+255745051250",
    "name": "Callback +255745051250",
    "kind": "BROADCAST",
    "status": "COMPLETED",
    "pauseReason": null,
    "waitingApproval": false,
    "scriptVersionId": "3bcb496b-e302-4a98-9e8e-fd3e4c80b768",
    "startsAt": "2026-11-13T14:41:00Z",
    "endsAt": null,
    "windowFrom": null,
    "windowTo": null,
    "days": null,
    "maxTries": 2,
    "retryGapSec": 300,
    "pacePerMin": 60,
    "total": 1,
    "byStatus": { "DONE": 1 },
    "byResult": { "ANSWERED": 1 },
    "byOutcome": {},
    "createdAt": "2026-11-13T14:36:38.242481Z",
    "startedAt": "2026-11-13T14:36:38.282846Z",
    "completedAt": "2026-11-13T14:41:34.766757Z"
  }
}
```

| Field | Description |
|-------|-------------|
| `status` / `pauseReason` | See the diagram above |
| `waitingApproval` | The flow version is not runnable yet; nobody is called until it is approved |
| `days` | Campaign days as text (`"1,2,3,4,5"`) |
| `total` / `byStatus` / `byResult` / `byOutcome` | Counts over its people |

---

## H8. Call-out stats
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts/{id}/stats`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Call-out stats",
  "action": null,
  "action_time": "2026-11-13T20:05:37.463714267",
  "data": {
    "calloutId": "510fca7b-b226-46ad-b80a-0082ab4ab0d5",
    "total": 1,
    "byStatus": { "DONE": 1 },
    "byResult": { "ANSWERED": 1 },
    "byOutcome": {},
    "byAttempts": { "1": 1 },
    "byHour": [
      { "key": "2026-11-13T17:00", "values": { "dialed": 1, "answered": 1, "noAnswer": 0, "busy": 0, "failed": 0, "seconds": 28 } }
    ]
  }
}
```

| Field | Description |
|-------|-------------|
| `byAttempts` | People by the try that finished them (`{"1": 80, "2": 15, "3": 5}`) |
| `byHour[].key` | Hour in the account's time zone |
| `byHour[].values` | Calls dialled, answered, not answered, busy, failed; answered seconds |

## H9. Export a call-out (CSV)
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts/{id}/export.csv`

**Success Response**: `text/csv` attachment, one row per person:
```
e164,status,result,attempts,answeredSeconds,keys,outcomes,callId,meta,updatedAt
+255745051250,DONE,ANSWERED,1,28,1234,,fff4aa3f-8ed5-44e6-947c-c82bb3628c41,"{""said"": ""After five minutes, baada ya dakika tano."", ""scheduledBy"": ""ca10b2f7-…""}",2026-11-13T14:41:34.763432Z
```

---

# M. OTP (a code by voice)

## M1. Call a code to a number
**Purpose**: Read a one-time code to a person ("Namba yako ya siri ni moja, mbili, tatu, nne"), e.g. when SMS fails.
Not subject to quiet hours. The code is kept encrypted and never shown in timelines or logs.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/otp`

**Access Level**: 🔒 Protected · Bearer API key · feature `OTP`

**Request JSON Sample**:
```json
{
  "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
  "to": "+255745051250",
  "code": "482913",
  "lang": "sw",
  "numberId": "8a8d9c13-bda2-4a18-8410-c5ec49ed2c66",
  "meta": { "purpose": "login" }
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `accountId` | uuid | Yes | | |
| `to` | string | Yes | Tanzanian number | |
| `code` | string | Yes | Digits only | 4–10 digits |
| `lang` | string | No | `sw` · `en` | default: the account's language |
| `numberId` | uuid | No | Calling from | default: the account's first number |
| `meta` | object | No | Kept on the call | |

**Success Response JSON Sample** (`201 Created`):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Calling",
  "action": null,
  "action_time": "2026-11-13T20:15:00.000000000",
  "data": { "callId": "a7c3…", "status": "CALLING" }
}
```
Follow the call with `call.answered` / `call.ended` webhooks (type `OTP`).

**Errors**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Validation failed",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:05:41.000000000",
  "data": { "code": "must match \"\\d{4,10}\"" }
}
```
`400` "Only Tanzanian numbers can be called" · `400` "This account has no number to call from" ·
`403 FEATURE_OFF` · `429 RATE_LIMITED` "Too many codes to this number; try again later" (`otpPerDestinationPer10Min`).

---

# N. Account stats

## N1. Calls per day, hour, number or type
**Purpose**: A business dashboard: how many calls, how they ended, how long.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/stats`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `from` / `to` | date | No | `YYYY-MM-DD`, account time; at most 400 days | the last 7 days |
| `groupBy` | string | No | `day` · `hour` · `number` · `type` | `day` |

**Success Response JSON Sample** (`groupBy=day`):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Account stats",
  "action": null,
  "action_time": "2026-11-13T20:05:41.000000000",
  "data": [
    { "key": "2026-11-13", "values": { "incoming": 38, "outgoing": 2, "answered": 39, "byScript": 11, "byAgent": 3,
                                        "byAi": 9, "voicemail": 6, "missed": 0, "abandoned": 1, "hungUpEarly": 8, "seconds": 2311.0 } }
  ]
}
```

| `values` (day / hour / number) | Description |
|-------|-------------|
| `incoming` / `outgoing` | Calls in / out |
| `answered` | Answered calls |
| `byScript` / `byAgent` / `byAi` / `voicemail` | Who dealt with them |
| `missed` | Callers who waited for an agent and nobody answered |
| `abandoned` | Callers who hung up while waiting |
| `hungUpEarly` | Callers who hung up in the first seconds |
| `seconds` | Total call seconds |

With `groupBy=type`, `key` is the call type and `values` = `{calls, answered, seconds, pstnSeconds}`:
```json
[ { "key": "BROADCAST", "values": { "calls": 2, "answered": 1, "seconds": 28, "pstnSeconds": 28 } },
  { "key": "INCOMING",  "values": { "calls": 38, "answered": 38, "seconds": 2283, "pstnSeconds": 2283 } } ]
```

**Errors**: `400` "groupBy must be day, hour, number or type".
Agents' stats: [Part 2D](2d-agents.md#i13-agent-stats).

# Part 2D — Agents and live calls

[← Contents](README.md) · Previous: [Part 2C — Calls](2c-calls.md) · Next: [Part 2E — App actions](2e-actions.md)

People who answer calls. Ringmaster keeps only what it needs to ring them: a `ref` (the client app's user id), a
display name, an optional mobile, their departments and their status. How the browser desk works end to end:
[Part 1 §1.7](1-how-it-works.md#17-agents-in-the-browser); the messages the desk receives: [Part 3](3-webhooks.md#33-agent-desk-signals).

All endpoints: 🔒 Protected · Bearer API key. The agent's own buttons go **through the client app's backend**, which
adds `agentRef` after checking who is logged in — the browser never holds the API key.

```
Set up (once)        I1 department · I7 agent · I5 members
Agent's shift        I12 lobby token → join lobby → I10 status AVAILABLE
A caller arrives     (engine rings: "ring" signal in the lobby)
                     J1 accept → join call room · or J2 decline
On the call          J3 hold · J4 resume · J5 transfer · J6 hang up
After the call       WRAP_UP for wrapUpSec → AVAILABLE (or I11 ready)
End of shift         I10 status OFFLINE → leave lobby
```

---

# I. Departments and agents

## I1. Create a department
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/departments`

**Request JSON Sample**:
```json
{
  "name": "Mapokezi",
  "strategy": "RING_ALL",
  "ringTimeoutSec": 20,
  "wrapUpSec": 30,
  "maxMissedBeforeAway": 5,
  "ifNobodyOnline": "WAIT",
  "record": false
}
```

| Parameter | Type | Required | Description | Validation / default |
|-----------|------|----------|-------------|------------|
| `name` | string | Yes | Shown to callers' agents and in reports | not blank |
| `strategy` | string | No | `RING_ALL` (every free member at once) · `ROUND_ROBIN` (one at a time, in member order, starting after the last one rung) | `RING_ALL` |
| `ringTimeoutSec` | integer | No | How long one ring lasts before the next try | 10–60, default 20 |
| `wrapUpSec` | integer | No | Quiet time after each call before the agent is rung again | 0–300, default 0 |
| `maxMissedBeforeAway` | integer | No | Missed or declined rings in a row before the agent is set `AWAY`; `0` = never | 0–20, default 5 |
| `ifNobodyOnline` | string | No | `WAIT` (caller waits in the waiting room) · `EXIT_NO_ANSWER` (the Connect block's `noAnswer` exit at once) | `WAIT` |
| `record` | boolean | No | Record talk in this department (when the account's recording mode allows) | `true` |

**Success Response JSON Sample** (`201 Created`, `message` "Department created"):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Department created",
  "action": null,
  "action_time": "2026-11-13T20:10:47.69580505",
  "data": {
    "id": "c09c1657-d18a-4a32-94c7-4ffa24e038c3",
    "name": "Mapokezi",
    "strategy": "RING_ALL",
    "ringTimeoutSec": 20,
    "wrapUpSec": 0,
    "maxMissedBeforeAway": 5,
    "ifNobodyOnline": "WAIT",
    "record": false,
    "members": [],
    "waiting": 0
  }
}
```

| Field | Description |
|-------|-------------|
| `id` | Put it in a flow's Connect block: `"department": "<id>"` |
| `members` | Agent refs in ring order |
| `waiting` | Callers waiting now |

**Errors**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Validation failed",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:10:48.000000000",
  "data": { "ringTimeoutSec": "must be greater than or equal to 10" }
}
```

---

## I2. List departments
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/departments`

**Success Response**: `data` = array of departments ([I1](#i1-create-a-department) shape), by name.

## I3. Update a department
**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/departments/{departmentId}`

**Request**: same as [I1](#i1-create-a-department); `name` is required, missing settings keep their value.
**Success Response**: the department, `message` "Department updated". **Errors**: `404` "Department not found".

## I4. Delete a department
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/departments/{departmentId}`

**Success Response**: `200`, `message` "Department deleted", `data` null. Its members are removed from it.

**Errors**:
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "Department is used by live script versions",
  "code": "DEPARTMENT_IN_USE",
  "action_time": "2026-11-13T20:11:00.000000000",
  "data": { "usedBy": ["recorded-music v2"] }
}
```
`409 DEPARTMENT_BUSY` "Callers are still waiting in this department" — try again when the queue is empty.

---

## I5. Set a department's members
**Purpose**: Replace the whole member list; the order is the round-robin order. Callers already waiting are offered to
new free members at once.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/departments/{departmentId}/members`

**Request JSON Sample**:
```json
{ "agentRefs": ["asha", "juma", "neema"] }
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agentRefs` | string[] | Yes | Existing agents of the account; `[]` empties the department; repeats are ignored |

**Success Response**: the department, `message` "Members set".

**Errors**: `404` "Agent not found: ghost" — nothing is changed.

---

## I6. Callers waiting in a department
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/departments/{departmentId}/queue`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Waiting callers",
  "action": null,
  "action_time": "2026-11-13T20:10:47.790000000",
  "data": [
    { "callId": "8a5e282d-93a1-443b-81f3-4eb7294a0639", "from": "+255745051250", "position": 1, "waitingSeconds": 27 }
  ]
}
```
Order: callers put back at the head (a failed transfer, an agent who dropped) first, then by waiting time.

---

## I7. Add or update an agent
**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/agents/{ref}`

**Request JSON Sample**:
```json
{ "displayName": "Asha (mapokezi)", "mobile": "+255712345678" }
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `{ref}` (path) | string | Yes | The client app's user id | |
| `displayName` | string | Yes | Shown in the desk, wallboard and reports | not blank |
| `mobile` | string | No | Rung when the number's ring mode includes `MOBILE`; `""` removes it; missing keeps it | a phone number |

**Success Response JSON Sample** (`message` "Agent saved"; a new agent starts `OFFLINE`):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Agent saved",
  "action": null,
  "action_time": "2026-11-13T20:10:47.726488473",
  "data": {
    "ref": "asha",
    "displayName": "Asha (mapokezi)",
    "mobile": null,
    "status": "AVAILABLE",
    "online": true,
    "missedInRow": 0,
    "currentCallId": null,
    "departments": ["c09c1657-d18a-4a32-94c7-4ffa24e038c3"]
  }
}
```

| Field | Description |
|-------|-------------|
| `status` | `OFFLINE` · `AVAILABLE` · `RINGING` · `BUSY` · `WRAP_UP` · `AWAY` ([Part 1 §1.7](1-how-it-works.md#17-agents-in-the-browser)) |
| `online` | The agent's browser is connected to the lobby now |
| `missedInRow` | Missed or declined rings since the last answered one |
| `currentCallId` | The call ringing or live with them |
| `departments` | Department ids they are a member of |

**Errors**: `422` `{"displayName": "must not be blank"}` · `400` "mobile: …" (not a phone number).

---

## I8. List agents
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/agents`

**Success Response**: `data` = array of agents ([I7](#i7-add-or-update-an-agent) shape), by display name. Use it for a
wallboard together with `agent.status` webhooks.

## I9. Remove an agent
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/agents/{ref}`

**Success Response**: `200`, `message` "Agent removed". They leave every department and are never rung again; a call
in progress with them finishes normally. **Errors**: `404` "Agent not found: …".

---

## I10. Set an agent's status
**Purpose**: The agent's own buttons: **Available**, **Away** (break), **Offline** (end of shift). `RINGING`, `BUSY`
and `WRAP_UP` are set by calls, never by hand.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/agents/{ref}/status`

**Request JSON Sample**:
```json
{ "status": "AVAILABLE" }
```

| `status` | Effect |
|---|---|
| `AVAILABLE` | Can be rung; `missedInRow` back to 0; a caller already waiting is offered at once |
| `AWAY` | Online but not rung (a ring in progress is cancelled) |
| `OFFLINE` | Not rung |

**Success Response**: the agent, `message` "Status set". An `agent.status` webhook follows.

**Errors**:
- `400` "Status can be set to AVAILABLE, AWAY or OFFLINE"
- `409 AGENT_BUSY` "Finish the current call first"

> Being **online** (connected to the lobby) is separate from the status: an agent whose browser is gone for more than
> 30 s (and who is not on a call) is set `OFFLINE` by the engine.

---

## I11. Ready (end wrap-up early)
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/agents/{ref}/ready`

**Success Response**: the agent (`AVAILABLE` if they were in `WRAP_UP`), `message` "Ready".

---

## I12. Lobby token
**Purpose**: Lets the agent's browser join their **lobby room**, where the engine sends ring signals. Data only: no
microphone. Valid for 10 minutes; ask for a new one before it runs out (an open connection stays up).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/agents/{ref}/lobby-token`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Lobby token",
  "action": null,
  "action_time": "2026-11-13T20:10:47.815393198",
  "data": {
    "url": "wss://<livekit-host>",
    "room": "lobby-0ea57aeb-c3c9-48f5-b366-7fe3e457ffbd",
    "identity": "agent-0ea57aeb-c3c9-48f5-b366-7fe3e457ffbd",
    "token": "eyJhbGciOiJIUzI1NiIsInR5…"
  }
}
```

In the browser (LiveKit JS SDK):
```js
const room = new LivekitClient.Room();
room.on(LivekitClient.RoomEvent.DataReceived, bytes => onSignal(JSON.parse(new TextDecoder().decode(bytes))));
await room.connect(data.url, data.token);           // then PUT …/status {AVAILABLE} through the backend
```
Make the browser's ring sound start from the agent's "Start shift" click: browsers mute sound that did not start from
a click.

---

## I13. Agent stats
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/agents/stats`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `from` / `to` | date | No | `YYYY-MM-DD`, account time | the last 7 days |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Agent stats",
  "action": null,
  "action_time": "2026-11-13T20:10:47.759658701",
  "data": [
    { "key": "asha", "values": { "offered": 7, "answered": 3, "missed": 2, "declined": 0, "talkSeconds": 50.0 } }
  ]
}
```

| `values` | Description |
|-------|-------------|
| `offered` | Rings |
| `answered` | Accepted |
| `missed` | Rang until the ring time ran out |
| `declined` | Pressed Decline |
| `talkSeconds` | Time talking to callers |

---

# J. Live call controls

Every control takes the agent: `{"agentRef": "asha"}` (sent by the client app's backend). Controls for a call that is
not ringing for, or not live with, that agent are refused.

## J1. Accept a ringing call
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/accept`

**Request JSON Sample**:
```json
{ "agentRef": "asha" }
```

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Call accepted",
  "action": null,
  "action_time": "2026-11-13T19:43:46.998000000",
  "data": {
    "url": "wss://<livekit-host>",
    "room": "call-_+255745051250_uqL6Uaw9VmC9",
    "identity": "agent-0ea57aeb-c3c9-48f5-b366-7fe3e457ffbd",
    "token": "eyJhbGciOiJIUzI1NiIsInR5…"
  }
}
```
The browser joins `room` with `token` and turns the microphone on; the caller's waiting music stops and they are
connected as soon as the agent is in the room (the engine waits up to 15 s; if the agent never arrives, the caller goes
back to the head of the queue). Other agents rung for the same call get `ring_cancel` (`answered_elsewhere`).

**Errors**:
- `409 NOT_RINGING` "This call is not ringing for you any more" (ring time ran out, the caller left, or it was cancelled)
- `409 ALREADY_TAKEN` "Another agent already answered this call"
- `404` "Call not found" · "Agent not found: …"

---

## J2. Decline a ringing call
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/decline`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `200`, `message` "Call declined". The engine rings the next
agent; the decline counts as a missed ring ([I1](#i1-create-a-department) `maxMissedBeforeAway`).
**Errors**: `409 NOT_RINGING`.

---

## J3. Hold
**Purpose**: The caller hears hold music; the agent's microphone is muted for the caller.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/hold`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `200`, `message` "On hold". Holding twice is harmless.

**Errors**:
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "This call is not with you",
  "code": "NOT_YOUR_CALL",
  "action_time": "2026-11-13T20:10:57.000000000",
  "data": null
}
```

## J4. Resume from hold
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/unhold`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `200`, `message` "Back from hold". **Errors**: `409 NOT_YOUR_CALL`.

---

## J5. Transfer
**Purpose**: Hand the caller to another department or a named agent (blind transfer: the first agent leaves at once,
the caller hears music meanwhile).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/transfer`

**Request JSON Samples**:
```json
{ "agentRef": "asha", "toDepartmentId": "dbd644af-e460-40fa-9b09-4cb838915bdc" }
```
```json
{ "agentRef": "asha", "toAgentRef": "juma" }
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agentRef` | string | Yes | The agent now on the call |
| `toDepartmentId` | uuid | One of the two | The caller goes to the **head** of that department's queue |
| `toAgentRef` | string | One of the two | That agent is rung; if they don't answer, the caller's current department picks them up (they are at its head) |

**Success Response**: `200`, `message` "Transferring". The call's timeline gets a `TRANSFER` event (`{from, to}`).

**Errors**: `400` "Give toDepartmentId or toAgentRef" · `409 NOT_YOUR_CALL` · `404` "Agent not found: …".

---

## J6. Hang up
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/hangup`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `200`, `message` "Hanging up". The caller is disconnected; the
call ends with `result: HANDLED_BY_AGENT`, `endReason: AGENT_HUNG_UP`, `endedBy: AGENT`; the agent goes to `WRAP_UP`
(or straight to `AVAILABLE` when `wrapUpSec` is 0).

**Errors**: `409 NOT_YOUR_CALL`.

> **If the agent's browser drops** during a call (closed tab, lost network), the caller is not cut off: they hear music
> and go back to the head of the queue.

# Part 2E — App actions and opt-outs

[← Contents](README.md) · Previous: [Part 2D — Agents](2d-agents.md) · Next: [Part 3 — What the engine sends you](3-webhooks.md)

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](1-how-it-works.md#19-app-actions-the-client-app-does-something-during-a-call).

```
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**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/actions`

**Request JSON Sample**:
```json
[
  { "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](#k2-read-the-actions)).

**Errors**:
```json
{
  "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"
  ]
}
```
```json
{
  "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}\"" }
}
```
```json
{
  "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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/actions`

**Success Response JSON Sample**:
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/action-result`

**What the client app received** (webhook, [Part 3](3-webhooks.md#actionrequested)):
```json
{ "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**:
```json
{
  "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**:
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/opt-outs`

**Request JSON Sample**:
```json
{ "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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/opt-outs`

**Success Response JSON Sample**:
```json
{
  "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**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{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.