Guides

Voice — WebRTC and PSTN

AdminUpdated Sep 19, 2026

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/voice is a separate NestJS service from the main API. Its routes are served from the voice host (voice.chatlychat.com), not from api.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 workspaceId straight from the request body. It is called only over VOICE_INTERNAL_URL from 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

workspaceId (required)

uuid

UUID v7.

conversationId (required)

uuid

UUID v7. Together with the workspace this derives the room name, so a caller cannot name someone else's room.

participantIdentity (required)

string

The API passes visitor:<contactId>.

participantName

string

Optional display name.

role

enum

agent | visitor | observer. Only observer is denied publish. Default: visitor.

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

  1. Channels → New → Voice.

  2. Provider: Twilio, Telnyx or SignalWire.

  3. Paste the carrier credentials the form shows for that provider.

  4. Phone number (E.164): +18005551234.

  5. 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

provider

enum

twilio | telnyx | signalwire. A provider with no inbound receiver is rejected at write time rather than silently dropping every call.

phoneNumberE164

string

Your DID. Optional at create — the number-provisioning flow can bind one later.

twilio

object

{ accountSid, authToken }

telnyx

object

{ apiKey, publicKey?, signingSecret?, connectionId? }

signalwire

object

{ spaceUrl, projectId, apiToken } — the API is rooted at your own space.

recordingConsent

enum

none | announce | require_keypress. Default: none.

recordingConsentMessage

string

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

A call comes in (Voice) (required)

webhook URL

https://voice.chatlychat.com/v1/voice/{provider}/inbound — HTTP POST

Status callback

webhook URL

https://voice.chatlychat.com/v1/voice/{provider}/status

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

400

status

Unparseable payload, or the resolved channel has no carrier credentials configured.

404

status

No voice channel claims the dialled number (unknown-did).

401

status

Signature verification failed. Twilio and SignalWire sign the request URL, Telnyx signs timestamp|rawBody with Ed25519 — all three are checked over the exact bytes on the wire.

200

status

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 with 486 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 the voice service 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

  1. Customer dials your number.

  2. The carrier POSTs /v1/voice/{provider}/inbound. The service verifies the signature, resolves the channel by the dialled number, and creates a voice_calls row plus a conversation.

  3. If recordingConsent is require_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. announce rides along as a <Say> prelude instead.

  4. Business hours, then IVR, then queue — whichever the channel's flow config selects — produce the answering TwiML.

  5. livekit-sip accepts the INVITE and joins a room.

  6. 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

recording.enabled

bool

Master switch. Recording also requires recording.s3 — with the switch on and no bucket, nothing starts. Default: false.

recording.s3 (required)

object

{ accessKeyId, secretAccessKey, region, bucket, endpoint?, pathPrefix? }. BYOK. Defaults the prefix to workspaces/{workspaceId}/voice.

recording.provider

enum

openai-whisper | deepgram. Omit to transcribe with the workspace's own AI configuration instead.

recording.apiKey

string

BYOK STT key, forwarded to the AI service alongside recording.provider. Both must be present to take effect.

recordingConsent

enum

Top-level, not under recording. announce plays a disclosure; require_keypress gates the whole call on a keypress. Default: none.

recordingConsentMessage

string

Top-level. Your disclosure wording.

How it works under the hood:

  1. On LiveKit's room_started, the consent gate is evaluated. For a require_keypress channel it fails closed — an unknown consent state does not record.

  2. If it passes, a room-composite egress starts, audio_only, writing an OGG file straight to your bucket.

  3. 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 on voice_calls.transcript_text with transcript_provider.

Warning — There is no per-channel recording retention setting

recording.retentionDays does 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. recordingConsent defaults to none — set it to announce or require_keypress deliberately.

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

greetingText / greetingAudioUrl

string

Rendered as <Play> when an audio URL is set, <Say> otherwise.

options

object[]

Up to 12. Each is { digit, label, action }; digit is 0-9, * or #, and each digit may only be mapped once.

action.kind

enum

route_to_team, route_to_agent, submenu, voicemail, hangup, external_transfer. All but the last two need a target.

timeoutSec

integer

1–60. Default: 5.

maxRetries

integer

0–5. Default: 2.

noInputAction / invalidAction

object

Fallbacks. Default: hangup.

isRoot

bool

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-only for 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

5060/UDP, 5060/TCP

SIP

SIP signalling into livekit-sip. Inbound from your carrier's signalling IPs.

20000-20100/UDP

RTP (SIP)

Media for SIP legs. This is the range to open to your carrier's media IPs — not LiveKit's WebRTC range.

50000-50100/UDP

ICE/RTP (WebRTC)

LiveKit browser media.

7881/TCP

LiveKit

TCP fallback for browsers behind restrictive firewalls.

3479/UDP + 30000-30100/UDP

TURN

LiveKit's embedded TURN. 3479 rather than 3478 because a separate coturn container owns the default port on this host.

443/TCP

WSS

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]:5060

Costs

  • 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 the voice service and check the sip 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-Proto and X-Forwarded-Host reach the service, since Twilio signs the public URL it was configured with.

Warning — 404 unknown-did

No enabled voice channel has config.phoneNumberE164 equal to the dialled number. A channel created before config normalisation existed may still be storing a flat phoneNumber — re-save it.

Warning — One-way audio

Almost always NAT/firewall on the SIP media range. Open 20000-20100/UDP bidirectionally to your carrier. The LiveKit 50000-50100 range 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 for voice.recording_suppressed in the logs.

Was this page helpful?