Voice Integration (Twilio)
Answer and place phone calls through Twilio. Outeract handles the signalling: who the call is between, which conversation it belongs to, and the call.* events that record it. The audio goes straight from Twilio to your own voice service over a Twilio Media Stream. It never passes through Outeract.
Overview
| Property | Value |
|---|---|
| Platform Type | TWILIO_VOICE |
| Provider | Twilio |
| Authentication | Account SID + Auth Token (the Auth Token itself, not an API key secret: it also validates Twilio’s webhook signatures) |
| Webhook Type | Dedicated (configured on the number automatically) |
| Carries | Calls, not messages. A voice connection never sends a message |
Prerequisites
- Twilio account (sign up)
- A voice-capable Twilio phone number
- A voice service reachable over
wss://that accepts Twilio Media Streams (for example Natter’snatter-voiceservice) - A shared secret for the stream ticket, configured on both sides
Setup Guide
Step 1: Get Twilio Credentials
- Log in to Twilio Console
- On the dashboard, copy the Account SID (starts with
AC) and the Auth Token
Step 2: Get a Phone Number
- In Twilio Console, go to Phone Numbers → Manage → Buy a number
- Pick a number with Voice capability
- Note its E.164 form (
+447700900000) and its inbound processing region
The same number can also back an SMS connection. The voice connection only sets the number’s Voice URL and status callback and leaves its SMS URL alone.
Step 3: Create Platform Connection
mutation {
createPlatformConnection(
platformName: TWILIO_VOICE
name: "Twilio Voice"
config: {
account_sid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
auth_token: "your-auth-token"
phone_number: "+447700900000"
region: "ie1"
stream_url: "wss://voice.example.com/twilio/media"
stream_secret: "a-long-random-secret"
unknown_caller_policy: "reject"
}
) {
id
webhookUrl
}
}| Field | Required | Default | Description |
|---|---|---|---|
account_sid | Yes | - | Twilio Account SID (AC + 32 hex characters) |
auth_token | Yes | - | Account Auth Token. Also used to validate webhook signatures |
phone_number | Yes | - | The number callers dial and calls are placed from (E.164) |
region | No | us1 | Twilio processing region: us1, ie1 or au1. Must match the number’s inbound region and the credentials |
stream_url | Yes | - | wss:// URL of your voice service. Answered calls stream their audio here |
stream_secret | Yes | - | Shared secret your voice service uses to verify each call’s ticket |
unknown_caller_policy | No | reject | reject or create_user. What to do when a number Outeract has never seen calls |
allowed_callers | No | empty | Optional allow list of E.164 numbers. Empty allows everyone the policy allows |
max_call_seconds | No | 1800 | Hard limit Twilio enforces on outbound calls |
Step 4: Webhooks (automatic)
When the connection is saved, Outeract points the number at its dedicated webhook URL:
- Voice URL:
<webhookUrl>?kind=voice(a call to answer) - Status callback:
<webhookUrl>?kind=status(a call ended)
Nothing needs to be set in the Twilio Console. If setup fails, check that the number exists on the account and region you configured.
Step 5: Test the Connection
Call the number from a phone that has already messaged your app (on WhatsApp or SMS). Your voice service should receive a media stream, and a call.started event should appear in that person’s conversation.
How a Call Flows
Inbound
flowchart LR
A["Caller"] --> B["Twilio"] -->|"Voice URL"| C["Outeract"]
C -->|"TwiML Connect/Stream + ticket"| B
B -->|"Media Stream (wss)"| D["Your voice service"]- Twilio posts the call to the Voice URL.
- Outeract resolves the caller to the user they already are. An existing identity on this connection is used first; otherwise the user who owns a
PHONEidentity for that number (created from WhatsApp or SMS) is linked to a new identity on this connection. The call therefore joins that person’s existing conversation with your app. - Outeract records
call.startedin that conversation. - It answers with TwiML
<Connect><Stream url="{stream_url}">carrying a signed ticket as<Parameter name="ticket">, followed by<Hangup/>. When your service closes the websocket, the call ends. - When the call finishes, Twilio’s status callback produces
call.ended.
Outbound
startCall records call.initiated, then asks Twilio to dial. When the person picks up, Twilio posts to the Voice URL with the call.initiated id, and Outeract records call.started and streams the call to stream_url exactly as for an inbound call. If they don’t pick up, only call.ended follows, with status no-answer, busy, failed or canceled.
Callers
| Situation | What happens |
|---|---|
| Known caller | Connected; the call lands in their existing conversation |
Unknown number, unknown_caller_policy: reject | Told “Sorry, I don’t recognise this number. Message me first and then call.” and hung up |
Unknown number, unknown_caller_policy: create_user | A new user is created and the call is connected |
| Withheld caller ID | Told it can’t take calls from withheld numbers, and hung up |
Not on allowed_callers (when set) | Rejected |
Caller ID can be spoofed. Twilio’s STIR/SHAKEN verdict (StirVerstat) is recorded on call.started as stir_verstat, so your service can decide how much to trust it.
The Stream Ticket
Your voice service must not trust anything the caller controls. Every stream carries a ticket that Outeract signs with stream_secret:
base64url(json claims) "." base64url(hmac_sha256(stream_secret, claims_b64))| Claim | Meaning |
|---|---|
v | Ticket version (1) |
call_sid | The Twilio CallSid this ticket was minted for |
call_event_id | The call.started event for this call |
exp | Expiry (Unix seconds); tickets live 120 seconds |
jti | Unique ticket id |
On the stream’s start message, the consumer should:
- Verify the HMAC with the shared secret and check
exp. - Reject the ticket if
call_siddiffers from the stream’s owncallSid, so a leaked ticket can’t be attached to another call. - Accept each
jtionce. - Read who the call is between (
user_id,system_user_id,conversation_id) from thecall.startedevent the ticket names.
Claims carry ids only, because Twilio caps a stream’s custom parameters at about 500 characters.
Placing and Ending Calls
mutation {
startCall(
recipientUserId: "user-uuid"
idempotencyKey: "exec-123:call"
reason: "They asked to be called about the booking"
firstMessage: "Hi, it's about your booking"
) {
id
eventTypeName
payload
}
}The number dialled is always the recipient’s own PHONE identity; there is no way to pass a number. A retry with the same idempotencyKey returns the original call.initiated and never dials twice. reason, firstMessage and metadata are carried onto call.started, so your voice service knows why it is calling.
mutation {
endCall(callSid: "CA...")
}See Calls for the full parameters.
Call Events
| Event | When | Origin |
|---|---|---|
call.initiated | startCall accepted (before Twilio dials) | - |
call.started | The call was answered and handed to stream_url | call.initiated (outbound only) |
call.ended | Twilio reported a terminal status | call.started, or call.initiated if never answered |
All three carry platform: "twilio_voice" and an in_conversation edge to the pair’s conversation. Payloads are in Event Types.
Transcripts
Outeract does not transcribe calls. If your voice service records what was said, it writes ordinary message.inbound / message.outbound events with platform: "twilio_voice" into the same conversation (for example with createEvent).
Voice is never treated as a messaging channel. sendMessage without an explicit connection picks the recipient’s channel from their most recent inbound message, skipping any platform that can’t send, so a call transcript never steers a reply onto the voice connection. Sending on a voice connection directly fails with “A voice connection cannot send messages”.
WhatsApp Calls (planned)
WhatsApp Business Calling can hand calls to a Twilio SIP Domain whose Voice URL is the same webhook. Outeract normalises sip:+44…@… callers to E.164 and records transport: "whatsapp" (or "sip" for other SIP traffic) on call.started. PSTN calls carry transport: "pstn".
Security
Every webhook is checked against X-Twilio-Signature: an HMAC-SHA1, keyed on the Auth Token, over the URL Twilio was configured with (Outeract’s public BASE_URL plus the webhook path and query) and the POSTed form parameters sorted by name. The raw request URL is accepted only as a fallback. Requests that fail are rejected.
Troubleshooting
Callers hear “this line isn’t set up yet”
The connection is missing stream_url or stream_secret, or has no phone_number.
Callers hear “I don’t recognise this number”
The number has no PHONE identity in the app and unknown_caller_policy is reject. Have them message the app first, or switch the policy to create_user.
Webhooks rejected with a signature error
- The connection must use the account’s Auth Token, not an API key secret
- Outeract’s
BASE_URLmust match the host Twilio calls (the URL in the number’s Voice settings)
“The call may have been placed; not retrying”
Twilio timed out or returned a 5xx, so the call may have gone through. Don’t retry with a new idempotency key, or the person may be rung twice. Watch for call.started / call.ended instead.
“Recipient has no phone number on record”
startCall only dials a number the user already has as a PHONE identity. They must message the app from a phone first.
Voice service stops receiving live events
If your voice service follows the conversation over the event stream, each live call holds one open stream. An app may open at most STREAM_MAX_CONCURRENT_PER_APP streams at once (default 100); beyond that, new streams get 429.