
Twilio Voice SDK - Receive incoming calls from external phone numbers while keeping numbers hidden on the frontend
Introduction
In this article, I will explain how to implement voice call reception functionality with an anonymized number UI using Twilio Voice service. In a previous article, we built a system for making calls from a browser to external phone numbers. This time, we will implement a system where when a customer calls a Twilio purchased number, the operator can receive the call through a web application while keeping the number hidden. As shown in the screenshot below, the phone number is not displayed in the UI, only identifying information such as the customer's name is presented.
Anonymized number call reception is effective in cases where both customers and service providers need to talk directly, yet don't want to share personal phone numbers, such as in ride-hailing apps or delivery services.
What is Twilio
Twilio is a cloud service that provides communication features such as voice calls, SMS, and video calls as APIs. Developers can integrate communication capabilities into applications through REST APIs and SDKs without having to build complex communication infrastructure. In particular, the Voice service supports a wide range of use cases, from browser calls based on WebRTC to external calls via telephone networks (PSTN).
Target Audience
- Application developers who want to implement voice call reception functionality
- Those using Twilio Voice service for the first time
- Those with basic knowledge of JavaScript and Node.js
- Those considering a PoC for an anonymized call system
References
- Twilio Voice JavaScript SDK Documentation
- Twilio Voice Quickstart for JavaScript
- TwiML Voice Reference## Overview and Architecture
In this implementation, we'll create a flow where a customer calls a Twilio purchased number → the call rings in the operator's browser → the UI displays only the customer name while hiding the number during call acceptance. For this PoC, we'll simplify some parts, but the explanation is based on a future production configuration.
- The frontend requests an Access Token from Twilio Function
/token
with identity=appId to register the web app as a device capable of receiving calls via Twilio Voice JS SDK - The frontend initializes the Twilio Voice JS SDK with the obtained token and registers the device
- The customer calls the Twilio purchased number
- Twilio calls the Twilio Function
/incoming
via Incoming Webhook /incoming
calls the internal backend API to identify the customer and handler (appId) from the phone number by referencing the customer database/incoming
generates and returns TwiML- Twilio forwards the incoming call event to the frontend based on the appId
- The frontend retrieves parameters such as customer name from the incoming event and displays them in the number-hiding UI
- When the operator presses the answer button, the call is established (two-way audio via WebRTC, with Twilio bridging the PSTN side)
Implementation
Obtaining an API Key
-
Account Dashboard > Account Info > API Keys & Tokens
-
Friendly name: Any name (e.g.,
incoming-poc-key
) -
Key type: Standard
-
Take note of the Key SID and Secret
-
Create a new service in Develop > Functions and Assets > Services
-
Service Name: Any name (example:
incoming-poc-service
) -
Create
/token
with Add Function
/token implementation
// /token
exports.handler = function (context, event, callback) {
// CORS and response initialization
const res = new Twilio.Response();
// In production, don't use wildcards but set allowed origins using environment variables
// Example: CORS_ALLOW_ORIGIN=https://example.com
const allowOrigin = context.CORS_ALLOW_ORIGIN || 'https://example.com';
res.appendHeader('Content-Type', 'application/json');
res.appendHeader('Access-Control-Allow-Origin', allowOrigin);
res.appendHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.appendHeader('Access-Control-Allow-Headers', 'Content-Type');
if (event.httpMethod === 'OPTIONS') {
res.setStatusCode(200);
res.setBody('');
return callback(null, res);
}
// Restrict allowed methods
if (event.httpMethod !== 'GET' && event.httpMethod !== 'POST') {
res.setStatusCode(405);
res.setBody(JSON.stringify({ error: 'method_not_allowed' }));
return callback(null, res);
}
// Get and validate identity
// For now, we'll use identity from query or form
// In real applications, this would be resolved from authenticated users via internal backend
const identityRaw = (event.identity || '').trim();
const ID_REGEX = /^[A-Za-z0-9_\-\.@]{1,64}$/; // Restrict allowed characters
if (!identityRaw || !ID_REGEX.test(identityRaw)) {
res.setStatusCode(400);
res.setBody(JSON.stringify({
error: 'invalid_identity',
message: 'Please specify identity [letters, numbers, underscore, hyphen, dot, at sign, 1-64 characters]'
}));
return callback(null, res);
}
const identity = identityRaw;
// Token generation
const AccessToken = Twilio.jwt.AccessToken;
const VoiceGrant = AccessToken.VoiceGrant;
// TTL can be controlled by environment variables. Default is 3600 seconds if unspecified
const ttlSec = Number(context.TOKEN_TTL_SEC) > 0 ? Number(context.TOKEN_TTL_SEC) : 3600;
const token = new AccessToken(
context.ACCOUNT_SID,
context.API_KEY_SID,
context.API_KEY_SECRET,
{ identity, ttl: ttlSec }
);
// Allow incoming calls only. No outgoing call permissions are granted as they're not needed.
token.addGrant(new VoiceGrant({ incomingAllow: true }));
res.setStatusCode(200);
res.setBody({ identity, token: token.toJwt() });
return callback(null, res);
};
:::- Change Visibility to Public
- After changing, don't forget to press Save and Deploy All
/incoming
)
Twilio Function: TwiML Generation (- Create
/incoming
with Add Function - This Function will be called when a call comes in to your purchased Twilio number
/incoming implementation
// /incoming
exports.handler = async function (context, event, callback) {
const twiml = new Twilio.twiml.VoiceResponse();
// We capture the caller's number but don't expose it externally or log it.
// Reason: To minimize personal information. We also don't include it in UI or Parameters.
const fromNumber = event.From || '';
// In a real implementation, we would resolve
// { appId, customerName, customerId } from the fromNumber using an internal API.
// Example:
// const axios = require('axios');
// let appId, customerName, customerId;
// try {
// const resp = await axios.get(`${context.INTERNAL_API_BASE}/resolve-caller`, {
// params: { from_number: fromNumber },
// timeout: 2000,
// });
// ({ appId, customerName, customerId } = resp.data);
// } catch (e) {
// // In production, we recommend only logging CallSid and status to audit logs,
// // and not directly storing fromNumber (hash it if necessary).
// // As needed, fall back to a main contact number or guidance.
// }
// For this PoC, we'll use fixed values.
const appId = 'user001';
const customerName = '越井 琢巳';
const customerId = 'cm-koshii';
// Fallback when routing is not possible
if (!appId) {
twiml.say({ language: 'ja-JP' }, '担当に接続できません。しばらくしてからおかけ直しください。');
return callback(null, twiml);
}
// Bridge to the operator's web app (identity = appId)
const dial = twiml.dial({ answerOnBridge: true, timeLimit: 3600 });
const client = dial.client(appId);
// Pass only minimal meta information for UI display.
// Don't include phone numbers, internal IDs, or sensitive information.
client.parameter({ name: 'customerName', value: customerName });
client.parameter({ name: 'customerId', value: customerId });
return callback(null, twiml);
};
public/app.js implementation
// ===== Configuration =====
// Replace with your actual Functions domain
const FUNCTIONS_BASE_URL = 'https://YOUR-FUNCTIONS-DOMAIN.twil.io';
// Operator identity prioritizes URL query ?identity=user001, otherwise uses default
const DEFAULT_OPERATOR_ID = 'user001';
const urlParams = new URLSearchParams(location.search);
const OPERATOR_ID = urlParams.get('identity') || DEFAULT_OPERATOR_ID;
const TOKEN_URL = (id) => `${FUNCTIONS_BASE_URL}/token?identity=${encodeURIComponent(id)}`;
// ===== State =====
let device = null;
let pendingCall = null;
let activeCall = null;
// ===== Helper Functions =====
function $(id) { return document.getElementById(id); }
function safeText(id, text) {
const el = $(id);
if (el) el.textContent = text;
}
function log(line) {
// Simple logging for demonstration. Does not record personal information.
const box = $('status');
if (!box) return;
const t = new Date().toLocaleTimeString();
box.textContent += `[${t}] ${line}\n`;
}
async function fetchToken(id) {
const res = await fetch(TOKEN_URL(id), { method: 'GET', cache: 'no-store' });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`token fetch failed: ${res.status} ${text}`);
}
return res.json();
}
async function ensureMicPermission() {
// Get browser permission. Will throw an exception if denied.
await navigator.mediaDevices.getUserMedia({ audio: true });
}
// ===== Initialization Flow =====
async function initialize() {
const initButton = $('initButton');
try {
if (initButton) initButton.disabled = true;
safeText('initStatus', 'Checking microphone permission...');
await ensureMicPermission();
safeText('initStatus', 'Getting token...');
const { token, identity } = await fetchToken(OPERATOR_ID);
// Twilio Voice SDK v2
device = new Twilio.Device(token);
await device.register();
wireDeviceEvents(device);
safeText('initStatus', `Initialized. Waiting for calls (identity: ${identity})`);
const incomingSection = $('incomingSection');
if (incomingSection) incomingSection.style.display = 'block';
} catch (e) {
safeText('initStatus', 'Initialization failed. Please retry.');
log(`init error`);
if (initButton) initButton.disabled = false;
}
}
function wireDeviceEvents(dev) {
dev.on('registered', () => log('Device.registered'));
dev.on('unregistered', () => log('Device.unregistered'));
dev.on('error', () => log('Device.error'));
// Token renewal
dev.on('tokenWillExpire', async () => {
try {
log('tokenWillExpire → Updating');
const { token } = await fetchToken(OPERATOR_ID);
await dev.updateToken(token);
log('Token updated');
} catch {
log('Token update failed');
}
});
// Incoming call handling
dev.on('incoming', (call) => {
pendingCall = call;
// Get UI display metadata added in /incoming
const customerName = call.customParameters?.get('customerName') || 'Unknown';
const customerId = call.customParameters?.get('customerId') || 'unknown';
// Customer name is only displayed in UI, not recorded in logs
safeText('incomingInfo', `Incoming: ${customerName} (ID: ${customerId})`);
log('Received incoming call');
const answerBtn = $('answerBtn');
const rejectBtn = $('rejectBtn');
const hangupBtn = $('hangupBtn');
if (answerBtn) answerBtn.disabled = false;
if (rejectBtn) rejectBtn.disabled = false;
if (hangupBtn) hangupBtn.disabled = true;
call.on('disconnect', () => {
log('Call ended');
activeCall = null;
pendingCall = null;
if (answerBtn) answerBtn.disabled = true;
if (rejectBtn) rejectBtn.disabled = true;
if (hangupBtn) hangupBtn.disabled = true;
safeText('incomingInfo', 'Waiting for incoming call');
});
call.on('error', () => log('Call.error'));
});
dev.on('connect', (call) => {
log('Connected');
activeCall = call;
const hangupBtn = $('hangupBtn');
if (hangupBtn) hangupBtn.disabled = false;
});
}
// ===== Bind events after DOM construction =====
document.addEventListener('DOMContentLoaded', () => {
const initButton = $('initButton');
const answerBtn = $('answerBtn');
const rejectBtn = $('rejectBtn');
const hangupBtn = $('hangupBtn');
if (initButton) initButton.addEventListener('click', initialize);
if (answerBtn) {
answerBtn.addEventListener('click', async () => {
if (!pendingCall) return;
try {
answerBtn.disabled = true;
if (rejectBtn) rejectBtn.disabled = true;
await pendingCall.accept();
log('Call answered');
if (hangupBtn) hangupBtn.disabled = false;
} catch {
log('Answer failed');
answerBtn.disabled = false;
if (rejectBtn) rejectBtn.disabled = false;
}
});
}
if (rejectBtn) {
rejectBtn.addEventListener('click', async () => {
try {
rejectBtn.disabled = true;
if (answerBtn) answerBtn.disabled = true;
await pendingCall?.reject();
log('Call rejected');
} catch {
log('Rejection failed');
} finally {
pendingCall = null;
const hangupBtn2 = $('hangupBtn');
if (hangupBtn2) hangupBtn2.disabled = true;
safeText('incomingInfo', 'Waiting for incoming call');
}
});
}
if (hangupBtn) {
hangupBtn.addEventListener('click', async () => {
try {
hangupBtn.disabled = true;
await activeCall?.disconnect();
log('Call disconnected');
} catch {
log('Disconnection failed');
} finally {
activeCall = null;
pendingCall = null;
safeText('incomingInfo', 'Waiting for incoming call');
if (answerBtn) answerBtn.disabled = true;
if (rejectBtn) rejectBtn.disabled = true;
}
});
}
});
:::## Operation Verification
Confirming Twilio Functions Deployment
Verify that /token
and /incoming
are deployed in the Functions Console.
Starting the Local Server
npx http-server ./public -p 3000
Testing the Operation
- Open
http://localhost:3000
in your browser
- Click the "Initialize Audio Function" button and authorize microphone usage
- Confirm that the registration log for identity: user001 is displayed and status changes to "Waiting for call"
- Make a call from a mobile phone to your purchased Twilio number. An incoming call appears in the browser, displaying only customer name and customer ID (phone number is not displayed)
- When you click "Answer Call", the call is established and audio can be confirmed on both sides. After ending the call, a log "Call has ended." is displayed and the status returns to "Waiting for call"
Summary
In this article, we built a system that transfers incoming calls from external phone numbers to a frontend application using Twilio Voice JavaScript SDK and Twilio Functions. While assuming a flow where customer information is resolved using from_number as a key through an internal API, we implemented this PoC using fixed values. By passing only customer name and ID to the frontend without displaying the phone number itself, it's possible to establish calls while keeping the number hidden. This approach is expected to be useful in scenarios where customer information needs to be handled securely, such as in call center operations and user support.