On-Demand Masked Sessions with Twilio Proxy, Voice and Serverless

September 16, 2026
Written by
Leroy Chan
Twilion
Reviewed by
Paul Kamp
Twilion

In this post, you'll learn how to build a Just-in-Time (JIT) Masked Session Creation system to securely connect users on demand. When phone numbers can't be pre-associated due to inventory constraints, this architecture intercepts inbound calls to a central Twilio number powered by Twilio Voice, uses an IVR to collect a tracking code, and dynamically creates a private proxy session on the fly with Twilio Proxy.

Let’s build it!

Solution overview

For on-demand delivery, ridesharing, and marketplace services, connecting two users securely is a standard operational requirement.

Typically, customer and courier interactions are managed using masked communications, allowing both parties to call or text each other without revealing their personal phone numbers. Twilio Proxy simplifies this task by dynamically mapping intermediate phone numbers and bridging active sessions. However, a traditional implementation assumes that the identity of both parties is known beforehand to pre-allocate a static session. What happens when a delivery courier is standing outside an apartment complex trying to reach a customer, but their phone numbers cannot be pre-associated due to scaling or inventory constraints?

This tutorial introduces a customized architecture called Just-in-Time (JIT) Masked Session Creation. By intercepting inbound calls on a single reserved Twilio number, prompting the caller with an interactive voice response (IVR) to key in a short tracking code, and querying a lightweight backend, you can resolve and stand up private sessions on the fly.

High-level solution architecture

This dynamic architecture runs entirely on Twilio Serverless Functions.

The core challenge occurs when Twilio Proxy intercepts a call on a number with no active session, triggering an Out-of-Session Callback. That callback's payload does not contain the digits pressed on the keypad, so we implement a '2-Bounce' out-of-session workflow using a redirect and Twilio Sync to carry state across the bounces.

Architectural call flow sequence illustrating the 2-Bounce out-of-session redirect trick stashing session data in Twilio Sync.
Architectural call flow sequence illustrating the 2-Bounce out-of-session redirect trick stashing session data in Twilio Sync.

The sequential diagram above illustrates how a single call is bounced twice to achieve dynamic session creation:

  • First Bounce (Out-of-Session): The caller dials the reserved Proxy number. Since no session exists, Twilio Proxy fires a callback to /out-of-session. The endpoint checks Twilio Sync for an existing call resolution. Finding none, it returns TwiML with a <Gather> prompt asking the caller to enter their 6-digit code.
  • Digits Collection & Bidirectional Lookup: The caller enters the code via DTMF. Twilio posts the digits and CallSid to /gather-action. This endpoint calls an internal /lookup API. The /lookup endpoint performs a bidirectional search: it finds the mapping by the tracking code, determines which of the two mapped parties is calling, and returns the other party's phone number.
  • Stashing the Resolution in Sync: Since the second bounce needs to know the destination number but won't have access to the entered digits, the resolved phone number is stored out-of-band in a temporary Twilio Sync Document, using res- + CallSid as the unique key with a short TTL (e.g., 900 seconds).
  • The Redirect Bounce: After stashing the resolution, /gather-action returns a <Redirect> pointing the live call back to the Proxy Service Call URL. Since no session has been created yet, Proxy intercepts the call again and fires its Out-of-Session Callback a second time.
  • Second Bounce & Session Auto-Creation: On this second bounce, /out-of-session queries Twilio Sync using the CallSid. It finds the stashed phone number, deletes the Sync Document, and returns an application/json payload instructing Proxy to auto-create the session, adding the caller and bridging them to the resolved destination.

Business objectives solved

This architecture addresses three critical business challenges:

  • Zero-Friction Courier Experience: By dialing a single proxy phone number and responding to a clear voice prompt using their dialpad, couriers can securely bridge the call.
  • Enable Flexible Marketplace Communications: Restaurants or any assigned drivers can initiate contact using the same workflow which starts by dialing a Proxy number.
  • Support Third-Party Contact: By allowing any caller to provide an order-specific tracking code, this architecture facilitates secure, authorized connections regardless of whether their personal number was pre-registered, allowing third-party contacts to reach support.

