ScaiWave connector
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.
1. Architecture#
1 2 3 4 5 6 7 8 9 | |
- 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_urlreturned (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.endedwith reasonanswer_no_audio, and treats the call as unanswered (the extension'sno_answerforward 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 createswaveextensions for its users, plus numbers and a trunk for outside calls); until then its users get an emptyGET /meand no rings or events. - Who may act on an extension:
- owner: the user a
waveextension belongs to (extension.target_ref). - owner / manage / answer: users given one of these roles on the extension by an admin.
- owner: the user a
| 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:
1 2 | |
| 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 /ringsfor 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#
- A call reaches a
waveextension: from a DID through the dialplan, from another extension, or by transfer. - Before anything rings, ScaiGrid applies the extension's own policy:
- DND on (and
dnd_untilnot passed): straight to voicemail, and noringis sent. - Forward rule
always: forwards to that extension, and noringis sent.
- DND on (and
- ScaiGrid sends
ring, andring_timeout_sis your deadline. Fan out to every session and device of every user inuser_ids; ScaiGrid doesn't know about devices. - One of these happens:
- Accept:
POST /rings/{leg_id}/acceptreturns 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.answeredfollows once your audio track is in the room, orcall.ended(answer_no_audio)after 10 s without it. - Decline:
POST /rings/{leg_id}/declinewithfallback: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 ano_answerforward rule, that applies; otherwise itstimeout_actionapplies:voicemail(default),forwardtotimeout_forward_to, orhangup. - Caller hangs up:
ring.cancelled(caller_hangup).
- Accept:
- An unknown or expired ring returns 404 (
scaidial.ring_not_found).
Assistant pickup:
assistant_pickup_after_sis 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
acceptand put the assistant into the room with the returned token. You can accept again later with the same identity throughPOST /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) andto_display(the extension's name or the DID's label, when known). - Recording:
recording({id, state}while the call is being recorded or paused, elsenull),recording_ids(every recording of the call that still exists, oldest first) andcaptions(live captions running). - The other party, as the requesting user sees the call:
peer_numberandpeer_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_numbercan be passed straight toPOST /callsto redial. Calls from before 2026-10-13 have noto_number, sopeer_*isnullfor calls the user placed. - Timing and outcome:
started_at,ended_at,duration_ms,end_reason. legs: each leg hasid,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_numberif the number isn't valid in its country (e.g. a digit missing; show the message to the user); 422scaidial.no_outbound_trunkif the tenant has no trunk; 502 if the originate fails. Transfers to an E.164 number get the samescaidial.invalid_numbercheck. - 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.
- The user must be owner or manage on
-
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;
unholdlifts it. -
Transfer is blind, and
leg_idis 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_idis 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.consultreportsringing→talkingwhen 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 whiletalking): the caller and the target are connected, and the user's leg ends (transferred:<to>).cancel(whileringingortalking): 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_declinedotherwise). 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 joinskeep_leg_id's call: all parties hear each other. The user continues in the kept call only, so theirmerge_leg_idleg 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-…andsip_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_declinedif either call has an attended transfer in progress, or the calls are already merged.
- Everyone on
-
Conference controls (
leg_id= the user's leg in the kept call).focusdecides who talks to whom:Button focusUser talks with On hold Join alleveryone nobody Split keptthe 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 nonenobody 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}withmoh_playlist_id(a built-in, tenant or the user's own playlist;""= tenant default). - Shuffle plays the playlist in random order, otherwise in
positionorder; 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.
transcriptis filled only when the tenant has opted in to voicemail transcription; otherwise it isnull.summaryis 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 isnull.
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.
| 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: alwaystrue, with no interim results;scaidial.participant_identity,scaidial.speaker_label,scaidial.start_msandscaidial.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.
captionson the call object says whether they're on.- With tenant
recording_captions_without_recording,captions/startworks 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_holdon 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.consentisannounced,confirmed,declinedornot_asked.- When the tenant asks outside callers to press 1,
declinedandnot_askedparties were never recorded; show that in the UI rather than implying the whole call is on tape.
- Gaps:
gaps:at_mson 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) andsummary(two sentences, ornull). 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. Thentranscript.readyfollows, when transcripts are on.- The recording is deleted at
retention_until(tenant setting, default 90 days), and you receiverecording.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#
- Get the connector token and webhook secret from ScaiVault.
- Give us your webhook URL.
- Use the Python client:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
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
/eventsand/rings.