# Part 3 — What the engine sends you (Webhook)

[← Contents](README.md) · Previous: [Part 2E — App actions](2e-actions.md) · Next: [Part 4 — Reference](4-reference.md)

The engine talks to the client app in three ways:

| Channel | Direction | Used for |
|---|---|---|
| **Webhooks** | engine → client app's webhook URL (POST) | Everything that happened: calls, blocks, recordings, call-outs, agents, media, app actions |
| **The can-call check** | engine → client app's can-call URL (GET), waits for the answer | Ask before a call: is this account allowed to call now? (optional) |
| **Agent desk signals** | engine → the agent's browser, through LiveKit | Ring, ring cancelled, connecting |

Both URLs and the shared secret are set by the Ringmaster owner for the client app ([Appendix](appendix.md)).

---

# 3.1 Webhooks

## Every webhook

```http
POST https://app.example.co.tz/hooks/ringmaster
Content-Type: application/json
X-Ringmaster-Event: call.ended
X-Ringmaster-Delivery: f267e572-f9d2-4a8d-831a-a3bec700cd44
X-Ringmaster-Timestamp: 1794588244
X-Ringmaster-Signature: 5c1e9a0d3f…   (64 hex characters)

{
  "id": "f267e572-f9d2-4a8d-831a-a3bec700cd44",
  "type": "call.ended",
  "createdAt": "2026-11-13T16:59:31.053Z",
  "data": { … }
}
```

| Part | Description |
|---|---|
| `X-Ringmaster-Event` | Same as `type` |
| `X-Ringmaster-Delivery` | Same as `id`; unique per event, also on retries — **drop repeats by this id** |
| `X-Ringmaster-Timestamp` | Unix seconds when this attempt was sent |
| `X-Ringmaster-Signature` | Lower-case hex HMAC-SHA256 of `"<timestamp>.<raw body>"` with the webhook secret |
| `createdAt` | When the event happened (attempts later keep it) |
| `data` | The event's content (below). Every event about an account carries `accountRef` (the account's `externalRef`) — except `call.block` and `call.scheduled`, which carry `callId` |

## Checking the signature

Check it on the **raw body bytes**, before parsing; refuse timestamps more than 5 minutes off.

```python
import hmac, hashlib, time

def valid(secret: str, headers, raw_body: bytes) -> bool:
    ts = headers["X-Ringmaster-Timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, headers["X-Ringmaster-Signature"])
```
```java
String expected = HexFormat.of().formatHex(Mac.getInstance("HmacSHA256") {{ init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256")); }}
        .doFinal((timestamp + "." + rawBody).getBytes(UTF_8)));
boolean ok = MessageDigest.isEqual(expected.getBytes(UTF_8), signature.getBytes(UTF_8));
```

## Answering, retries, order

- Answer **2xx within a few seconds**, then do the work (queue it). Any other answer, or no answer, is retried:
  **1 min, 5 min, 30 min, 2 h, 6 h, 12 h**, then every 12 h, until **24 h** after the event; after that the event is
  marked failed and not sent again.
- Events are stored in the same database step as the change they report, so none is lost when the engine restarts.
- **Order is not guaranteed** across retries: `call.ended` can arrive before a late `call.block`. Use `createdAt`,
  and treat each event as "the latest I know", not as a step in a strict sequence.
- The same event can arrive twice (a 2xx that got lost on the way back). Keep the `id`s you processed.
- The owner can send a `test.ping` (`ringmaster-admin client-app test-webhook <app>`) to check the URL and the secret.

## Events

