---
summary: "The server-to-server API ScaiWave uses to give its users ScaiDial phone\
  \ extensions \u2014 rings, calls, call control, conferences, voicemail, music on\
  \ hold, call recording, transcripts and live captions."
title: ScaiWave connector
path: integrations/scaiwave-connector
status: published
---

The connector lets ScaiWave users own and use ScaiDial extensions. Their phones ring in ScaiWave, they answer, place calls, transfer, put calls on hold, change their settings and read their voicemail. A Python client is available: [ScaiWave connector client](./scaiwave-client).

## 1. Architecture

```
 caller (PSTN / extension)                         ScaiWave clients (browser, desktop)
        │                                                    ▲ join room with our token
        ▼                                                    │
  SBC → LiveKit room  ◄──────────── audio, always ───────────┘
        │                         wss://rtc.scailabs.ai
        ▼
  ScaiGrid / ScaiDial ── signed webhooks ──► ScaiWave server
        ▲                                         │
        └──── REST /v1/public/scaidial/wave ◄──────┘  (Bearer token + X-ScaiDial-User)
```

- **Server to server only.** ScaiWave's backend talks to ScaiGrid, and its clients never call ScaiGrid directly.
- **Audio always goes through a LiveKit room.** ScaiGrid mints a LiveKit token for the user (accept, dial-out, rejoin). ScaiWave hands the token to the client, which joins at the `livekit_url` returned (`wss://rtc.scailabs.ai`).
- **Publish the microphone as soon as you join.** A caller on a phone or the carrier is only answered (their phone stops ringing) once the room has an audio track they can hear. Publish the mic track right after connecting. If no audio track from the accepting user appears within **10 seconds** of the accept, ScaiGrid removes that participant from the room, sends `call.ended` with reason `answer_no_audio`, and treats the call as unanswered (the extension's `no_answer` forward or timeout action, by default voicemail). If the microphone isn't available, tell the user rather than letting the call fall through silently.
- **No SIP between ScaiWave and ScaiGrid.**

## 2. Authentication and access

Every request carries:

| Header | Value |
|---|---|
| `Authorization` | `Bearer <connector token>` |
| `X-ScaiDial-User` | The user ScaiWave acts for: a ScaiKey id (`usr_…`) or a ScaiGrid user id. Required on all endpoints except `GET /events` and `GET /rings`. |

- ScaiWave has already authenticated the user, and ScaiGrid trusts the assertion. Keep the token server-side only.
- **Tenant allowlist.** The user must be active and their tenant enabled for the connector, otherwise 403 `scaidial.wave_tenant_not_allowed`. The connector is enabled for all tenants. A tenant only has something to show once ScaiDial is set up for it (an admin creates `wave` extensions for its users, plus numbers and a trunk for outside calls); until then its users get an empty `GET /me` and no rings or events.
- **Who may act on an extension:**
  - **owner:** the user a `wave` extension belongs to (`extension.target_ref`).
  - **owner / manage / answer:** users given one of these roles on the extension by an admin.

| Action | Roles |
|---|---|
| Ring / answer / decline | owner, manage, answer |
| Read an extension | owner, manage, answer, observe |
| Change settings, forward rules, dial out from it, delete voicemail | owner, manage |
| Read voicemail (list, get, audio, mark listened) | owner, manage, answer, observe |
| Call control (hangup, hold, transfer, rejoin) | Any party to the call: the user placed it, holds a leg in it, or has a role on an extension with a leg in it |

Group grants count: a grant to a group (ScaiGrid or ScaiKey group id) applies to its direct members, and `ring.user_ids` lists them. Nested groups are not expanded.

**Credentials:**
- The connector token and the webhook signing secret go through **ScaiVault**, never chat or email.
- To rotate, ask ScaiGrid ops. Both values change together, with a short cut-over.

## 3. Responses and errors

Success: `{"status":"success","data":{…},"meta":{"request_id":…}}`. 204 endpoints have no body.

Error: `{"status":"error","error":{"code":"scaidial.…","message":"…"},"meta":{…}}`. Exception: a request that fails body or query validation gets HTTP 422 as `{"detail":[…]}`, FastAPI's own shape. The client maps that to code `validation_error`.

| HTTP | Codes |
|---|---|
| 400 | `scaidial.wave_user_required`, `scaidial.invalid_request`, `scaidial.invalid_caller_id` |
| 401 | `scaidial.wave_unauthorized` (bad or missing token) |
| 403 | `scaidial.permission_denied`, `scaidial.wave_tenant_not_allowed` |
| 404 | `scaidial.wave_user_unknown`, `scaidial.extension_not_found`, `scaidial.ring_not_found`, `scaidial.call_not_found`, `scaidial.leg_not_found`, `scaidial.voicemail_not_found` |
| 409 | `scaidial.ring_already_answered`, `scaidial.call_ended` |
| 422 | `scaidial.invalid_destination`, `scaidial.invalid_number`, `scaidial.no_outbound_trunk`, `scaidial.voicemail_unavailable` |
| 429 | `scaidial.rate_limited` |
| 502 | `scaidial.originate_failed` |
| 503 | `scaidial.wave_connector_disabled`, `scaidial.livekit_not_configured` |

## 4. Events (webhooks)

ScaiGrid POSTs each event as JSON to your webhook URL:

```json
{ "id": "1791622220244-0", "type": "ring", "tenant_id": "tnt_…",
  "created_at": "2026-10-10T08:50:20.244Z", "data": { … } }
```

| Header | |
|---|---|
| `X-ScaiDial-Event` | event type |
| `X-ScaiDial-Event-Id` | same as `id`; use it to dedupe |
| `X-ScaiDial-Timestamp` | unix seconds |
| `X-ScaiDial-Signature` | `v1=<hex HMAC-SHA256(secret, "<timestamp>." + raw body)>` |

**Verifying:**
- Compare the signature in constant time over the raw bytes.
- Reject timestamps more than 5 minutes off.
- `verify_webhook()` in the client does both.
- Answer with any 2xx within 3 s, and do slow work after responding.

**Delivery is best-effort:** one attempt, no retries. Every event is also written to a replay stream, so reconcile:
- `GET /events?after=<last id you processed>`, on startup and periodically. This returns the same envelopes, oldest first. Ids sort in time order.
- `GET /rings` for rings still waiting for an answer.

| Type | When | `data` |
|---|---|---|
| `ring` | An extension the user may answer is ringing | `leg_id`, `call_id`, `tenant_id`, `direction` (`inbound` \| `internal` \| `internal_transfer`), `extension_id`, `extension_number`, `extension_display_name`, `user_ids` (ScaiGrid ids that may answer), `scaikey_user_ids`, `from_e164`, `from_display`, `from_user_id`, `from_extension_id`, `livekit_room`, `ring_started_at`, `ring_timeout_s`, `correlation_id` |
| `ring.cancelled` | Ringing stopped | `leg_id`, `call_id`, `tenant_id`, `reason`: `claimed` (+ `claimed_by_user_id`), `ring_timeout`, `decline`, `caller_hangup` |
| `call.answered` | A user accepted and their audio track is in the room (the caller is connected) | `leg_id`, `call_id`, `tenant_id`, `extension_id`, `user_id` |
| `call.ended` | A ScaiWave-side leg ended | `leg_id`, `call_id`, `tenant_id`, `extension_id`, `reason` (e.g. `wave_hangup`, `room_finished`, `transferred:<to>`, `answer_no_audio`: accepted but no audio within 10 s) |
| `voicemail.left` | A message was stored | `voicemail_id`, `tenant_id`, `extension_id`, `extension_number`, `user_ids` (ScaiGrid ids that may read it: owner / manage / answer / observe), `scaikey_user_ids`, `call_id`, `from_e164`, `from_display`, `duration_ms`, `has_transcript`, `has_summary`, `created_at` |
| `recording.started` · `recording.paused` · `recording.resumed` | A call's recording changed state (`started` also has `trigger`: `user` \| `policy`; `resumed` has `reason`: `user` \| `auto`) | `recording_id`, `call_id`, `tenant_id`, `state`, `user_ids`, `scaikey_user_ids` (everyone who may see the recording) |
| `recording.stopped` | Recording ended; the audio follows in `recording.ready` | same, plus `reason`: `user` \| `call_ended` \| `merged` |
| `recording.ready` | The audio can be played | same, plus `duration_ms`, `size_bytes` |
| `transcript.ready` | The transcript and summary are done (tenant has recording transcripts on) | same, plus `transcript_state` (`ready` \| `failed`), `turns`, `has_summary` |
| `recording.deleted` | Deleted; drop any cached copy | same, plus `reason`: `user` \| `retention` \| `admin` \| `erasure` |
| `call.merged` | The user merged two calls | `tenant_id`, `call_id` (kept), `merged_call_id`, `merged_leg_id` (the user's leg that ended), `bridge_id` |
| `conference.changed` | The user joined, split, swapped or held a conference | `tenant_id`, `leg_id`, `focus`, `merged_calls` [`{bridge_id, call_id, on_hold}`], `others_on_hold` |
| `transfer.consult` | An attended transfer changed state | `consult_id`, `call_id`, `tenant_id`, `transferor_leg_id`, `target`, `state` (`ringing` \| `talking` \| `completed` \| `cancelled` \| `ended`), `ended_reason` (`completed`, `cancelled`, `no_answer`, `target_hangup`, `transferor_hangup`, `caller_hangup`, `timeout`, `error`) |

## 5. Ring lifecycle

1. A call reaches a `wave` extension: from a DID through the dialplan, from another extension, or by transfer.
2. Before anything rings, ScaiGrid applies the extension's own policy:
   - **DND on** (and `dnd_until` not passed): straight to voicemail, and no `ring` is sent.
   - **Forward rule `always`:** forwards to that extension, and no `ring` is sent.
3. ScaiGrid sends `ring`, and **`ring_timeout_s` is your deadline**. Fan out to every session and device of every user in `user_ids`; ScaiGrid doesn't know about devices.
4. One of these happens:
   - **Accept:** `POST /rings/{leg_id}/accept` returns the room and token. Join immediately and publish the microphone (see *Audio* above): the caller is answered only when your audio track appears. The first accept wins; a later accept or decline gets **409**. `ring.cancelled(claimed)` follows right away, so you can stop ringing other devices; `call.answered` follows once your audio track is in the room, or `call.ended(answer_no_audio)` after 10 s without it.
   - **Decline:** `POST /rings/{leg_id}/decline` with `fallback`:
     - `voicemail` (default): this extension's voicemail.
     - `forward` + `forward_to`: rings that extension number.
     - `next`: continue the tenant's dialplan.
   - **Timeout:** `ring.cancelled(ring_timeout)`. If the extension has a `no_answer` forward rule, that applies; otherwise its `timeout_action` applies: `voicemail` (default), `forward` to `timeout_forward_to`, or `hangup`.
   - **Caller hangs up:** `ring.cancelled(caller_hangup)`.
5. An unknown or expired ring returns 404 (`scaidial.ring_not_found`).

**Assistant pickup:**
- `assistant_pickup_after_s` is stored on the extension and returned with it. The timer and the assistant are ScaiWave's.
- To ScaiGrid, an assistant pickup is an ordinary accept: call `accept` and put the assistant into the room with the returned token. You can accept again later with the same identity through `POST /calls/{call_id}/token`, for example so the user can take over.

## 6. Endpoints

Base URL: `https://scaigrid.scailabs.ai/v1/public/scaidial/wave`. "U" means `X-ScaiDial-User` is required.

### Rings and events

| | Method + path | Body / query | Returns |
|---|---|---|---|
| | `GET /events` | `after` (default `0`), `limit` 1–1000 (default 100) | `{items: [envelope], last_id}` |
| (U) | `GET /rings` | without U: all pending rings, optional `tenant_id`; with U: only rings that user may answer | `{items: [ring data]}` |
| U | `POST /rings/{leg_id}/accept` | `{device?}` (≤64 chars) | `{call_id, leg_id, room_name, livekit_url, livekit_token, participant_identity}` |
| U | `POST /rings/{leg_id}/decline` | `{fallback: "voicemail"\|"forward"\|"next", forward_to?}` (forward requires `forward_to`, an enabled extension number in the tenant, else 400) | `{leg_id, fallback}` |

### User, extensions and settings

| | Method + path | Body / query | Returns |
|---|---|---|---|
| U | `GET /me` | | `{user_id, scaikey_id, tenant_id, name, extensions: [extension]}` |
| U | `GET /extensions/{id}` | | extension |
| U | `PATCH /extensions/{id}` | see below | extension |
| U | `GET /extensions/{id}/forward-rules` | | `{items: [{id, condition, condition_params, forward_to, priority, enabled}]}` |
| U | `POST /extensions/{id}/forward-rules` | `{condition: "always"\|"no_answer", forward_to: "<ext number>", priority=100, enabled=true}` | rule (201) |
| U | `DELETE /extensions/{id}/forward-rules/{rule_id}` | | 204 |
| U | `GET /directory` | `q` (number or name contains), `limit` 1–100 | `{items: [{id, number, type, display_name}]}`, enabled extensions in the user's tenant |

**Extension object:**
- Identity and access: `id`, `number`, `type`, `display_name`, `enabled`, `role` (the user's role).
- Availability: `dnd`, `dnd_until`.
- Ringing: `ring_timeout_s`, `timeout_action`, `timeout_forward_to`, `assistant_pickup_after_s`.
- Voicemail and caller ID: `voicemail_greeting_text`, `outbound_caller_id_e164`.

**PATCH fields** (all optional; send only what changes):

| Field | Rule |
|---|---|
| `display_name` | ≤160 chars; `""` clears it |
| `dnd` | bool |
| `dnd_until` | ISO-8601 |
| `clear_dnd_until` | `true` clears `dnd_until` |
| `ring_timeout_s` | 5–120 |
| `timeout_action` | `voicemail` \| `forward` \| `hangup`; `forward` requires `timeout_forward_to` (an extension number) |
| `assistant_pickup_after_s` | 0–120 |
| `clear_assistant_pickup` | `true` clears `assistant_pickup_after_s` |
| `voicemail_greeting_text` | ≤500 chars; `""` restores the default greeting |
| `outbound_caller_id_e164` | must be one of the tenant's numbers, else 400 `scaidial.invalid_caller_id`; `""` clears it. Presented as the caller ID on calls dialled out from this extension. |

**Forward rules:** `forward_to` must be another extension number in the tenant. To forward to a phone number, the admin creates an `external` extension for it.

### Calls

| | Method + path | Body / query | Returns |
|---|---|---|---|
| U | `POST /calls` | `{from_extension_id, to}`, where `to` is E.164 (`+31…`) or an extension number | 201 `{call_id, leg_id, kind: "pstn"\|"internal", room_name, livekit_url, livekit_token, participant_identity}` |
| U | `GET /calls/active` | | `{items: [call]}`: calls not ended that involve the user's extensions or that the user placed |
| U | `GET /calls/history` | `limit` 1–200 (default 50), `before` (ISO-8601) | `{items: [call]}`, newest first |
| U | `POST /calls/{call_id}/token` | | `{call_id, room_name, livekit_url, livekit_token, participant_identity}` |
| U | `POST /legs/{leg_id}/hangup` | | 204 |
| U | `POST /legs/{leg_id}/hold` / `unhold` | | 204 |
| U | `POST /legs/{leg_id}/transfer` | `{to, mode: "blind"}` | `{call_id, transferred_to}` |
| U | `POST /legs/{leg_id}/transfer` | `{to, mode: "attended"}` | `{consult_id, call_id, transferor_leg_id, target, state: "ringing", ended_reason}` |
| U | `POST /calls/merge` | `{keep_leg_id, merge_leg_id}` | `{call_id, merged_call_id, bridge_id}` |
| U | `GET /legs/{leg_id}/conference` | | `{focus, merged_calls: [{bridge_id, call_id, on_hold}], others_on_hold}` |
| U | `POST /legs/{leg_id}/conference/focus` | `{focus: "all"\|"kept"\|"none"\|"<bridge_id>"}` | the conference (same shape) |
| U | `POST /legs/{leg_id}/conference/drop` | `{bridge_id}` | `{dropped}` |
| U | `GET /transfers/{consult_id}` | | the consultation (same shape) |
| U | `POST /transfers/{consult_id}/complete` | | the consultation, `state: "completed"` |
| U | `POST /transfers/{consult_id}/cancel` | | the consultation, `state: "cancelled"` |

**Call object:**
- Who and what: `id`, `direction`, `state` (`ringing` \| `active` \| `ended`), `from_e164`, `from_display`, `from_user_id`, `livekit_room`.
- What was called: `to_number` (the E.164 number dialled, the extension number for internal calls, our DID for inbound calls) and `to_display` (the extension's name or the DID's label, when known).
- Recording: `recording` (`{id, state}` while the call is being recorded or paused, else `null`), `recording_ids` (every recording of the call that still exists, oldest first) and `captions` (live captions running).
- The other party, as the requesting user sees the call: `peer_number` and `peer_display`. For a call the user placed (from the app or their desk phone) this is what they dialled; otherwise it is the caller (E.164, or the caller's extension number for internal calls). `peer_number` can be passed straight to `POST /calls` to redial. Calls from before 2026-10-13 have no `to_number`, so `peer_*` is `null` for calls the user placed.
- Timing and outcome: `started_at`, `ended_at`, `duration_ms`, `end_reason`.
- `legs`: each leg has `id`, `role` (`caller` \| `answerer` \| `callee`), `extension_id`, `extension_type`, `state`, `participant_identity`, `answered_at`, `ended_at`.

**Semantics:**
- **Dial-out** (`POST /calls`):
  - The user must be owner or manage on `from_extension_id`. The limit is 30 calls per minute per tenant (429 when exceeded).
  - **E.164:** dials out through the tenant's trunk. 422 `scaidial.invalid_number` if the number isn't valid in its country (e.g. a digit missing; show the message to the user); 422 `scaidial.no_outbound_trunk` if the tenant has no trunk; 502 if the originate fails. Transfers to an E.164 number get the same `scaidial.invalid_number` check.
  - **Extension number:** an internal call. The callee rings exactly as in §5, and on no answer the caller reaches the callee's voicemail.
  - Join the room right away. The far end is added when it answers.
- **Hold** puts the given leg's party on hold: nobody hears them and they hear music on hold instead of the call. To put the caller on hold, hold *their* leg; `unhold` lifts it.
- **Transfer** is blind, and `leg_id` is the user's *own* leg:
  - The other party stays in the room, and the target is added.
  - An extension target rings like §5, with voicemail on no answer. An E.164 target is dialled out through the tenant trunk and gets its own leg (`xfer-…`), so it can be held or hung up like any other party.
  - Then the user's leg ends.
- **Attended transfer** (`mode: "attended"`): the user talks to the target first. `leg_id` is the user's *own* leg; only that user can complete, cancel or read the consultation.
  - The other party hears "one moment please" (in the tenant's call language), then music on hold (the user's extension's playlist), and nobody hears them, until the consultation ends.
  - The target rings: an extension number (like §5, but **no voicemail** on no answer) or an E.164 number through the tenant trunk. The user hears ringback for E.164 targets.
  - `transfer.consult` reports `ringing` → `talking` when the target answers. If nobody answers (about 30 s) the user hears "no answer" and is connected to the caller again (`cancelled`, `no_answer`).
  - `complete` (only while `talking`): the caller and the target are connected, and the user's leg ends (`transferred:<to>`).
  - `cancel` (while `ringing` or `talking`): the target is dropped and the user is back with the caller.
  - If the user hangs up while `talking`, the transfer completes. If the target hangs up, the user is back with the caller. If the caller hangs up, the consultation ends (`ended`, `caller_hangup`) and the user and the target stay connected.
  - One consultation per call at a time (409 `scaidial.transfer_declined` otherwise). An unanswered consultation is cancelled after 15 minutes.
- **Merge calls** (`POST /calls/merge`): a three-way (or bigger) call from two of the user's calls. Both legs are the user's *own* legs in two different calls.
  - Everyone on `merge_leg_id`'s call joins `keep_leg_id`'s call: all parties hear each other. The user continues in the kept call only, so their `merge_leg_id` leg ends (`merged:<call_id>`); legs on hold in either call are taken off hold.
  - The calls keep their own rooms, joined by a bridge (participants `bridge-…` and `sip_scaidial-bridge-<n>`); keep using the kept call's room and token. Further calls can be merged into the same call (up to 8).
  - When someone hangs up, the others stay connected (conference rules). A side with no one left closes, and a call left with one party ends.
  - Refused with 409 `scaidial.transfer_declined` if either call has an attended transfer in progress, or the calls are already merged.
- **Conference controls** (`leg_id` = the user's leg in the kept call). `focus` decides who talks to whom:

  | Button | `focus` | User talks with | On hold |
  |---|---|---|---|
  | Join | `all` | everyone | nobody |
  | Split | `kept` | the kept call's parties | the merged calls |
  | Swap / talk to one call | `<bridge_id>` | that merged call | the kept call's other parties and other merged calls |
  | Hold | `none` | nobody | everyone |

  - Held parties hear "one moment please" (tenant call language), then music on hold (the user's extension's playlist, see below), and nobody hears them. Holds are applied before anyone is reconnected, so a swap never lets the two sides hear each other.
  - **Drop** (`conference/drop`) hangs up one merged call; the rest carries on.
  - Automatic: if the user leaves, or the merged call they were talking to hangs up, everyone is joined again. Merging another call also joins everyone first. Attended transfer from a split or held conference is refused until it is joined again.
- **Hangup** ends one leg:
  - Fewer than two parties left (e.g. the PSTN caller alone in the room): the room is closed and the call ends for everyone.
  - Hanging up the caller leg while extensions are still ringing cancels those rings.
- **Rejoin** returns a fresh token with the user's *existing* participant identity, so on reconnect LiveKit replaces the old connection instead of adding a duplicate.

### Music on hold

Held parties (plain hold, a caller waiting during an attended transfer, the parts of a conference that are split off) hear music. It comes from the **holding extension's** playlist (`moh_playlist_id`), else the tenant default (set by tenant admins in ScaiGrid), else the built-in public-domain classics. Users can keep **personal playlists** and upload their own music:

| | Method + path | Body / query | Returns |
|---|---|---|---|
| U | `GET /moh/playlists` | | `{items: [playlist]}`: built-in + tenant playlists (read-only) and the user's own |
| U | `POST /moh/playlists` | `{name, shuffle?}` | personal playlist (201); at most 5 per user |
| U | `PATCH /moh/playlists/{id}` | `{name?, shuffle?}` | playlist (own only) |
| U | `DELETE /moh/playlists/{id}` | | 204; tracks are deleted, extensions using it fall back to the tenant default |
| U | `GET /moh/playlists/{id}/tracks` | | `{items: [track]}` |
| U | `POST /moh/playlists/{id}/tracks` | multipart: `file`, `rights_confirmed=true` (required), `title?`, `artist?` | track (201) |
| U | `PUT /moh/playlists/{id}/order` | `{track_ids: [...]}` (every track once) | `{items: [track]}` |
| U | `DELETE /moh/tracks/{id}` | | 204 |
| U | `GET /moh/tracks/{id}/audio` | | `audio/flac` (preview) |

- **Playlist:** `id`, `name`, `scope` (`platform` \| `tenant` \| `personal`), `owner_user_id`, `shuffle`, `is_default`, `track_count`, `duration_ms`, `editable`.
- **Track:** `id`, `playlist_id`, `title`, `artist`, `duration_ms`, `size_bytes`, `position`, `license`, `source_url`, `created_at`.
- **Uploads:** MP3, WAV, FLAC, OGG/Opus, M4A/AAC; up to 40 MB and 15 minutes, at least 5 seconds, 50 tracks per playlist. Tracks are converted to 16 kHz mono and levelled to a calm volume. The uploader must confirm they may use the music (`rights_confirmed`); it is recorded with their user id. Errors are 400 with a readable message.
- **Choosing:** `PATCH /extensions/{id}` with `moh_playlist_id` (a built-in, tenant or the user's own playlist; `""` = tenant default).
- Shuffle plays the playlist in random order, otherwise in `position` order; it loops.

### Voicemail (read: owner / manage / answer / observe; delete: owner / manage)

| | Method + path | Body / query | Returns |
|---|---|---|---|
| U | `GET /voicemail` | `extension_id?`, `unheard=false`, `limit` 1–200 | `{items: [voicemail]}`, newest first |
| U | `GET /voicemail/{id}` | | voicemail |
| U | `GET /voicemail/{id}/audio` | | `audio/wav`, 16 kHz mono PCM |
| U | `POST /voicemail/{id}/listened` | | 204 |
| U | `DELETE /voicemail/{id}` | | 204; the recording is deleted too |

**Voicemail object:** `id`, `extension_id`, `call_id`, `from_e164`, `from_user_id`, `from_display`, `duration_ms`, `transcript`, `summary`, `listened_at`, `created_at`.

**How messages are recorded:**
- The caller hears the greeting: `voicemail_greeting_text`, or a default naming the extension.
- A tone follows, then recording. Recording stops when the caller hangs up, after about 6 s of silence following speech, or after 3 minutes.
- A message is only stored if the caller spoke for at least 1 s.
- `transcript` is filled only when the tenant has opted in to voicemail transcription; otherwise it is `null`.
- `summary` is a one- or two-sentence summary of the transcript, in its language: who called, what they want, any call-back number or time, and urgency. It is generated on ScaiLabs-hosted models and only when there is a transcript; otherwise it is `null`.

### Call recording

Recording policy is set by tenant admins in ScaiGrid: off, on request, or every call, with per-extension overrides. Every party hears an announcement before anything is recorded; parties who join later hear it on their own. Full behaviour: [call recording](../concepts/call-recording).

| | Method + path | Body / query | Returns |
|---|---|---|---|
| U | `POST /calls/{call_id}/recording/start` · `/pause` · `/resume` · `/stop` | | recording object. `start` returns once the announcement has played (a few seconds) |
| U | `POST /calls/{call_id}/captions/start` · `/stop` | | `{captions, recording_id}` (`recording_id` is null for captions without recording) |
| U | `GET /recordings` | `call_id`, `extension_id`, `before` (ISO-8601), `limit` 1–200 | `{items: [recording]}`, newest first |
| U | `GET /recordings/{id}` | | recording |
| U | `GET /recordings/{id}/audio` | `Range` header supported | `audio/ogg` (Opus, 16 kHz mono); 206 for ranges |
| U | `GET /recordings/{id}/transcript` | | `{transcript_state, language, summary, text, turns: [{start_ms, end_ms, participant_identity, speaker_label, text}]}` |
| U | `DELETE /recordings/{id}` | | 204 |

**Who may do what:**
- **Control:** the user who placed the call, or owner / manage / answer on an extension in the call. observe also works when the tenant enables it.
- **List, get, play:** placed the call, or any role on an extension in the call.
- **Delete:** owner / manage.
- Recordings the user may not see return 404.
- Every play and delete is logged with the user id.

**Live captions** (tenant `recording_live_captions_enabled`): while a recording runs, each person's words are sent into the LiveKit room about 1–2 s after they pause.
- They arrive as **text streams on topic `lk.transcription`**, the standard LiveKit transcription topic: `room.registerTextStreamHandler('lk.transcription', …)` in the JS SDK.
- Attributes:
  - `lk.transcribed_track_id`: the speaker's audio track;
  - `lk.segment_id`;
  - `lk.transcription_final`: always `true`, with no interim results;
  - `scaidial.participant_identity`, `scaidial.speaker_label`, `scaidial.start_ms` and `scaidial.end_ms` (time in the recording).
- The sender is ScaiGrid's hidden recorder, so the stream's sender identity is empty; use the attributes.
- Only app participants who can hear the speaker get captions: never a party on hold, or the caller waiting during a consultation.
- `captions` on the call object says whether they're on.
- With tenant `recording_captions_without_recording`, `captions/start` works on an unrecorded call: everyone hears "this call is being transcribed" and nothing is stored.

**Errors:**
- 409 `scaidial.captions_not_allowed`: captions are off, captions without recording aren't allowed, or an extension in the call never records.
- 409 `scaidial.recording_not_allowed`: off by policy.
- 409 `scaidial.recording_state`: already recording, not recording, or the call ended.
- 403 `scaidial.recording_stop_not_allowed`: the tenant requires recording; pause instead.
- 503 `scaidial.recording_announcement_unavailable`: nothing is recorded.
- 504 `scaidial.recording_timeout`.
- 409 `scaidial.recording_legal_hold` on delete.

**Recording object:**
- **Identity:** `id`, `call_id`, `state` (`recording` | `paused` | `processing` | `ready` | `failed`), `trigger` (`user` | `policy`), `started_by_user_id`.
- **Timing:** `started_at`, `ended_at`, `end_reason`, `duration_ms` (audio captured; pauses excluded).
- **Media:** `format` (`ogg_opus`), `size_bytes`.
- **Who was in it:** `participants`: `participant_identity`, `kind` (`phone` | `app` | `bot` | `bridge`), `display`, `e164`, `extension_id`, `extension_number`, `leg_id`, `consent`, `joined_at_ms`, `left_at_ms`.
  - `consent` is `announced`, `confirmed`, `declined` or `not_asked`.
  - When the tenant asks outside callers to press 1, `declined` and `not_asked` parties were never recorded; show that in the UI rather than implying the whole call is on tape.
- **Gaps:** `gaps`: `at_ms` on the recording's timeline, `wall_ms` (how long), `reason` (`paused` | `recorder_restart`).
- **Retention:** `retention_until`, `legal_hold`.
- **Merges:** `merged_into_recording_id`.
- **Transcripts:** `transcript_state` (`off` | `pending` | `ready` | `failed`) and `summary` (two sentences, or `null`). The turns come from `/transcript`. Every turn is labelled with its speaker, because each party is recorded separately. Reading the transcript is logged like a play.

**Lifecycle:**
- `started` → (`paused` ↔ `resumed`)* → `stopped` → `ready`, usually a few seconds after the stop or the hang-up. Then `transcript.ready` follows, when transcripts are on.
- The recording is deleted at `retention_until` (tenant setting, default 90 days), and you receive `recording.deleted`.

## 7. Participant identities in LiveKit rooms

| Identity | Who |
|---|---|
| `wave-<user id[:8]>-<leg or call id[:8]>` | A ScaiWave user: after accept (leg id), or after dial-out (call id) |
| `vm-<leg id[:16]>` | ScaiGrid's voicemail recorder |
| `moh-…` | Music on hold player; only held parties can hear it. Ignore it |
| `rec-…` | The call recorder: a hidden participant that only listens. You won't normally see it; ignore it if you do |
| `say-rec-…` | Plays the recording announcement (to everyone, or only to a party that just joined). Short-lived; ignore it |
| `say-…`, `ivr-…` | ScaiGrid speaking into the call (announcements, IVR menus, transfer prompts); short-lived, ignore them |
| `phone-<ext>-…` | A desk phone or softphone on the phones edge |
| `bridge-…`, `sip_scaidial-bridge-<n>` | The link between merged calls: carries the other call's audio. Not a person; don't show it as one |
| `sip-<call id[:12]>`, `xfer-<leg id[:12]>`, and carrier-side SIP participants | Phone-network parties |
| `bot:<bot id>:<call id[:8]>` | A ScaiBot voice bot |

## 8. Getting started

1. Get the connector token and webhook secret from ScaiVault.
2. Give us your webhook URL.
3. Use the [Python client](./scaiwave-client):

```python
from scaidial_wave_client import ScaiDialWaveClient, verify_webhook

async with ScaiDialWaveClient("https://scaigrid.scailabs.ai", token) as dial:
    me = await dial.me("usr_…")                      # extensions + roles
    async for ev in dial.iter_events(after=last_id):  # catch up
        ...

# in your webhook handler
event = verify_webhook(raw_body, request.headers, secret)  # raises on bad signature / stale
if event["type"] == "ring":
    for uid in event["data"]["scaikey_user_ids"]:
        ...  # notify that user's devices
    # on a device's "answer":
    ans = await dial.accept_ring("usr_…", event["data"]["leg_id"], device="desktop")
    # → client joins ans["livekit_url"] with ans["livekit_token"]
```

## 9. Known limits

- Server-side "speak text into a call" is available to bots and agents through the ScaiGrid MCP tool `scaidial.call.speak`, not through this connector API.
- No per-device ringing in ScaiGrid: ScaiWave fans out to devices.
- Webhooks are single-attempt. Reconcile with `/events` and `/rings`.
