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

PropertyValue
Platform TypeTWILIO_VOICE
ProviderTwilio
AuthenticationAccount SID + Auth Token (the Auth Token itself, not an API key secret: it also validates Twilio’s webhook signatures)
Webhook TypeDedicated (configured on the number automatically)
CarriesCalls, 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’s natter-voice service)
  • A shared secret for the stream ticket, configured on both sides

Setup Guide

Step 1: Get Twilio Credentials

  1. Log in to Twilio Console
  2. On the dashboard, copy the Account SID (starts with AC) and the Auth Token

Step 2: Get a Phone Number

  1. In Twilio Console, go to Phone Numbers → Manage → Buy a number
  2. Pick a number with Voice capability
  3. 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
  }
}
FieldRequiredDefaultDescription
account_sidYes-Twilio Account SID (AC + 32 hex characters)
auth_tokenYes-Account Auth Token. Also used to validate webhook signatures
phone_numberYes-The number callers dial and calls are placed from (E.164)
regionNous1Twilio processing region: us1, ie1 or au1. Must match the number’s inbound region and the credentials
stream_urlYes-wss:// URL of your voice service. Answered calls stream their audio here
stream_secretYes-Shared secret your voice service uses to verify each call’s ticket
unknown_caller_policyNorejectreject or create_user. What to do when a number Outeract has never seen calls
allowed_callersNoemptyOptional allow list of E.164 numbers. Empty allows everyone the policy allows
max_call_secondsNo1800Hard 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"]
  1. Twilio posts the call to the Voice URL.
  2. 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 PHONE identity 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.
  3. Outeract records call.started in that conversation.
  4. 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.
  5. 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

SituationWhat happens
Known callerConnected; the call lands in their existing conversation
Unknown number, unknown_caller_policy: rejectTold “Sorry, I don’t recognise this number. Message me first and then call.” and hung up
Unknown number, unknown_caller_policy: create_userA new user is created and the call is connected
Withheld caller IDTold 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))
ClaimMeaning
vTicket version (1)
call_sidThe Twilio CallSid this ticket was minted for
call_event_idThe call.started event for this call
expExpiry (Unix seconds); tickets live 120 seconds
jtiUnique ticket id

On the stream’s start message, the consumer should:

  1. Verify the HMAC with the shared secret and check exp.
  2. Reject the ticket if call_sid differs from the stream’s own callSid, so a leaked ticket can’t be attached to another call.
  3. Accept each jti once.
  4. Read who the call is between (user_id, system_user_id, conversation_id) from the call.started event 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

EventWhenOrigin
call.initiatedstartCall accepted (before Twilio dials)-
call.startedThe call was answered and handed to stream_urlcall.initiated (outbound only)
call.endedTwilio reported a terminal statuscall.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_URL must 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.

Resources