Skip to content

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_voice package)
  • 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:

  1. Extract CallSid, CallStatus, AnsweredBy (if present), timestamps, and any other Twilio metadata.
  2. Attempt to resolve the corresponding Communication:
  3. First via an internal mapping (CallSidCommunication.id) created when the outbound call was initiated.
  4. If that fails, via a Medplum search on a Twilio-specific extension containing the CallSid.
  5. If no existing Communication is found, create a new one:
  6. resourceType: "Communication"
  7. status: "in-progress" (or "preparation" if you prefer)
  8. category: Perci-specific coding for Twilio voice calls
  9. medium: TEL (telephone)
  10. subject: Patient reference (member)
  11. sender: Practitioner or PractitionerRole reference (clinician)
  12. recipient: Patient (or RelatedPerson)
  13. basedOn: Appointment reference for the relevant appointment
  14. sent: Twilio start time, if known
  15. Twilio extension containing at least callSid and initial callStatus
  16. Apply updates based on the current Twilio event:
  17. Update status according to the mapping table above.
  18. Set received when the call transitions to in-progress for the first time.
  19. 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)
  20. Optionally update a short human-readable summary in payload[0].contentString.
  21. Persist the changes with a PUT or PATCH to Medplum. Each update creates a new version of the Communication, 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

  1. TwiML Application: Configure Voice URL to point to /v1/twilio/outgoing
  2. Phone Numbers: Configure Voice Webhook to point to /v1/twilio/incoming
  3. 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

  1. Start local backend with emulator
  2. Start Flutter app (web)
  3. Open appointment call screen
  4. Click "Call" button
  5. Verify token is fetched
  6. Verify call is placed
  7. Check backend logs for TwiML generation
  8. Check Pub/Sub for status events
  9. Verify appointment status updates in Medplum

Frontend

Backend - BFF Clinical

Backend - Public API


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