Skip to main content
Connect two parties, a field agent and a customer, through masking numbers so neither ever sees the other’s real number. Either side can call, and both directions are private. XML elements used: <Dial callerId=…>, <Number>, <Speak>, <Hangup>

View on GitHub

Clone and run the full working example
New to the concept? Read What is Number Masking? for the why and the call-flow theory, then come back here to build it.

Getting started

git clone https://github.com/vobiz-ai/Vobiz-Two-Way-Number-Masking.git
cd Vobiz-Two-Way-Number-Masking
cp .env.example .env
pip install -r requirements.txt
uvicorn app:app --reload --port 8001

Overview

You provision two masking DIDs and attach both to a single Vobiz application. When either number is dialed, Vobiz POSTs the call to your /answer webhook. Your backend looks up the dialed number (To), checks the caller (From) is authorized, and returns a <Dial> that bridges to the real destination, showing the masking number as caller ID. Neither party sees a real number. The whole system is two rows of mapping:
  • M1 is the number the agent dials to reach the customer.
  • M2 is the number the customer dials to reach the agent.
Caller IDs are cross-mapped so each party always sees one consistent number for the other: dial M1 and the customer sees M2; dial M2 and the agent sees M1.
Dialed number (To)Allowed caller (From)Bridges toCaller ID shown
M1the agentthe customerM2
M2the customerthe agentM1

Call flow

Agent calls the customer
  Agent (FE) ── dials ──▶ M1
        └── Vobiz ── POST /answer  From=FE, To=M1 ──▶ your backend
                       resolve(): authorize FE, dial CUSTOMER, show M2
              ◀── <Dial callerId="M2"><Number>CUSTOMER</Number></Dial> ──┘
        └── Vobiz dials CUSTOMER, who sees M2 ── both legs bridge

Customer calls the agent  (mirror image)
  Customer ── dials ──▶ M2
        └── Vobiz ── POST /answer  From=CUSTOMER, To=M2 ──▶ your backend
                       resolve(): authorize CUSTOMER, dial FE, show M1
              ◀── <Dial callerId="M1"><Number>FE</Number></Dial> ──┘
        └── Vobiz dials FE, who sees M1 ── both legs bridge
If the caller is not the registered party for that masking number, the backend rejects the call with a spoken message instead of bridging.

Vobiz webhook

Set /answer as the Answer URL on your Vobiz application (the included setup_live.py does this for you).
MethodPathDescription
POST/answerResolves the dialed masking number and returns a <Dial> (or a rejection)
GET/healthHealth check

Environment variables

