Voice — WebRTC and PSTN
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Voice — WebRTC and PSTN
Self-hosted LiveKit for in-browser audio, plus a BYOK Twilio / Telnyx / SignalWire number bridged over SIP, with recording and transcription.
Voice in Chatly is two distinct surfaces stitched together: WebRTC audio through a self-hosted LiveKit, and PSTN voice through your own carrier number bridged into the same LiveKit room over SIP. Both land in the same agent dashboard and share the same routing.
Info — Voice runs as its own service, on its own host
apps/voiceis a separate NestJS service from the main API. Its routes are served from the voice host (voice.chatlychat.com), not fromapi.chatlychat.com. The two are different origins with different Traefik routers, so a carrier webhook pointed at the API host will 404.
Architecture at a glance
Visitor / agent browser ─── WebRTC ──┐
│
▼
LiveKit room ◄── WebRTC ─── Agent dashboard
▲
│
PSTN caller ── SIP/RTP ─ livekit-sip ─┘ (TwiML <Dial><Sip> routes the call here)
▲
Twilio / Telnyx / │
SignalWire (BYOK) ─┘Three containers in docker-compose.prod.yml:
livekit— the WebRTC SFU (livekit/livekit-server:v1.7).livekit-sip— the SIP gateway (livekit/sip:latest).voice— our NestJS bridge. Mints LiveKit tokens, renders TwiML, handles carrier webhooks, and reacts to LiveKit lifecycle webhooks to start recording and transcription.
Minting a LiveKit token
The LiveKit access token is minted by the voice service, not by the API. Its token route is served on the voice host — https://voice.chatlychat.com (VOICE_HOST_FQDN in docker-compose.prod.yml) — which is a different origin from the https://api.chatlychat.com base URL every other endpoint on this page uses. Requesting it on the API host is a 404.
Danger — Not an integration point — there is no public token-minting endpoint
The voice service registers no authentication guard on that route, and it reads
workspaceIdstraight from the request body. It is called only overVOICE_INTERNAL_URLfrom inside the deployment network, after the API has checked that the visitor is a party to the conversation. Do not publish the voice host's token route, and do not build an integration against it — use the widget route below, which is on the API host and is authenticated.
The body it takes is not what an integrator would guess:
Voice-service token request body
Name | Type | Description |
|---|---|---|
|
| UUID v7. |
|
| UUID v7. Together with the workspace this derives the room name, so a caller cannot name someone else's room. |
|
| The API passes |
|
| Optional display name. |
|
|
|
The response is { url, room, token, expiresAt, sessionId }. The room is lc:<workspaceId>:<conversationId>. The grant is roomJoin + canSubscribe + canPublishData, and canPublish for anything that is not an observer — publishing is not restricted to audio. The token default TTL is one hour; nothing on this path shortens it.
The reachable path
For a widget visitor, the public surface is on the API host:
POST /v1/widget/video-calls/token
POST /v1/widget/video-calls/end
Both take { publicId, visitorToken, conversationId }, enforce the channel's origin allow-list, and confirm the visitor owns the conversation before proxying to the voice service. If the voice service is unreachable they answer 503, not 500.
Warning — The widget has no call button yet
The widget bundle ships the client methods for both routes and nothing calls them — there is no camera or phone control in the widget UI, and no widget feature flag that turns one on. A visitor-initiated call is not reachable from the shipped widget today. The agent-side and PSTN paths below are unaffected.
PSTN (BYOK phone numbers)
You bring a number from Twilio, Telnyx or SignalWire. Chatly never sells numbers and never pays your carrier.
1. Add the channel
Dashboard
Channels → New → Voice.
Provider: Twilio, Telnyx or SignalWire.
Paste the carrier credentials the form shows for that provider.
Phone number (E.164):
+18005551234.Save.
API
POST /v1/channels — Bearer token
POST /v1/channels
Authorization: Bearer …
Idempotency-Key: 2ad9…
Content-Type: application/json
{
"type": "voice",
"name": "Voice — Support line",
"publicId": "acme-voice",
"config": {
"provider": "twilio",
"accountSid": "AC…",
"authToken": "…",
"phoneNumber": "+18005551234"
}
}Voice is the one channel type whose config is rewritten on write. The flat shape above is normalised to the canonical nested shape before storage:
{
"provider": "twilio",
"phoneNumberE164": "+18005551234",
"twilio": { "accountSid": "AC…", "authToken": "…" }
}Read the channel back and you will see the nested form. phoneNumberE164 is what the DID resolver matches on; phoneNumber is only accepted as an input alias.
Voice channel config (canonical, after normalisation)
Name | Type | Description |
|---|---|---|
|
|
|
|
| Your DID. Optional at create — the number-provisioning flow can bind one later. |
|
|
|
|
|
|
|
|
|
|
|
|
|
| Your own disclosure wording. Falls back to a shared default. |
Warning — Saving does not test your carrier credentials
Creating a voice channel writes the row and returns it. Nothing calls Twilio, Telnyx or SignalWire to validate what you pasted. The first thing that exercises the credentials is an inbound call's signature check, or an outbound dial.
2. Wire the carrier webhooks
Point your number at the voice host. The path segment is the provider id:
Carrier number config
Name | Type | Description |
|---|---|---|
|
|
|
|
|
|
Chatly can do this for you. Numbers bought through POST /v1/voice/numbers/purchase are auto-configured, GET /v1/voice/numbers grades each number's webhook config as ok / misconfigured / missing, and POST /v1/voice/numbers/{e164}/repair-webhooks re-points a number at this deployment.
How an inbound webhook is answered
Name | Type | Description |
|---|---|---|
|
| Unparseable payload, or the resolved channel has no carrier credentials configured. |
|
| No voice channel claims the dialled number ( |
|
| Signature verification failed. Twilio and SignalWire sign the request URL, Telnyx signs |
|
| TwiML answering the call. |
3. The SIP trunk
Warning — No bound number means no trunk, by design
The voice service provisions a LiveKit SIP inbound trunk and dispatch rule at boot, from the set of DIDs on enabled voice channels. A LiveKit trunk with an empty number list matches every dialled number, and port 5060 is publicly reachable — so when no channel carries a
phoneNumberE164, Chatly deliberately provisions nothing and tears down anything it provisioned earlier. LiveKit then answers every INVITE with486 Busy Here. That is the correct answer, not a fault.
Warning — Binding a number needs a voice-service restart
Provisioning runs once, in
bootstrap(). Adding the first DID — or removing the last one — does not reconcile the trunk until thevoiceservice is restarted. A failure to provision is logged and non-fatal, so the WebRTC side keeps working while PSTN does not.
The TwiML we return dials <Sip>{trunkUri}?X-Room={ourRoom}</Sip>. LiveKit does not pick a room from a SIP header, so the trunk maps X-Room into a participant attribute, the dispatch rule creates a room of its own under the lc-voice- prefix, and the participant_joined webhook rebinds voice_calls.livekit_room to the room LiveKit actually made.
The trunk URI comes from LIVEKIT_SIP_TRUNK_URI, defaulting to sip:livekit-sip@${PUBLIC_HOST_FQDN_BASE}:5060.
4. What happens on a ring
Customer dials your number.
The carrier POSTs
/v1/voice/{provider}/inbound. The service verifies the signature, resolves the channel by the dialled number, and creates avoice_callsrow plus a conversation.If
recordingConsentisrequire_keypress, the call stops here and gets a<Gather numDigits="1">; the pressed digit comes back to/v1/voice/twilio/consent/{callId}and re-enters the decision tree.announcerides along as a<Say>prelude instead.Business hours, then IVR, then queue — whichever the channel's flow config selects — produce the answering TwiML.
livekit-sipaccepts the INVITE and joins a room.The agent is rung; answering joins their browser to the same room.
Flow configuration is read and written at GET/PATCH /v1/voice/channels/{channelId}/flow-config.
Recording + transcription
Off by default, configured per channel under config.recording.
Recording config
Name | Type | Description |
|---|---|---|
|
| Master switch. Recording also requires |
|
|
|
|
|
|
|
| BYOK STT key, forwarded to the AI service alongside |
|
| Top-level, not under |
|
| Top-level. Your disclosure wording. |
How it works under the hood:
On LiveKit's
room_started, the consent gate is evaluated. For arequire_keypresschannel it fails closed — an unknown consent state does not record.If it passes, a room-composite egress starts,
audio_only, writing an OGG file straight to your bucket.On
egress_ended, the voice service hands the audio to the internal AI service, so the transcription is cost-metered and quota-gated like every other model call, and stores the result onvoice_calls.transcript_textwithtranscript_provider.
Warning — There is no per-channel recording retention setting
recording.retentionDaysdoes not exist. Recordings live in your bucket under your own lifecycle rules. Chatly's retention policies are workspace-scoped and cover conversations and messages, not audio objects.
Warning — Two-party consent is the rule, not the exception
In many US states and most of the EU you must inform every party that a call is being recorded.
recordingConsentdefaults tonone— set it toannounceorrequire_keypressdeliberately.
Recordings are read back through GET /v1/voice/calls/{id}/recording-url, which returns a short-lived URL.
Outbound calls
POST /v1/voice/calls/outbound — Bearer token
Body: { toE164, channelId, conversationId, contactId }. Requires conversations:write and an Idempotency-Key. The API places the call through your carrier with your BYOK credentials, and returns a room-scoped LiveKit token for the agent, who is already in the room when the answered leg bridges in over the same <Dial><Sip> shape.
Live calls can also be transferred — warm or cold — via POST /v1/voice/calls/{id}/transfer and POST /v1/voice/calls/{id}/transfer/complete, ended with POST /v1/voice/calls/{id}/end, and dispositioned with POST /v1/voice/calls/{id}/disposition.
Routing + IVR
Voice routes through the same routing engine as chat. IVR menus are first-class rows:
GET /v1/voice/ivr-menus — Bearer token
POST /v1/voice/ivr-menus — Bearer token
PATCH /v1/voice/ivr-menus/{id} — Bearer token
DELETE /v1/voice/ivr-menus/{id} — Bearer token
GET /v1/voice/ivr-menus/{id}/twiml — Bearer token
Reads need channels:read; writes need channels:write. The twiml route renders an illustrative preview from the saved row for the builder's preview pane — it is not the TwiML the live call path emits.
IVR menu
Name | Type | Description |
|---|---|---|
|
| Rendered as |
|
| Up to 12. Each is |
|
|
|
|
| 1–60. Default: |
|
| 0–5. Default: |
|
| Fallbacks. Default: |
|
| The menu an inbound call enters. |
Submenus may legally form a cycle ("back to main menu"), so the dispatcher counts hops per call and falls through to invalidAction after 5 — a cycle degrades to a normal fallback rather than looping forever.
Unanswered calls fall to voicemail (GET /v1/voice/voicemails, POST /v1/voice/voicemails/{id}/listened) and waiting callers are visible at GET /v1/voice/queue and GET /v1/voice/queue/stats.
Info — A voice conversation has no outbound message path
Voice is declared
inbound-onlyfor message delivery. A "reply" to a voice conversation is a transcript note in the dashboard, not something pushed at a provider — so the composer does not send on a voice thread.
Self-host: ports and services
Parameters
Name | Type | Description |
|---|---|---|
|
| SIP signalling into |
|
| Media for SIP legs. This is the range to open to your carrier's media IPs — not LiveKit's WebRTC range. |
|
| LiveKit browser media. |
|
| TCP fallback for browsers behind restrictive firewalls. |
|
| LiveKit's embedded TURN. 3479 rather than 3478 because a separate coturn container owns the default port on this host. |
|
| LiveKit signalling for browsers, served through Traefik. |
Both LiveKit and the SIP gateway are configured with use_external_ip: true, so they announce the public address rather than their container IP in SDP.
For Twilio you configure an elastic SIP trunk whose termination URI is the gateway:
Termination URI: sip:[email protected]:5060Costs
WebRTC calls: free — the SFU is yours.
PSTN minutes: your carrier's bill. We never mark up.
STT: your OpenAI / Deepgram bill, or your workspace AI budget.
Storage: your S3 bucket. We never mark up.
Troubleshooting
Warning — Every inbound call gets 486 Busy Here
LiveKit has no SIP trunk. Either no voice channel carries a
phoneNumberE164, or one was added since the voice service last booted. Bind the number, then restart thevoiceservice and check thesip provisioned trunk=…line in its startup log.
Warning — 401 on every carrier webhook
Signature verification. Check that the credentials on the channel are the ones the number actually belongs to, and — behind a proxy — that
X-Forwarded-ProtoandX-Forwarded-Hostreach the service, since Twilio signs the public URL it was configured with.
Warning — 404 unknown-did
No enabled voice channel has
config.phoneNumberE164equal to the dialled number. A channel created before config normalisation existed may still be storing a flatphoneNumber— re-save it.
Warning — One-way audio
Almost always NAT/firewall on the SIP media range. Open
20000-20100/UDPbidirectionally to your carrier. The LiveKit50000-50100range is the browser side and will not fix a carrier leg.
Info — Recording never starts on a require_keypress channel
The gate fails closed on an unknown consent state. If the consent webhook never fired — a missing
/v1/voice/twilio/consent/{callId}callback, a dropped digit — the call connects and is deliberately not recorded. Look forvoice.recording_suppressedin the logs.