Prerequisites

To follow along with this tutorial, you'll need the following:

  • A Twilio Account: You must have an active Twilio account. You can sign up for a free Twilio account here.
  • Twilio Phone Numbers: You must have a minimum of 2 voice-enabled phone numbers purchased ( Guide)
  • Twilio Proxy: You will need to have a Twilio proxy service created, and have added a minimum of 2 phone numbers into the Twilio Proxy’s phone number pool. One of the phone numbers must be marked as a reserve phone number ( Guide).
  • Twilio Sync:To set up Twilio Sync, in your Console navigate to Twilio Console > Develop > Sync > Services, where you can either create a new Twilio Sync Service or confirm that "Default Service" is listed.
  • Node.js and npm: Ensure Node.js (version 22 or higher) and npm are installed on your local machine.
  • Twilio CLI & Serverless Toolkit: Used for local testing and serverless deployment. Install via npm install -g twilio-cli and npm install -g @twilio-labs/plugin-serverless

Building the app

This section walks through the step-by-step implementation – you will build the five key serverless endpoints and their helper functions.

Want to skip the build? The full source code used in this guide can be found over here.

Clone Prebuilt App

In your terminal, run the following commands:

git clone https://github.com/leroychan/twilio-proxy-anxe-dynamic-session
cd twilio-proxy-anxe-dynamic-session
npm install

Configuring environment variables

Begin by setting up the environment variables. These variables provide the SDK client with authentication details and define which services the endpoints interact with.

Create a .env file in your project root and configure the following variables:

# Twilio account credentials (ACCOUNT_SID is also available at runtime as context.ACCOUNT_SID)
ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AUTH_TOKEN=your_auth_token

# Proxy Service SID used for the redirect URL (Proxy auto-creates the session)
PROXY_SERVICE_SID=KSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# Optional: Twilio Sync Service SID (ISxxxx...) used to pass the resolved
# destination number from /gather-action to the 2nd /out-of-session bounce.
# When empty, the account's default Sync service (alias "default") is used, so
# no setup is required.
SYNC_SERVICE_SID=

# SEED DATA for the Sync `lookup` Map (NOT read at runtime). Run
# `npm run seed:lookup` to load these into Sync, which is what /lookup reads.
# JSON map of entered 6-digit code -> the two parties on that order.
# Each code maps to a [partyA, partyB] pair; the caller (From) is matched
# against the pair and the OTHER party's number is returned, so the same code
connects A->B and B->A. (A legacy "code":"+number" string is still accepted.)
LOOKUP_MAP={"123456":["+15551230000","+15551119999"],"654321":["+15559990000","+15558887777"]}

# Fallback destination when the code is unknown or the caller isn't in the pair
DEFAULT_REAL_NUMBER=+15559999999

# Optional: absolute base URL of this deployed service. When empty, the base
# URL is derived from context.DOMAIN_NAME (https for *.twil.io, http for localhost).
SERVICE_BASE_URL=

If you need help finding your Twilio account credentials, this page shows where to find your Account SID, while this page shows how to find your Auth Token.

Implementing helper functions in Twilio Sync

To keep our serverless handlers clean, we move all Twilio Sync interactions into a shared asset helper file (src/assets/helpers.private.ts). We will break down this file step-by-step.

SDK Types and Global Constant

First, define the minimal TypeScript shape for the Twilio Sync API SDK alongside your global application constants.

Notice resolutionKey: Sync rejects a uniqueName that matches standard 34-character Twilio SIDs (like a CallSid starting with CA). To bypass this constraint, we explicitly prefix the SID with res-.

// src/assets/helpers.private.ts

