# 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`

> **With Agent Phone** the agent's devices get an `outgoing` signal (`callId`, `to`, and the contact's `name`, `label`,
> `note`; or your `meta.name`) and join by themselves ([J7](2d-agents.md#j7-join-an-outgoing-call)); you don't need to
> hand them the room token. No recording notice is said on these calls unless `recording.agentCallNotice` is on.
> `meta.via: "dial-pad"` marks calls typed on Agent Phone's dial pad: refused with `409 DIAL_PAD_OFF` when the account
> turned `agentPhone.allowDialPad` off.

**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).