Twilio Voice Calls Architecture¶
This document explains how Twilio voice calling works in the Perci platform, covering the end-to-end flow from frontend token acquisition to backend webhook processing.
Overview¶
The Perci platform uses Twilio Voice SDK to enable clinicians to make phone calls to members directly from the clinicians app. The implementation uses:
- Frontend (Flutter): Twilio Voice SDK for Flutter (
twilio_voicepackage) - Backend (Node.js/TypeScript): Twilio REST API and TwiML for call orchestration
- Event Processing: Google Cloud Pub/Sub for asynchronous webhook event handling
sequenceDiagram
participant Clinician as Clinician App
participant BFF as BFF Clinical API
participant Twilio as Twilio Service
participant API as Public API
participant PubSub as Google Pub/Sub
participant Member as Member's Phone
Clinician->>BFF: 1. Request Access Token
BFF->>BFF: Generate JWT with Voice Grant
BFF->>Clinician: 2. Return {token, identity, fromNumber}
Clinician->>Clinician: 3. Initialize Twilio SDK with token
Clinician->>Twilio: 4. Place call to member's number
Twilio->>API: 5. Request TwiML (outgoing endpoint)
API->>Twilio: 6. Return TwiML dial instructions
Twilio->>Member: 7. Initiate call
Twilio->>API: 8. Send status webhooks (ringing, answered, etc.)
API->>PubSub: 9. Publish event to Pub/Sub
PubSub->>API: 10. Process event async
API->>API: 11. Create/update FHIR Communication log (no appointment state change)
Frontend: Token Acquisition and Call Initiation¶
1. Service Initialization¶
The frontend uses a singleton service TwilioVoiceService to manage the Twilio Voice SDK lifecycle.
Key responsibilities: - Initialize the Twilio Voice SDK - Fetch and register access tokens - Manage call state (active, muted, duration) - Handle call events (ringing, connected, ended)
Initialization flow:
// Initialize the service (singleton)
final twilioService = TwilioVoiceService();
final initialized = await twilioService.initialize();
// Sets up event listeners for call events
_callEventSubscription = TwilioVoice.instance.callEventsListener.listen(
_handleCallEvent,
onError: _handleCallError,
);
2. Fetching Access Token¶
Before making a call, the frontend requests an access token from the BFF Clinical API.
API Call:
// Location: lib/backend/api_requests/api_calls.dart
final response = await BFFClinicalGroup.twilioAccessTokenCall.call(
token: currentAuthenticationToken, // Descope session token
);
Endpoint: POST /bff_clinical/v1/twilio/access-token
Response structure:
{
status: 'success',
data: {
token: string, // JWT access token for Twilio Voice SDK
identity: string, // Practitioner's Medplum ID (used as caller identity)
fromNumber: string // Twilio phone number to use as caller ID
}
}
Token contents:
The JWT token contains a Voice Grant that:
- Identifies the caller using the practitioner's Medplum ID
- Authorizes outgoing calls via the configured TwiML app
- Expires after 1 hour (3600 seconds)
- Does NOT allow incoming calls (incomingAllow: false)
3. Making a Call¶
Once the token is fetched, the service registers it with the Twilio SDK and initiates the call.
Call flow:
// 1. Register token with Twilio SDK
await TwilioVoice.instance.setTokens(
accessToken: token,
deviceToken: '', // Not needed for web
);
// 2. Place the call
final result = await TwilioVoice.instance.call.place(
from: identity, // Practitioner's Medplum ID
to: memberPhoneNumber, // Member's phone number
extraOptions: {
'appointmentId': appointmentId // Pass appointment context
},
);
What happens: - The Twilio SDK connects to Twilio's infrastructure using the access token - Twilio validates the token and initiates a call - Twilio makes a callback to the backend TwiML endpoint to get instructions on how to handle the call
4. Call Event Handling¶
The frontend listens to call events from the Twilio SDK:
| Event Type | Description | Frontend Action |
|---|---|---|
ringing |
Call is ringing | Show "Calling..." UI |
connected |
Call answered | Start duration timer |
ended |
Call disconnected | Reset call state, preserve duration |
reconnecting |
Network issue | Show reconnecting UI |
reconnected |
Network restored | Resume normal UI |
Backend: Token Generation¶
Endpoint: POST /bff_clinical/v1/twilio/access-token¶
Location: apps/perci-platform-backend/functions/src/bff-clinical/twilio/
Authentication: Requires Descope clinician authentication via descopeClinicianMiddleware
Implementation: apiGenerateTwilioAccessToken.ts
Process: 1. Extract practitioner identity: Uses the authenticated clinician's Medplum practitioner ID as the Twilio identity 2. Generate JWT token: Creates a Twilio Access Token with a Voice Grant 3. Select phone number: Gets a Twilio phone number from the pool to use as caller ID 4. Return credentials: Sends token, identity, and phone number to the frontend
Key code:
// Generate JWT with Voice Grant
const token = TwilioVoiceApi.value.generateAccessToken(
twilioIdentity, // Practitioner's Medplum ID
3600, // 1 hour TTL
);
// Get a phone number for the call
const fromNumber = await TwilioVoiceApi.value.selectPhoneNumberFromPool();
Voice Grant configuration:
const voiceGrant = new VoiceGrant({
outgoingApplicationSid: process.env.TWILIO_TWIML_APP_SID,
incomingAllow: false, // Only outgoing calls
});
TwiML App Configuration:
The TWILIO_TWIML_APP_SID environment variable points to a Twilio TwiML application that defines:
- Request URL: The backend endpoint to call for TwiML instructions when a call is placed
- Voice URL: https://[backend]/v1/twilio/outgoing (see next section)
Backend: TwiML Endpoints¶
Endpoint: POST /v1/twilio/outgoing¶
Location: apps/perci-platform-backend/functions/src/api/twilio/
Purpose: TwiML endpoint called by Twilio when a call is initiated from the Voice SDK
Implementation: apiHandleTwimlVoiceRequest.ts
Request validation: - Validates Twilio's signature to ensure request authenticity - Skips validation in development/local environments
Process:
1. Extract parameters: Gets To (member's number), From (caller identity), CallSid, and appointmentId from request body
2. Select caller ID: Gets a Twilio phone number to use as caller ID (required for PSTN calls)
3. Generate TwiML: Creates XML instructions for Twilio to dial the number
4. Return TwiML response: Sends XML back to Twilio
TwiML generation:
const twimlResponse = new VoiceResponse();
const dial = twimlResponse.dial({ callerId: fromPhoneNumber });
// Add status callback for appointment tracking
dial.number({
statusCallbackEvent: 'initiated ringing answered completed',
statusCallback: `${FUNCTIONS_URL}/v1/twilio/webhooks/call-status?AppointmentId=${appointmentId}`
}, memberPhoneNumber);
Example TwiML response:
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Dial callerId="+441234567890">
<Number statusCallback="https://backend.perci.health/v1/twilio/webhooks/call-status?AppointmentId=abc123"
statusCallbackEvent="initiated ringing answered completed">
+447987654321
</Number>
</Dial>
</Response>
Endpoint: POST /v1/twilio/incoming¶
Purpose: Handles incoming calls to Twilio numbers (when members call back)
Implementation: apiHandleIncomingCall.ts
Behavior:
- Plays a friendly message informing callers that they missed a call from a clinician
- Directs them to log into their Perci Health account to message their nurse
- Uses British English neural voice (Polly.Amy-Neural)
TwiML response:
twimlResponse.say(
{ voice: 'Polly.Amy-Neural', language: 'en-GB' },
'Thank you for calling Perci Health. ' +
'You have missed a call from one of our clinicians. ' +
'Please log in to your Perci Health account to send a message to your nurse. ' +
'Thank you, goodbye.'
);
twimlResponse.hangup();
Backend: Webhook Event Processing¶
Endpoint: POST /v1/twilio/webhooks/call-status¶
Purpose: Receives call status updates from Twilio during the call lifecycle
Implementation: apiTwilioCallStatusWebhook.ts
Call status events:
Twilio sends webhooks for these events (when configured in statusCallbackEvent):
- queued: Call is queued
- initiated: Call is ready to be dialed
- ringing: Phone is ringing
- answered: Call was answered
- in-progress: Call is active
- completed: Call ended successfully
- busy: Number was busy
- failed: Call failed
- no-answer: No one answered
- canceled: Call was canceled
Validation:
- Validates Twilio's X-Twilio-Signature header to ensure authenticity
- Uses twilio.validateRequest() to verify the signature matches the request body and URL
Processing:
1. Validate signature: Ensures request is from Twilio
2. Log event: Records the event details
3. Publish to Pub/Sub: Sends event to twilio topic for asynchronous processing
4. Respond quickly: Returns 200 OK immediately (Twilio expects quick responses); all downstream FHIR logging is handled asynchronously via Pub/Sub
Pub/Sub message structure:
{
topicId: 'twilio',
message: {
attributes: {
type: 'appointment',
subtype: 'CallStatus',
source: 'api',
version: '1.0',
},
data: Buffer.from(JSON.stringify(req.body)) // Raw Twilio webhook payload
}
}
Endpoint: POST /v1/twilio/pubsub¶
Purpose: Processes Twilio events from Google Cloud Pub/Sub asynchronously
Implementation: apiPostTwilioPubSub.ts
Process:
1. Validate Pub/Sub message: Ensures message structure is correct
2. Decode message data: Extracts Twilio webhook payload
3. Route by event subtype: Handles different event types
4. Process call status: For CallStatus events, creates or updates FHIR Communication resources to log the call
Event processing: twilioCallStatusChanged.ts
For CallStatus events, we log the call lifecycle in Medplum using a FHIR Communication resource. We do not change the FHIR Appointment status based on the call.
Twilio status → Communication mapping¶
Each Twilio CallSid corresponds to a single FHIR Communication. As Twilio sends status updates, we update that same Communication resource.
High-level mapping:
| Twilio Status | Communication.status | Notes |
|---|---|---|
queued |
in-progress |
Call created, not yet ringing |
initiated |
in-progress |
Ready to dial |
ringing |
in-progress |
Phone is ringing |
answered |
in-progress |
Call has been answered |
in-progress |
in-progress |
Active call; we also set received timestamp |
completed |
completed |
Call ended; duration and outcome stored in extension |
busy |
completed |
Outcome captured via Twilio status in extension |
failed |
completed |
Outcome captured via Twilio status in extension |
no-answer |
completed |
Outcome captured via Twilio status in extension |
canceled |
completed |
Outcome captured via Twilio status in extension |
We do not attempt to infer “appointment started” or any other appointment lifecycle changes from these events.
Communication creation and update process¶
For each CallStatus Pub/Sub message:
- Extract
CallSid,CallStatus,AnsweredBy(if present), timestamps, and any other Twilio metadata. - Attempt to resolve the corresponding
Communication: - First via an internal mapping (
CallSid→Communication.id) created when the outbound call was initiated. - If that fails, via a Medplum search on a Twilio-specific extension containing the
CallSid. - If no existing
Communicationis found, create a new one: resourceType: "Communication"status: "in-progress"(or"preparation"if you prefer)category: Perci-specific coding for Twilio voice callsmedium:TEL(telephone)subject:Patientreference (member)sender:PractitionerorPractitionerRolereference (clinician)recipient:Patient(orRelatedPerson)basedOn:Appointmentreference for the relevant appointmentsent: Twilio start time, if known- Twilio extension containing at least
callSidand initialcallStatus - Apply updates based on the current Twilio event:
- Update
statusaccording to the mapping table above. - Set
receivedwhen the call transitions toin-progressfor the first time. - Update a Twilio-specific extension with:
callStatus(raw Twilio status)answeredBy(from AMD, e.g.human,machine_start,unknown)durationSeconds(when available)direction(e.g.outbound-api)recordingSid(if the call is recorded)
- Optionally update a short human-readable summary in
payload[0].contentString. - Persist the changes with a
PUTorPATCHto Medplum. Each update creates a new version of theCommunication, giving us a full history of the call lifecycle.
Communication structure (example)¶
Below is an example of the type of Communication we aim to store for a completed outbound call:
{
"resourceType": "Communication",
"status": "completed",
"category": [
{
"coding": [
{
"system": "https://perci.health/communication-category",
"code": "twilio-voice",
"display": "Twilio voice call"
}
]
}
],
"medium": [
{
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/v3-ParticipationMode",
"code": "TEL",
"display": "telephone"
}
]
}
],
"subject": { "reference": "Patient/<patient-id>" },
"sender": { "reference": "PractitionerRole/<practitioner-role-id>" },
"recipient": [{ "reference": "Patient/<patient-id>" }],
"basedOn": [{ "reference": "Appointment/<appointment-id>" }],
"sent": "2025-11-24T10:15:00Z",
"received": "2025-11-24T10:15:10Z",
"payload": [
{
"contentString": "Outbound Twilio voice call; status=completed; answeredBy=human; duration=180s"
}
],
"extension": [
{
"url": "https://perci.health/fhir/StructureDefinition/twilio-call",
"extension": [
{ "url": "callSid", "valueString": "CA1234..." },
{ "url": "direction", "valueString": "outbound-api" },
{ "url": "callStatus", "valueString": "completed" },
{ "url": "answeredBy", "valueString": "human" },
{ "url": "durationSeconds", "valueInteger": 180 },
{ "url": "recordingSid", "valueString": "RE5678..." }
]
}
]
}
Appointments remain the source of truth for scheduling and fulfilment state; Communications act as an auditable log of each call attempt associated with that appointment.
Security Considerations¶
Token Security¶
- Short-lived tokens: Access tokens expire after 1 hour
- Identity binding: Tokens are bound to the practitioner's Medplum ID
- Limited permissions: Voice Grant only allows outgoing calls
- No persistent storage: Tokens are not stored in the database
Webhook Security¶
- Signature validation: All webhook requests are validated using Twilio's signature mechanism
- HTTPS only: All endpoints require HTTPS
- Rate limiting: Twilio enforces rate limits on webhook delivery
- Idempotency: Webhook processing is designed to be idempotent
Phone Number Pool¶
- Controlled access: Only backend can access the phone number pool
- Caller ID verification: Twilio verifies caller ID ownership
- Environment-based: Can configure different numbers per environment
Error Handling¶
Frontend Errors¶
- Token fetch failure: Shows user-friendly error, prompts retry
- Call placement failure: Displays error message, suggests checking connection
- Permission denial: Prompts user to grant microphone permission
Backend Errors¶
- No phone numbers available: Returns 503 Service Unavailable
- Invalid practitioner ID: Returns 400 Bad Request
- Token generation failure: Returns 500 Internal Server Error
- Webhook validation failure: Returns 403 Forbidden
Call Failures¶
- Number busy: Allows clinician to retry
- No answer: Allows clinician to retry
- Failed: Resets appointment to scheduled state for retry
- Network error: Frontend shows reconnecting state
Configuration¶
Environment Variables¶
Backend:
# Required
TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=...
TWILIO_API_KEY=SK...
TWILIO_API_SECRET=...
TWILIO_TWIML_APP_SID=AP...
# Optional (defaults to fetching from Twilio)
TWILIO_PHONE_NUMBER=+441234567890
# Deployment
FUNCTIONS_URL=https://backend.perci.health/perci-platform-production/europe-west2/api
Frontend:
- No Twilio-specific configuration needed
- Uses BFF Clinical endpoint configured in FFDevEnvironmentValues
Twilio Console Configuration¶
- TwiML Application: Configure Voice URL to point to
/v1/twilio/outgoing - Phone Numbers: Configure Voice Webhook to point to
/v1/twilio/incoming - Phone Numbers: Configure Status Callback to point to
/v1/twilio/webhooks/call-status
Testing¶
Local Development¶
- Webhook signature validation is disabled in development (
NODE_ENV=development) and emulator (FUNCTIONS_EMULATOR=true) - Use ngrok to expose local server for Twilio webhooks
- Test with Twilio's test credentials for development
End-to-End Flow¶
- Start local backend with emulator
- Start Flutter app (web)
- Open appointment call screen
- Click "Call" button
- Verify token is fetched
- Verify call is placed
- Check backend logs for TwiML generation
- Check Pub/Sub for status events
- Verify appointment status updates in Medplum
Related Files¶
Frontend¶
- TwilioVoiceService: Main service for SDK integration
- api_calls.dart: API client for token endpoint
- appointment_call_media_model.dart: UI model for call screen
- index.html: Includes Twilio Voice SDK JavaScript library
Backend - BFF Clinical¶
- twilioRouter.ts: Router for BFF endpoints
- apiGenerateTwilioAccessToken.ts: Token generation endpoint
Backend - Public API¶
- twilioRouter.ts: Router for webhooks and TwiML
- apiHandleTwimlVoiceRequest.ts: TwiML endpoint for outgoing calls
- apiHandleIncomingCall.ts: TwiML endpoint for incoming calls
- apiTwilioCallStatusWebhook.ts: Webhook receiver
- apiPostTwilioPubSub.ts: Pub/Sub processor
- twilioCallStatusChanged.ts: Event handler
- TwilioVoiceApi.ts: Twilio SDK wrapper
Future Improvements¶
- Call recording: Add ability to record calls for quality assurance
- Call analytics: Track call duration, success rate, and other metrics
- Multi-party calls: Support conference calls with multiple participants
- Incoming calls: Allow members to call clinicians directly
- SMS integration: Send SMS notifications before/after calls
- Call queuing: Implement a queue system for managing multiple calls