/**
 * Shape of the Twilio client's Sync Documents API that our helpers use.
 */
export type SyncClient = {
  sync: {
    v1: {
      services: (serviceSid: string) => {
        documents: ((key: string) => {
          fetch: () => Promise<{ data?: Record<string, unknown> }>;
          remove: () => Promise<unknown>;
        }) & {
          create: (opts: {
            uniqueName: string;
            data: Record<string, unknown>;
            ttl?: number;
          }) => Promise<unknown>;
        };
        syncMaps: (mapName: string) => {
          syncMapItems: (key: string) => {
            fetch: () => Promise<{ data?: Record<string, unknown> }>;
          };
        };
        syncStreams: ((name: string) => {
          streamMessages: {
            create: (opts: {
              data: Record<string, unknown>;
            }) => Promise<unknown>;
          };
        }) & {
          create: (opts: {
            uniqueName: string;
            ttl?: number;
          }) => Promise<unknown>;
        };
      };
    };
  };
};

export const LOOKUP_SYNC_MAP_NAME = 'lookup';
export const RESOLUTION_TTL_SECONDS = 900;

/**
 * Prefixes CallSid to bypass Twilio's SID pattern restriction on uniqueNames.
 */
export function resolutionKey(callSid: string): string {
  return `res-${callSid}`;
}

export function getBaseUrl(context: {
  DOMAIN_NAME?: string;
  SERVICE_BASE_URL?: string;
}): string {
  const override = context.SERVICE_BASE_URL;
  if (override && override.trim() !== '') {
    return override.trim().replace(/\/+$/, '');
  }
  const domain = context.DOMAIN_NAME || '';
  const scheme = domain.startsWith('localhost') ? 'http' : 'https';
  return `${scheme}://${domain}`;
}

Document CRUD: Managing Call Resolutions

When handling out-of-session call bounces, the incoming webhook payload does not retain user-entered digits. These helper functions handle saving, fetching, and removing the destination number inside temporary Sync Documents using the prefixed CallSid key.

// src/assets/helpers.private.ts

/**
 * Persists the resolved destination number in a temporary Sync Document.
 */
export async function saveResolution(
  client: SyncClient,
  syncServiceSid: string,
  callSid: string,
  realNumber: string
): Promise<void> {
  await client.sync.v1.services(syncServiceSid).documents.create({
    uniqueName: resolutionKey(callSid),
    data: { realNumber },
    ttl: RESOLUTION_TTL_SECONDS,
  });
}

/**
 * Reads the stashed destination number. Returns `null` if the document does not exist (404).
 */
export async function getResolution(
  client: SyncClient,
  syncServiceSid: string,
  callSid: string
): Promise<string | null> {
  try {
    const doc = await client.sync.v1
      .services(syncServiceSid)
      .documents(resolutionKey(callSid))
      .fetch();
    const realNumber = doc.data && doc.data.realNumber;
    return typeof realNumber === 'string' && realNumber.length > 0
      ? realNumber
      : null;
  } catch {
    return null;
  }
}

/**
 * Deletes the stored resolution after consumption. Swallows errors as TTL acts as a backstop.
 */
export async function deleteResolution(
  client: SyncClient,
  syncServiceSid: string,
  callSid: string
): Promise<void> {
  try {
    await client.sync.v1
      .services(syncServiceSid)
      .documents(resolutionKey(callSid))
      .remove();
  } catch {
    // Already cleaned up or expired
  }
}

Sync Map Lookups and Counterparty Resolution

These helpers extract paired metadata from a persistent Sync Map. resolveCounterparty resolves bidirectional calls, matching the incoming caller against a pair of numbers and returning the other party's number.

// src/assets/helpers.private.ts

/**
 * Reads an entry from the specified Sync Map by entered digits.
 */