VariableRequiredDescription
VOBIZ_AUTH_IDYesVobiz account auth ID
VOBIZ_AUTH_TOKENYesVobiz account auth token
VOBIZ_API_BASENoAPI base (defaults to https://api.vobiz.ai/api/v1)
PUBLIC_BASE_URLYesYour public HTTPS tunnel (Cloudflare/ngrok), used as the Answer URL
M1YesMasking DID the agent dials → reaches the customer
M2YesMasking DID the customer dials → reaches the agent
FEYesField agent’s real number
CUSTOMERYesCustomer’s real number

Step 1: The number map

All numbers come from the environment, so no real numbers live in source. The resolve() function returns a Leg only when the dialed number is a known masking number and the caller is the registered party for it. Matching is format-tolerant (compares the final 10 digits), so +, spaces, a leading 0, or a missing country code all still match.
mappings.py
import os
from dataclasses import dataclass
from dotenv import load_dotenv

load_dotenv()

FE = os.environ.get("FE", "918888800001")              # field agent real number
CUSTOMER = os.environ.get("CUSTOMER", "918888800002")  # customer real number
M1 = os.environ.get("M1", "919000000001")              # masking: FE -> Customer
M2 = os.environ.get("M2", "919000000002")              # masking: Customer -> FE


@dataclass
class Leg:
    masking_number: str   # the number that was dialled (the `To`)
    allowed_caller: str   # only this registered real number may use it
    destination: str      # the real number to bridge to
    caller_id_shown: str  # the masking number the destination should SEE


# Keyed by the number that was *dialled* (the `To`).
# Cross-mapped caller ID: dial M1 -> customer sees M2; dial M2 -> agent sees M1.
MAPPINGS: dict[str, Leg] = {
    M1: Leg(masking_number=M1, allowed_caller=FE, destination=CUSTOMER, caller_id_shown=M2),
    M2: Leg(masking_number=M2, allowed_caller=CUSTOMER, destination=FE, caller_id_shown=M1),
}


def _norm(number: str) -> str:
    """Digits only, last 10 — tolerant of '+', spaces, leading 0, country code."""
    digits = "".join(c for c in (number or "") if c.isdigit())
    return digits[-10:] if len(digits) >= 10 else digits


_NORM_MAPPINGS = {_norm(k): v for k, v in MAPPINGS.items()}


def resolve(to_number: str, from_number: str) -> Leg | None:
    """Return the Leg if `to_number` is a masking number AND `from_number` is its
    registered caller; otherwise None."""
    leg = _NORM_MAPPINGS.get(_norm(to_number))
    if leg is None or _norm(leg.allowed_caller) != _norm(from_number):
        return None
    return leg

Step 2: The XML builders

vobiz_xml.py holds a few small helpers (response(), dial(), speak(), hangup()) that return Vobiz XML strings so handlers don’t concatenate strings by hand. The only one that matters for masking is dial(), whose caller_id argument becomes the callerId the called party sees, and for masking that is always the masking number, never the real caller:
dial(leg.destination, caller_id=leg.caller_id_shown, timeout=30)
# → <Dial callerId="919000000002" timeout="30"><Number>918888800002</Number></Dial>
The full builder module is in the repo, you can also just return the XML as a literal string if you prefer.

Step 3: The webhook

The /answer handler is stateless and idempotent: it reads From and To, resolves the mapping, and returns either a bridging <Dial> or a spoken rejection. Every request carries everything it needs, so retries are safe and you can run many instances behind a load balancer.
app.py
from fastapi import FastAPI, Form, Response

import mappings
from vobiz_xml import dial, hangup, response, speak

app = FastAPI(title="Vobiz masking — two-way (client-hosted)")

XML = "application/xml"


@app.post("/answer")
async def answer(
    From: str = Form(...),
    To: str = Form(...),
    CallUUID: str = Form(default=""),
    Direction: str = Form(default=""),
    CallStatus: str = Form(default=""),
) -> Response:
    """Vobiz POSTs call details here when a masking number is dialled."""
    leg = mappings.resolve(to_number=To, from_number=From)
    if leg is None:
        xml = response(
            speak("Sorry, this number is not authorised for your account."),
            hangup(reason="rejected"),
        )
        return Response(content=xml, media_type=XML)

    xml = response(
        dial(leg.destination, caller_id=leg.caller_id_shown, timeout=30),
    )
    return Response(content=xml, media_type=XML)


@app.get("/health")
async def health() -> dict:
    return {"status": "ok", "strategy": "two-way-client-hosted"}
When the agent dials M1, this returns:
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Dial callerId="919000000002" timeout="30">
    <Number>918888800002</Number>
  </Dial>
</Response>

Step 4: Expose your server over HTTPS

Vobiz needs a public HTTPS Answer URL. In development, tunnel your local port:
# Cloudflare tunnel (or use ngrok http 8001)
cloudflared tunnel --url http://127.0.0.1:8001
Copy the https://… URL it prints into PUBLIC_BASE_URL in your .env.

Step 5: Go live

Run the bundled one-time setup script:
python setup_live.py
It creates a Vobiz application whose Answer URL is PUBLIC_BASE_URL/answer, then attaches both masking DIDs to it, so inbound calls to either number hit your server. (You can also do this in the dashboard: create one application and point both numbers at it.)

Test it

Offline (no real call), simulate Vobiz’s webhook with a POST:
curl -X POST localhost:8001/answer -d "From=918888800001" -d "To=919000000001"
Expected response, dial the customer, show M2 as caller ID:
<Response><Dial callerId="919000000002" timeout="30"><Number>918888800002</Number></Dial></Response>
An unregistered caller is rejected:
curl -X POST localhost:8001/answer -d "From=910000000000" -d "To=919000000001"
# → <Response><Speak>Sorry, this number is not authorised…</Speak><Hangup reason="rejected"/></Response>
Then place a real call: from the agent phone, dial M1, the customer’s phone should ring showing M2.

Troubleshooting

The callerId on <Dial> must be a Vobiz DID you own. Confirm leg.caller_id_shown resolves to M1/M2 (your masking numbers), not the real FE/CUSTOMER values. Using a number you don’t own is rejected as Unknown Caller ID (hangup code 3030, see Hangup Causes).
resolve() requires the From number to match the registered caller for that masking number. Check the caller is dialing from their registered handset and that FE/CUSTOMER in .env match the real numbers (matching uses the last 10 digits, so country-code formatting is tolerant but the digits must match).
Verify PUBLIC_BASE_URL is reachable over HTTPS and that setup_live.py attached the application to both DIDs. A non-2xx or unreachable Answer URL fails with code 7011; an invalid XML response fails with 8011, see Hangup Causes.
Your /answer handler must respond well within the webhook timeout (aim for sub-second). Keep the mapping in fast in-memory or cache storage and avoid slow DB calls on the hot path.

What’s next

  • Expire mappings. Create a pairing when a transaction starts and remove it when the order is delivered or the ride ends, after that, dialing the masking number connects to nothing.
  • Scale beyond one pair. Reuse a pool of DIDs across many simultaneous pairings by keying the mapping on (masking number + caller) and rotating numbers.
  • Add recording or IVR. Compose the masked leg with call recording or a cloud IVR greeting before bridging.
  • Validate callbacks. Verify webhook authenticity before acting on a request in production.

Number masking, the use case

See where two-way masking fits, ride-hailing, delivery, marketplaces, and how Vobiz powers it.