| Type | When | Main `data` |
|---|---|---|
| [`call.started`](#callstarted) | A call begins (incoming picked up, or dialling out) | callId, type, direction, from, to |
| [`call.answered`](#callanswered) | The person answered a call-out / OTP / agent call | same as `call.started` |
| [`call.block`](#callblock) | A caller's choice or answer worth acting on (menu, collect, poll, pick, ask, request, app action) | callId, block, type, … |
| [`call.ended`](#callended) | The call is over | the full call summary |
| [`call.missed`](#callmissed) | A caller gave up waiting, or nobody answered them | callId, from, to, result |
| [`call.ring_declined`](#callring_declined) | An agent declined a ring | callId, agentRef, department, reason, from |
| [`call.scheduled`](#callscheduled) | A caller booked a callback | callId, calloutId, at, to, said |
| [`voicemail.received`](#voicemailreceived--recordingready) | A voicemail is stored | callId, recordingId, durationSec, from |
| [`recording.ready`](#voicemailreceived--recordingready) | A recorded live part (agent talk) is stored | same |
| [`summary.ready`](#summaryready) | Transcript and summary of a recording are ready | callId, summary, sentiment, language, fields, unanswered |
| [`ai.unanswered`](#aiunanswered) | The AI could not answer some caller questions | callId, questions |
| [`action.requested`](#actionrequested) | A flow asks the client app to do one of its actions | actionId, name, callId, params, wait, timeoutAt |
| [`request.failed`](#requestfailed) | A Send request delivery kept failing for 24 h | callId, block, attempts, status, error |
| [`callout.progress`](#calloutprogress--calloutcompleted--calloutpaused) | Every 30 s while a call-out runs | counts |
| [`callout.paused`](#calloutprogress--calloutcompleted--calloutpaused) | The engine paused a call-out | counts + reason |
| [`callout.completed`](#calloutprogress--calloutcompleted--calloutpaused) | Everybody was called (or it was stopped) | counts |
| [`callout.recipient_results`](#calloutrecipient_results) | Batches of people who are done | results[] |
| [`agent.status`](#agentstatus) | An agent's status or presence changed | agentRef, status, online, departments |
| [`agent.device_paired`](#agentdevice_paired--agentdevice_revoked) | An agent connected an Agent Phone | agentRef, deviceId, name, platform |
| [`agent.device_revoked`](#agentdevice_paired--agentdevice_revoked) | A device was removed or cut off | agentRef, deviceId, name, platform, reason |
| [`agent.shift_started`](#agentshift_started--agentshift_ended) | An agent started a shift on a shared computer | agentRef, stationId, station |
| [`agent.shift_ended`](#agentshift_started--agentshift_ended) | A shift ended | agentRef, stationId, station, reason |
| `station.paired` · `station.removed` | A shared computer was added or removed ([2F K4](2f-agent-phone.md#k4-shared-computers-stations)) | stationId, name |
| [`media.ready`](#mediaready--mediafailed) / [`media.failed`](#mediaready--mediafailed) | An uploaded audio file finished processing | mediaId, durationMs / error |
| [`test.ping`](#testping) | Sent by the owner to test the setup | — |

The samples below are real events, shortened only where marked.

---

### `call.started`
```json
{ "id": "32c49361-fa61-441c-9c6d-9fcb0add6227", "type": "call.started", "createdAt": "2026-11-13T16:58:37.622Z",
  "data": { "callId": "4e2e8a2d-4a1e-487e-b684-bd7e5cfee244", "accountRef": "softphone", "type": "INCOMING",
            "direction": "INBOUND", "from": "+255745051250", "to": "+255677000001" } }
```
Use it for a "live calls" screen or a screen pop (look up `from` in the client app's contacts).

### `call.answered`
```json
{ "id": "c8365f5d-d368-47b9-a6d2-910ba5e9523e", "type": "call.answered", "createdAt": "2026-11-13T14:41:06.810Z",
  "data": { "callId": "fff4aa3f-8ed5-44e6-947c-c82bb3628c41", "accountRef": "softphone", "type": "BROADCAST",
            "direction": "OUTBOUND", "from": "+255677000003", "to": "+255745051250" } }
```
Only for outgoing calls (incoming calls are answered by the engine at once).

### `call.block`
Sent when a caller does something the client app may act on during the call. `data.type` says which block:

| `type` | Extra fields | Example |
|---|---|---|
| `menu` | `option` (the option's id), `by` (`key` / `speech`) | `{"callId": "763f…", "block": "main", "type": "menu", "option": "jitoe", "by": "key"}` |
| `collect` | `saveAs`, `value` (`••••` when the block is `sensitive`) | `{"block": "acct", "type": "collect", "saveAs": "account", "value": "1234"}` |
| `poll` | `question` (saveAs), `answer` (key), `label`, `by` | `{"block": "rate", "type": "poll", "question": "rating", "answer": "5", "by": "key"}` |
| `pick` | `id`, `label` of the chosen item | `{"block": "product", "type": "pick", "id": "acetic", "label": "Acetic Acid"}` |
| `ask` | `saveAs`, `text` (the transcribed answer) | `{"block": "problem", "type": "ask", "saveAs": "problem", "text": "Simu yangu haiwaki"}` |
| `request` | `outcome` (the exit taken), `status` (HTTP); late callbacks add `late: true`, `reply` | `{"block": "look", "type": "request", "outcome": "success", "status": 200}` |
| `app_action` | `name`, `outcome`; `late: true` when the answer came after the call moved on | `{"block": "pay_1", "type": "app_action", "name": "pay", "outcome": "paid"}` |

```json
{ "id": "a8156b02-36c6-4484-bb04-8f8b61b723c7", "type": "call.block", "createdAt": "2026-11-13T16:18:05.966Z",
  "data": { "callId": "763f750d-cf31-4265-a03c-7540627d55df", "block": "main", "type": "menu", "option": "jitoe", "by": "key" } }
```

### `call.ended`
`data` is the full call summary, exactly as `GET /v1/calls/{callId}` returns it ([Part 2C, G2](2c-calls.md#g2-get-a-call)).
```json
{ "id": "f267e572-f9d2-4a8d-831a-a3bec700cd44", "type": "call.ended", "createdAt": "2026-11-13T16:59:31.053Z",
  "data": {
    "callId": "4e2e8a2d-4a1e-487e-b684-bd7e5cfee244", "accountRef": "softphone", "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
    "type": "INCOMING", "direction": "INBOUND", "from": "+255745051250", "to": "+255677000001",
    "status": "ENDED", "result": "HANDLED_BY_AGENT", "endReason": "AGENT_HUNG_UP", "endedBy": "AGENT", "handledBy": "AGENT",
    "startedAt": "2026-11-13T16:58:37.599172Z", "answeredAt": "2026-11-13T16:58:37.599172Z", "endedAt": "2026-11-13T16:59:31.034198254Z",
    "durations": { "ringSeconds": null, "totalSeconds": 54, "scriptSeconds": 1, "waitSeconds": 16, "talkSeconds": 35, "holdSeconds": 0, "aiSeconds": 0 },
    "keys": null, "lastBlock": "desk", "outcomes": [],
    "usage": { "pstnSeconds": 54, "trunkSeconds": 54, "waitSeconds": 16, "aiSeconds": 0, "ttsChars": 0, "sttSeconds": 0 },
    "providerCost": { "tzs": 25.61, "usd": 0.0, "items": [ { "provider": "AT", "tzs": 25.61, "usd": 0.0, "lines": [ "…" ] } ] },
    "recorded": false,
    "script": { "versionId": "e183c7f1-3137-4848-95c1-11a7e8bef17a", "version": 1, "ref": "desk-test" },
    "calloutId": null, "hangupCause": null, "sipStatus": null, "meta": null } }
```
`recorded: true` means a `voicemail.received` / `recording.ready` (and later `summary.ready`) is coming or came.

### `call.missed`
A caller waited for an agent and left (`ABANDONED`) or nobody answered within the waiting room's give-up time
(`MISSED`). Use it for a "call back" list.
```json
{ "id": "07c440f3-50b1-4918-8338-cfd2b8e17bf2", "type": "call.missed", "createdAt": "2026-11-13T16:28:17.449Z",
  "data": { "callId": "44f41241-e356-46d1-86aa-dcd4f0f82c12", "accountRef": "softphone",
            "from": "+255745051250", "to": "+255677000001", "result": "ABANDONED" } }
```

### `call.ring_declined`
An agent pressed Decline. `reason` is what they tapped (`WITH_CUSTOMER`, `BREAK`, `WRONG_DEPARTMENT`, `OTHER`, or `null`);
`from` is masked when the account hides caller numbers. Use it for a live "declined" counter; the daily totals are in
[agent stats](2d-agents.md).
```json
{ "id": "0c5d0d0e-6a3e-4c0c-9a51-5b1a0e3f9a10", "type": "call.ring_declined", "createdAt": "2026-11-14T12:05:36.102Z",
  "data": { "callId": "44f41241-e356-46d1-86aa-dcd4f0f82c12", "accountRef": "softphone", "agentRef": "asha",
            "department": "Mapokezi", "reason": "BREAK", "from": "+255745051250" } }
```

### `call.scheduled`
A caller asked to be called later (Schedule call block or the AI's `schedule_call`) and a one-person call-out was booked.
```json
{ "id": "75974f9d-dc08-4fcf-834d-dea89c3bb037", "type": "call.scheduled", "createdAt": "2026-11-13T14:36:38.302Z",
  "data": { "callId": "ca10b2f7-a5a3-41bb-be8b-2e486b2b5dbf", "calloutId": "510fca7b-b226-46ad-b80a-0082ab4ab0d5",
            "at": "2026-11-13T17:41+03:00", "to": "+255745051250", "scriptRef": "account-callback",
            "said": "After five minutes, baada ya dakika tano." } }
```
| Field | Description |
|---|---|
| `at` | Booked time, with the account's offset |
| `scriptRef` | The Outgoing flow that will play |
| `said` | The caller's words (absent for a fixed time) |
| `calloutId` | Show or cancel it with the call-out API ([Part 2C](2c-calls.md#h4-start-pause-resume-stop-cancel)) |

A new booking by the same caller for the same flow replaces the old one (the old call-out is cancelled).

### `voicemail.received` / `recording.ready`
```json
{ "id": "3a73b6ee-36f4-4c4e-82ef-599145ed63a1", "type": "voicemail.received", "createdAt": "2026-11-13T16:46:20.647Z",
  "data": { "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96", "accountRef": "softphone",
            "recordingId": "3eb839d7-bac0-49da-a870-7aba6fa77b6e", "kind": "VOICEMAIL", "durationSec": 11, "from": "+255745051250" } }
```
`recording.ready` has the same shape with `kind: "CALL"` (the recorded talk with an agent). Fetch the audio with
`GET /v1/calls/{callId}/recording`.

### `summary.ready`
```json
{ "id": "0f86f71d-8bec-4aff-a634-2fca99f4c3a0", "type": "summary.ready", "createdAt": "2026-11-13T16:46:27.191Z",
  "data": { "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96", "accountRef": "softphone", "language": "sw",
            "summary": "Mpigaji amesema huo ndio ujumbe wake na ametoa shukrani nyingi.", "sentiment": "positive",
            "fields": {}, "unanswered": [] } }
```
The full text: `GET /v1/calls/{callId}/transcript`.

### `ai.unanswered`
Questions callers asked an AI Agent that its knowledge did not answer — material for the business's knowledge base.
```json
{ "id": "2099a5dd-ceab-4609-b32b-a167ea91d39e", "type": "ai.unanswered", "createdAt": "2026-11-13T09:30:36.154Z",
  "data": { "callId": "d6f2ef46-2775-4696-8cac-e4e1e9e2133a", "accountRef": "softphone",
            "questions": ["Je, miadi ya kesho saa 1 usiku inaweza kupangwa?", "Amini, wanipi."] } }
```

### `action.requested`
A flow (App action block, AI tool) or a campaign fallback asks the client app to run one of its registered actions.
Answer it with `POST /v1/calls/{callId}/action-result` ([Part 2E, K3](2e-actions.md#k3-answer-an-action)).
```json
{ "id": "9c1d…", "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",
            "meta": { "client": { "orderId": "ORD-88" } } } }
```
| Field | Description |
|---|---|
| `params` | Filled in: templates replaced by the call's values (`{caller}` → the number) |
| `wait` / `timeoutAt` | `true`: the caller is waiting; answer before `timeoutAt` or the flow takes its `timeout` exit |
| `meta.client` | The call's `meta` (from the call-out person, OTP request…) |

A **campaign fallback** (after a person's last failed try) has no call to wait on: `data` is
`{actionId, name, params, wait: false}`, and `params` also carries `to` (the person), `calloutId`, `accountRef` and
`reason` (their last result, e.g. `NO_ANSWER`). Nothing waits for an answer.

### `request.failed`
A Send request block in "send and continue" mode delivers in the background with retries (30 s, 2 min, 10 min, 30 min,
1 h, 2 h, 4 h…). After 24 h of failures it gives up and tells the client app:
```json
{ "id": "…", "type": "request.failed", "createdAt": "2026-11-14T09:12:30Z",
  "data": { "callId": "4b1e…", "block": "notify_crm", "attempts": 9, "status": 503, "error": "Service Unavailable" } }
```

### `callout.progress` · `callout.completed` · `callout.paused`
Same shape: the call-out's counts. `progress` every 30 s while it runs; `completed` once at the end (also after
`stop`); `paused` whenever it is paused (by you or by the engine), with `reason`.
```json
{ "id": "2d0dd47c-4f31-4b9f-a523-b3cd7e486f33", "type": "callout.completed", "createdAt": "2026-11-13T14:41:34.770Z",
  "data": { "calloutId": "510fca7b-b226-46ad-b80a-0082ab4ab0d5", "externalRef": "schedule:account-callback:+255745051250",
            "status": "COMPLETED", "total": 1, "byStatus": { "DONE": 1 }, "byResult": { "ANSWERED": 1 }, "byOutcome": {},
            "meta": { "client": { "said": "After five minutes, baada ya dakika tano.", "scheduledBy": "ca10b2f7-a5a3-41bb-be8b-2e486b2b5dbf" } } } }
```
| `reason` (paused) | Why |
|---|---|
| `ACCOUNT_SUSPENDED` / `CLIENT_SUSPENDED` | The account or the client app was suspended |
| `VERSION_REVOKED` | Its flow version was stopped |
| `FEATURE_OFF` | The owner took the client app's `CALLOUTS` feature away |
| `CAN_CALL:<reason>` | The can-call check said no ([3.2](#32-the-can-call-check)) |
| `PAUSED_BY_CLIENT` / your own text | You paused it (`POST …/pause`); the webhook is sent for these too |

The engine never resumes a call-out it paused.

### `callout.recipient_results`
People who are finished, in batches (about every 30 s), so the client app can update its contacts.
```json
{ "id": "50c42336-a708-4289-a307-6a4dd6ceefae", "type": "callout.recipient_results", "createdAt": "2026-11-13T14:42:44.692Z",
  "data": { "calloutId": "510fca7b-b226-46ad-b80a-0082ab4ab0d5",
            "results": [ { "e164": "+255745051250", "result": "ANSWERED", "attempts": 1, "callId": "fff4aa3f-8ed5-44e6-947c-c82bb3628c41",
                           "answeredSeconds": 28, "keys": "1234", "outcomes": null } ] } }
```

### `agent.status`
Every status or presence change, for a wallboard.
```json
{ "id": "19908d3b-3d76-4473-a18b-9b100ad5ea80", "type": "agent.status", "createdAt": "2026-11-13T16:59:31.077Z",
  "data": { "accountRef": "softphone", "agentRef": "asha", "status": "AVAILABLE", "online": true,
            "departments": ["c09c1657-d18a-4a32-94c7-4ffa24e038c3"] } }
```

### `media.ready` · `media.failed`
```json
{ "id": "facd42e5-8867-4b10-ae19-cb7ecfe02578", "type": "media.ready", "createdAt": "2026-11-13T09:46:09.038Z",
  "data": { "mediaId": "eebb855e-2a03-4af7-a8c8-301f85877308", "accountRef": "softphone", "durationMs": 24192 } }
```
`media.failed`: `{"mediaId", "accountRef", "error"}`.

### `test.ping`
Not in the usual envelope:
```json
{ "type": "test.ping", "deliveryId": "d451ae97-ea90-4039-9098-0363acbdeda7", "clientAppId": "bd74035b-bdb6-450d-885b-222e83719906",
  "at": "2026-11-13T15:19:12.682569166Z" }
```
Signed like every webhook; answer 2xx.

### `agent.device_paired` · `agent.device_revoked`
An agent paired Agent Phone ([Part 2F](2f-agent-phone.md)), or a device stopped being allowed. `reason`:
`REMOVED` (your dashboard), `SIGNED_OUT`, `TOKEN_REUSED` (a copied refresh token was used: treat as a security event),
`UNUSED` (30 days), `AGENT_REMOVED`.
```json
{ "type": "agent.device_revoked", "data": { "accountRef": "softphone", "agentRef": "asha",
  "deviceId": "1400d27e-8e21-4f9c-be7d-8e7c77c5f660", "name": "Agent Phone on reception-pc", "platform": "linux", "reason": "REMOVED" } }
```

### `agent.shift_started` · `agent.shift_ended`
Shared computers ([Part 2F, K4](2f-agent-phone.md#k4-shared-computers-stations)). `reason` on `shift_ended`: `ENDED`
(the agent), `DASHBOARD`, `SWITCHED` (another agent started a shift), `IDLE`, `END_TIME`, `STATION_REMOVED`,
`AGENT_REMOVED`.
```json
{ "type": "agent.shift_ended", "data": { "accountRef": "softphone", "agentRef": "asha",
  "stationId": "6f1e…", "station": "Reception PC", "reason": "IDLE" } }
```

---

# 3.2 The can-call check

Optional. When the client app has a **can-call URL**, the engine asks it before calls, so the client app can apply its
own rules (the business has credit, its plan allows calls now, a number is blocked):

```http
GET https://app.example.co.tz/hooks/can-call?accountRef=pmk-chemicals&type=INCOMING&from=%2B255745051250&to=%2B255677000010
X-Ringmaster-Timestamp: 1794588244
X-Ringmaster-Signature: <hex HMAC-SHA256(secret, "<timestamp>.")>      (empty body)
```

| Query | Description |
|---|---|
| `accountRef` | The account |
| `type` | `INCOMING` · `BROADCAST` · `CAMPAIGN` · `AGENT_CALL` |
| `from` / `to` | E.164 (`from` is empty for call-outs) |

**Answer** (bare, or inside the usual envelope's `data`):
```json
{ "allowed": true, "maxMinutes": 10, "reason": null }
```

| Field | Description |
|---|---|
| `allowed` | `false` refuses the call (missing = `true`) |
| `maxMinutes` | Optional: end this call after this many minutes (e.g. the business's remaining credit) |
| `reason` | Your short code, kept with the refusal |

**Time limit**: connect 0.7 s, answer 1.5 s. When the URL does not answer in time or fails:

| Call type | When the check fails | When `allowed: false` |
|---|---|---|
| `INCOMING` | **Allowed** (a caller is never refused because the client app is down) | Refused: caller hears the service is unavailable; `result: REJECTED` |
| `BROADCAST` / `CAMPAIGN` | **Refused** | The call-out is paused (`CAN_CALL:<reason>`, `callout.paused`) |
| `AGENT_CALL` | **Refused** | `403 CAN_CALL_REFUSED` to `POST /v1/calls/agent` |

For call-outs the check runs once per batch of people, not per person.

---

# 3.3 Agent desk signals

Sent by the engine into the agent's **lobby room** (LiveKit data messages, JSON) while their browser is connected
([Part 2D, I12](2d-agents.md#i12-lobby-token)).

| `type` | When | Fields | What the desk does |
|---|---|---|---|
| `ring` | A caller is offered to this agent | `callId`, `from`, `department`, `departmentId`; `name`, `label`, `note` when the [contacts URL](#34-the-contacts-url) knows them | Show "Incoming call", play the ring, offer Accept / Decline |
| `ring_cancel` | The offer ended | `callId`, `reason` | Stop ringing, hide the card |
| `connecting` | The agent's **mobile** answered the ring (ring mode with mobiles) | `callId`, `via: "mobile"` | Show "On a call on your mobile" |
| `outgoing` | An agent call for this agent started (dashboard, dial pad, call back) | `callId`, `to`; `name`, `label`, `note` | Show "Calling…", [join](2d-agents.md#j7-join-an-outgoing-call) |
| `call_answered` | The other phone picked up an outgoing call | `callId` | Stop the ringback, start the timer |
| `call_recording` | The call is being recorded | `callId` | Show a REC badge |
| `call_ended` | The call is over, whoever hung up | `callId`, `result` | Close the call screen |
| `status` | This agent's status or presence changed (dashboard, calls, devices) | `status`, `online`, `since` | Update the status dot |
| `team` | A teammate's status changed (only when `showTeam`) | `ref`, `name`, `status`, `online`, `since` | Update the team list |
| `queue` | Callers joined or left one of the agent's departments | `departmentId`, `waiting`, `oldestSince`, `callers` (≤ 10, in order) | Update the waiting list |
| `config` | Departments, ring settings, name or the account's switches changed | `version`, `changed` | Fetch the [desk](2d-agents.md#i14-the-agents-desk) again |
| `device_paired` | Another device was paired for this agent | `deviceId`, `name`, `platform` | Notify "New device connected" |
| `device_revoked` | A device was removed | `deviceId`, `reason` | That device: back to pairing |
| `shift` | A shared computer's shift started (in the station's room `station-{id}`) or ended (there and in the agent's lobby) | `deviceId`, `state: "started"` + `agentRef`, `name` · `state: "ended"` + `reason` | That station: switch to the agent's desk, or back to waiting |

| `ring_cancel.reason` | Means |
|---|---|
| `answered_elsewhere` | Another agent accepted it |
| `caller_left` | The caller hung up or left the waiting room |
| `timeout` | Nobody pressed Accept within the department's ring time |
| `declined` | This agent declined |
| `not_joined` | The agent accepted but their browser never joined the call room (15 s); the caller went back to the queue |

When the agent changes their own status while ringing (Away, Offline), the ring is cancelled without a signal: the
desk already knows.

```json
{ "type": "ring", "callId": "8a5e282d-93a1-443b-81f3-4eb7294a0639", "from": "+255745051250", "department": "Mapokezi" }
{ "type": "ring_cancel", "callId": "8a5e282d-93a1-443b-81f3-4eb7294a0639", "reason": "timeout" }
```
A ring is sent once the caller has heard everything before the waiting music, so the agent never meets a caller who
does not yet know they are being connected.

---

# 3.4 The contacts URL

Ringmaster keeps **no phone book**: when it needs a name, it asks your app. The Ringmaster owner sets your
`contactsUrl` once (`ringmaster-admin client-app edit <app> --contacts-url https://…`). Requests are `GET`, signed like
the can-call check (`X-Ringmaster-Timestamp`, `X-Ringmaster-Signature` = HMAC-SHA256 of `"<timestamp>."` with your
webhook secret).

**Who is this number?** Asked when a call comes in (while the can-call check runs) and for numbers agents dial.
At most 1 s; slow or failing answers mean "no name" and the call goes on.
```
GET {contactsUrl}?accountRef=softphone&number=%2B255745051250
→ 200 { "name": "John Mushi", "label": "VIP customer", "company": "Mushi Hardware",
        "note": "Prefers Swahili, calls about orders", "externalRef": "cust-1182" }
→ 404 (or {}) = unknown
```

**Search** (Agent Phone's dial pad, by name or number). At most 2 s, up to 10 answers.
```
GET {contactsUrl}?accountRef=softphone&q=john
→ 200 [ { "name": "John Mushi", "number": "+255745051250", "label": "VIP customer" }, … ]
```

| Where the answer shows | |
|---|---|
| Agents | Ring, call screen (name, label, note), queue list, recent calls, call log (`contactName`) |
| Flows | `{contact.name}`, `{contact.label}`, `{contact.company}`, `{contact.note}`, `{contact.ref}`; Condition `contactExists` |
| Timeline | A `CONTACT` event with the name and label |

Answers are remembered for an hour (unknown numbers for 10 minutes). The call keeps the name it was shown with, as call
history, deleted with the call.