export async function getLookupEntry(
  client: SyncClient,
  syncServiceSid: string,
  mapName: string,
  digits: string | undefined
): Promise<unknown | null> {
  if (!digits) return null;
  try {
    const item = await client.sync.v1
      .services(syncServiceSid)
      .syncMaps(mapName)
      .syncMapItems(digits)
      .fetch();
    return item.data ?? null;
  } catch {
    return null;
  }
}

/**
 * Evaluates the lookup payload to return the destination counterparty number.
 */
export function resolveCounterparty(
  entry: unknown,
  from: string | undefined,
  defaultNumber: string | undefined
): string {
  const isNonEmpty = (n: unknown): n is string =>
    typeof n === 'string' && n.length > 0;

  let parties: string[] | null = null;
  let single: string | null = null;

  if (Array.isArray(entry)) {
    parties = entry.filter(isNonEmpty);
  } else if (entry && typeof entry === 'object') {
    const e = entry as Record<string, unknown>;
    if (Array.isArray(e.parties)) {
      parties = e.parties.filter(isNonEmpty);
    } else if (isNonEmpty(e.number)) {
      single = e.number;
    }
  } else if (isNonEmpty(entry)) {
    single = entry;
  }

  if (parties && parties.length === 2 && from) {
    if (from === parties[0]) return parties[1];
    if (from === parties[1]) return parties[0];
  } else if (single) {
    return single;
  }
  return defaultNumber ?? '';
}

Sync Stream Event Publishing

Finally, we define the event schemas and publish a handler for streaming live events to the frontend UI via a Twilio Sync Stream. The publisher uses a self-healing pattern: if the target Sync Stream doesn't exist yet, it creates it auto-magically on the first publish failure.

// src/assets/helpers.private.ts

export type DemoEventType =
  | 'oos.prompt'
  | 'lookup.request'
  | 'lookup.result'
  | 'resolution.stored'
  | 'oos.autocreate';

export type DemoEvent = {
  type: DemoEventType;
  ts: string;
  callSid?: string;
  from?: string;
  to?: string;
  digits?: string;
  realNumber?: string;
  note?: string;
};

export const EVENTS_STREAM_NAME = 'demo-events';

/**
 * Publishes events to the Sync Stream. Safe to call anywhere: failures are swallowed
 * to ensure telecommunication and call flows are never disrupted by reporting errors.
 */
export async function publishEvent(
  client: SyncClient,
  syncServiceSid: string,
  event: DemoEvent
): Promise<void> {
  const service = () => client.sync.v1.services(syncServiceSid);
  try {
    await service()
      .syncStreams(EVENTS_STREAM_NAME)
      .streamMessages.create({ data: event as unknown as Record<string, unknown> });
    return;
  } catch {
    // Stream missing, attempt lazy creation below
  }
  try {
    await service().syncStreams.create({ uniqueName: EVENTS_STREAM_NAME });
  } catch {
    // Already created or transient issue
  }
  try {
    await service()
      .syncStreams(EVENTS_STREAM_NAME)
      .streamMessages.create({ data: event as unknown as Record<string, unknown> });
  } catch {
    // Silently continue to protect call progression
  }
}

Intercepting the Inbound Call (The First Bounce)

When a call is placed to the reserved Proxy number, Proxy fires its Out-of-Session Callback. Our /out-of-session endpoint acts as the primary traffic controller. On the first bounce, it checks Twilio Sync for a pre-resolved destination number. Since the caller has just dialed and hasn't entered a code yet, Sync returns null. The endpoint then returns a <Gather> TwiML verb to prompt the caller for their tracking code.

// src/functions/out-of-session.ts
import '@twilio-labs/serverless-runtime-types';
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
  ServerlessEventObject,
} from '@twilio-labs/serverless-runtime-types/types';
import type * as Helpers from '../assets/helpers.private';

type OutOfSessionContext = {
  SERVICE_BASE_URL?: string;
  SYNC_SERVICE_SID?: string;
  getTwilioClient: () => any;
};

