Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

text
1
2
3
4
5
6
7
8
9
 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
1
2
{ "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.

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:
python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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.
Updated 2026-10-14 00:46:56 View source (.md) rev 1