type OutOfSessionEvent = {
  From?: string;
  CallSid?: string;
};

function jsonResponse(body: unknown): any {
  const response = new Twilio.Response();
  response.appendHeader('Content-Type', 'application/json');
  // Pass the object itself — the runtime serializes it once because the
  // Content-Type is JSON. Calling JSON.stringify here would double-encode it
  // into a quoted string ("{\"uniqueName\":...}"), which Proxy can't parse.
  response.setBody(body as any);
  return response;
}

function gatherResponse(baseUrl: string): any {
  const twiml = new Twilio.twiml.VoiceResponse();
  const gather = twiml.gather({
    input: ['dtmf'],
    numDigits: 6,
    method: 'POST',
    action: `${baseUrl}/gather-action`,
  });
  gather.say('Please enter your order number.');

  const response = new Twilio.Response();
  response.appendHeader('Content-Type', 'application/xml');
  response.setBody(twiml.toString());
  return response;
}

export const handler: ServerlessFunctionSignature = async function (
  context: Context<OutOfSessionContext>,
  event: ServerlessEventObject<OutOfSessionEvent>,
  callback: ServerlessCallback
) {
  try {
    console.log('Out-of-Session Callback received:', JSON.stringify(event));
  } catch {
    // never let logging break the webhook
  }

  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const helpers = require(Runtime.getAssets()['/helpers.js']
    .path) as typeof Helpers;

  const { From, CallSid } = event;
  const syncServiceSid = context.SYNC_SERVICE_SID || 'default';

  // If `/gather-action` has already resolved a destination for this call, this
  // is the *second* out-of-session bounce (the redirect back into Proxy). Reply
  // with Proxy's auto-create-session JSON: Proxy stands up the session, binds
  // the caller to the exact (reserved) number they dialed, dials the
  // destination, and bridges the live call — no manual participant wiring, so
  // the caller can never land on the wrong proxy number.
  if (CallSid) {
    const client = context.getTwilioClient();
    const realNumber = await helpers.getResolution(
      client,
      syncServiceSid,
      CallSid
    );
    if (realNumber) {
      const body = {
        uniqueName: `${From} -> ${realNumber} @ ${new Date().toISOString()}`,
        ttl: 300,
        mode: 'voice-only',
        participantIdentifier: realNumber,
      };
      await helpers.publishEvent(client, syncServiceSid, {
        type: 'oos.autocreate',
        ts: new Date().toISOString(),
        callSid: CallSid,
        from: From,
        realNumber,
      });
      // Consumed — best-effort cleanup (the Sync TTL is the real backstop).
      await helpers.deleteResolution(client, syncServiceSid, CallSid);
      console.log(
        `Auto-creating session for CallSid ${CallSid}:`,
        JSON.stringify(body)
      );
      return callback(null, jsonResponse(body));
    }
  }

  // First bounce (no resolution yet): announce and prompt for the code.
  if (CallSid) {
    const client = context.getTwilioClient();
    await helpers.publishEvent(client, syncServiceSid, {
      type: 'oos.prompt',
      ts: new Date().toISOString(),
      callSid: CallSid,
      from: From,
    });
  }
  const baseUrl = helpers.getBaseUrl(context);
  return callback(null, gatherResponse(baseUrl));
};

Stashing the resolution and executing the redirect

After the caller enters their 6-digit code, Twilio posts the digits to the /gather-action endpoint. The endpoint calls an internal lookup routine to resolve the recipient's number. Once resolved, the application stashes the target number in Twilio Sync (keyed by the CallSid) and redirects the still-live call back into Twilio Proxy's voice webhook URL.

// src/functions/gather-action.ts

import '@twilio-labs/serverless-runtime-types';
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
  ServerlessEventObject,
} from '@twilio-labs/serverless-runtime-types/types';
import type * as Helpers from '../assets/helpers.private';

type GatherContext = {
  ACCOUNT_SID?: string;
  PROXY_SERVICE_SID?: string;
  SERVICE_BASE_URL?: string;
  SYNC_SERVICE_SID?: string;
};

type GatherEvent = {
  Digits?: string;
  From?: string;
  To?: string;
  CallSid?: string;
};

function errorTwiml(message: string): string {
  const twiml = new Twilio.twiml.VoiceResponse();
  twiml.say(message);
  twiml.hangup();
  return twiml.toString();
}

function xmlResponse(body: string): any {
  const response = new Twilio.Response();
  response.appendHeader('Content-Type', 'application/xml');
  response.setBody(body);
  return response;
}

export const handler: ServerlessFunctionSignature = async function (
  context: Context<GatherContext>,
  event: ServerlessEventObject<GatherEvent>,
  callback: ServerlessCallback
) {
  try {
    console.log('Gather-Action received:', JSON.stringify(event));
  } catch {
    // never let logging break the handler
  }

  const { Digits, From, CallSid } = event;

  if (!Digits || !From || !CallSid) {
    return callback(
      null,
      xmlResponse(errorTwiml('Sorry, we did not receive your input. Goodbye.'))
    );
  }

  try {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    const helpers = require(Runtime.getAssets()['/helpers.js']
      .path) as typeof Helpers;
    const baseUrl = helpers.getBaseUrl(context);

    // 1. Look up the real target number via the mock REST API.
    const lookupUrl =
      `${baseUrl}/lookup?Digits=${encodeURIComponent(Digits)}` +
      `&From=${encodeURIComponent(From)}`;
    const lookupResponse = await fetch(lookupUrl);
    if (!lookupResponse.ok) {
      throw new Error(`Lookup failed with status ${lookupResponse.status}`);
    }
    const { realNumber } = (await lookupResponse.json()) as {
      realNumber: string;
    };
    if (!realNumber) {
      throw new Error('Lookup returned no number');
    }
    console.log(
      `Resolved realNumber: ${realNumber} (Digits=${Digits}, From=${From})`
    );

    // 2. Stash the resolution so the *next* /out-of-session bounce can build
    //    the auto-create JSON. The out-of-session callback never carries the
    //    entered digits, so we persist the resolved number in Twilio Sync
    //    keyed by CallSid (stable across the redirect). We do NOT create the
    //    session or participants here — Proxy does that when it receives the
    //    auto-create response, and it binds the caller to the reserved number
    //    they actually dialed (which is why the caller can't land on the wrong
    //    proxy number and loop).
    const client = context.getTwilioClient();
    const syncServiceSid = context.SYNC_SERVICE_SID || 'default';
    await helpers.saveResolution(client, syncServiceSid, CallSid, realNumber);
    await helpers.publishEvent(client, syncServiceSid, {
      type: 'resolution.stored',
      ts: new Date().toISOString(),
      callSid: CallSid,
      from: From,
      realNumber,
    });
    console.log(
      `Stored resolution in Sync: CallSid=${CallSid} -> ${realNumber}.`
    );

    // 3. Redirect the live call back into Proxy. With no matching session yet,
    //    Proxy fires /out-of-session again — and that bounce returns the
    //    auto-create JSON, standing up the session and bridging the call.
    const redirectUrl =
      `https://webhooks.twilio.com/v1/Accounts/${context.ACCOUNT_SID}` +
      `/Proxy/${context.PROXY_SERVICE_SID}/Webhooks/Call`;
    const twiml = new Twilio.twiml.VoiceResponse();
    twiml.redirect({ method: 'POST' }, redirectUrl);
    return callback(null, xmlResponse(twiml.toString()));
  } catch (err) {
    console.error('gather-action error:', err);
    return callback(
      null,
      xmlResponse(
        errorTwiml('Sorry, we could not connect your call. Please try again later.')
      )
    );
  }
};

Creating the bidirectional lookup engine

The lookup engine maps a single code to a pair of users, resolving the recipient's phone number relative to who called first. If Party A (the courier) calls, the lookup returns Party B's (the customer) number. If Party B calls, it returns Party A's number. This elegant bidirectional routing is handled by the /lookup endpoint, which reads mapping data from a Twilio Sync Map.

// src/functions/lookup.ts
import '@twilio-labs/serverless-runtime-types';
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
  ServerlessEventObject,
} from '@twilio-labs/serverless-runtime-types/types';
import type * as Helpers from '../assets/helpers.private';

type LookupContext = {
  SYNC_SERVICE_SID?: string;
  DEFAULT_REAL_NUMBER?: string;
  getTwilioClient: () => any;
};

type LookupEvent = {
  Digits?: string;
  From?: string;
};

export const handler: ServerlessFunctionSignature = async function (
  context: Context<LookupContext>,
  event: ServerlessEventObject<LookupEvent>,
  callback: ServerlessCallback
) {
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const helpers = require(Runtime.getAssets()['/helpers.js']
    .path) as typeof Helpers;

  const syncServiceSid = context.SYNC_SERVICE_SID || 'default';
  const client = context.getTwilioClient();

  const ts = () => new Date().toISOString();
  await helpers.publishEvent(client, syncServiceSid, {
    type: 'lookup.request',
    ts: ts(),
    from: event.From,
    digits: event.Digits,
  });

  // The order → parties mapping lives in the Sync `lookup` Map (seeded by
  // `npm run seed:lookup`), so it's managed data rather than config.
  const entry = await helpers.getLookupEntry(
    client,
    syncServiceSid,
    helpers.LOOKUP_SYNC_MAP_NAME,
    event.Digits
  );
  const realNumber = helpers.resolveCounterparty(
    entry,
    event.From,
    context.DEFAULT_REAL_NUMBER
  );

  await helpers.publishEvent(client, syncServiceSid, {
    type: 'lookup.result',
    ts: ts(),
    from: event.From,
    digits: event.Digits,
    realNumber,
  });

  const response = new Twilio.Response();
  response.appendHeader('Content-Type', 'application/json');
  // Pass the object directly: the runtime JSON-serializes the body once for an
  // application/json response. Pre-stringifying here would double-encode it.
  response.setBody({ realNumber });
  return callback(null, response);
};

Configuring session lifecycle cleanups

To maximize the efficiency of the phone number pool, release numbers as soon as active interactions terminate. Point Twilio Proxy's Callback URL to the /callback endpoint. When an outbound call leg reaches a terminal state (such as completed, busy, or no-answer), the serverless function intercepts the event, validates the secure Twilio signature, and issues an API command to close the Proxy session instantly.

// src/functions/callback.ts

import '@twilio-labs/serverless-runtime-types';
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
  ServerlessEventObject,
} from '@twilio-labs/serverless-runtime-types/types';
import type * as Helpers from '../assets/helpers.private';

type CallbackContext = {
  AUTH_TOKEN?: string;
  PROXY_SERVICE_SID?: string;
  SERVICE_BASE_URL?: string;
  DOMAIN_NAME?: string;
};

type CallbackEvent = {
  interactionSessionSid?: string;
  outboundResourceStatus?: string;
  request?: { headers?: Record<string, string> };
};

// Terminal states for the outbound (destination) leg. Once the destination
// leg reaches any of these, the connection attempt is over — whether the
// destination answered or not — so the session, and the scarce proxy number
// it holds, can be released immediately instead of waiting out the TTL.
const TERMINAL_OUTBOUND_STATUSES = new Set([
  'completed',
  'busy',
  'no-answer',
  'failed',
  'canceled',
]);

function jsonResponse(statusCode: number): any {
  const response = new Twilio.Response();
  response.setStatusCode(statusCode);
  response.appendHeader('Content-Type', 'application/json');
  response.setBody(JSON.stringify({}));
  return response;
}

export const handler: ServerlessFunctionSignature = async function (
  context: Context<CallbackContext>,
  event: ServerlessEventObject<CallbackEvent>,
  callback: ServerlessCallback
) {
  // This endpoint now takes a destructive action (closing a live session), so
  // its public URL must be authenticated. Reject anything without a valid
  // Twilio signature BEFORE logging or acting on the payload — otherwise a
  // forged POST with a guessed session SID could tear down an active call.
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const helpers = require(Runtime.getAssets()['/helpers.js']
    .path) as typeof Helpers;
  const url = `${helpers.getBaseUrl(context)}/callback`;
  const signature =
    (event.request &&
      event.request.headers &&
      event.request.headers['x-twilio-signature']) ||
    '';
  // The runtime injects `request` and `cookies` into the event; strip them so
  // only the POST params Twilio actually signed remain.
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const { request, cookies, ...params } = event as any;

  const isValid =
    !!context.AUTH_TOKEN &&
    Twilio.validateRequest(context.AUTH_TOKEN, signature, url, params);
  if (!isValid) {
    console.warn('Rejected Proxy callback: missing/invalid Twilio signature.');
    return callback(null, jsonResponse(403));
  }

  try {
    console.log('Proxy Callback received:', JSON.stringify(event));

    const status = event.outboundResourceStatus;
    const sessionSid = event.interactionSessionSid;
    if (sessionSid && status && TERMINAL_OUTBOUND_STATUSES.has(status)) {
      const client = context.getTwilioClient();
      const serviceSid = context.PROXY_SERVICE_SID as string;
      await client.proxy.v1
        .services(serviceSid)
        .sessions(sessionSid)
        .update({ status: 'closed' });
      console.log(
        `Closed Proxy session ${sessionSid} after outbound leg reached "${status}".`
      );
    }
  } catch (err) {
    // Never let cleanup break the webhook — always ack 200 so Proxy doesn't
    // retry. The session's ttl is the backstop if this close didn't happen
    // (e.g. the session was already closed by an earlier terminal event).
    console.error('callback handler error:', err);
  }
  return callback(null, jsonResponse(200));
};

Deploy to Twilio Serverless Functions

On your terminal, enter the following command

npm run deploy

Configure Twilio Proxy

In the Twilio Console, navigate to Twilio Console > Products & Services > Twilio Proxy > Select Created Proxy > Configure

Based on the output of Step 8, fill up the following details:

  • Callback URL
  • Intercept Callback URL
  • Out Of Session Callback URL
Configuration page of Proxy Service KS with options for default timeout, callback URL, and geo whitelisting.

Get ready to test

Congratulations, you are now ready to start testing the dynamic session creation!

To start testing,

  1. Call the reserved phone number that you have in your Twilio Proxy’s phone number pool
  2. Enter the 6 digit code that you have configured in Step 2
  3. Call your reserved number to trigger the IVR, which will route and connect your call directly to the destination number defined in your LOOKUP_MAP environment variable.

Conclusion

By implementing a serverless Just-in-Time (JIT) Masked Session workflow, you eliminate the operational inefficiencies of statically pre-allocating phone numbers. This '2-bounce' redirect pattern using Twilio Functions and Twilio Sync creates a highly secure, private, and frictionless communication bridge that works across standard cellular connections. Your number pool is preserved, customer trust is reinforced, and your infrastructure scales elastically.

As a next step, you can elevate your contact center experience by building real-time translation with OpenAI’s Realtime API or implementing ultra-low-latency, speech-to-speech AI assistants using Twilio Media Streams with NVIDIA PersonaPlex. We’re excited to see what you build next!

Leroy is a seasoned presales solution architect with a knack for designing scalable architectures on the cloud. He is currently part of the Solution Engineering team for APJ. Leroy can be reached at lechan [at] twilio.com.