Build a privacy-first call masking service in 30 lines of VoyloML
Ride-sharing, marketplaces, HR screening — any product where two parties need to call each other without exposing their real numbers. Shared pool DID, session-based pairing, auto-recorded, callable in both directions. Full XML, data model, and the one gotcha everyone hits on day two.
Call masking is the pattern behind every ride-sharing driver contact flow, every delivery courier call, every marketplace where you shouldn't have to give a stranger your personal number to coordinate a meetup. Rider calls a shared number; driver answers; both parties converse; neither sees the other's real digits. The product category covers taxis, food delivery, recruiting, dating apps, classifieds, home services, telehealth — anywhere a transient two-party introduction needs a privacy layer.
The pattern is 20 years old. Twilio, Vonage, and MessageBird all have "proxy service" products that automate it at eye-watering per-session pricing. On Voylo, call masking is an ordinary VoyloML application — 30 lines of XML, one shared DID, no vendor to subscribe to.
This post walks through a real implementation. Two of our own customers (one in logistics, one in flower delivery) run variants of this in production today.
The model
Three concepts:
- Pool DID — one shared phone number that every call enters through. Could be a single number, could be a pool of several numbers that you rotate between to avoid caller-ID lock-in.
- Session— a short-lived mapping between two parties. "Rider A ↔ Driver B, expires at 7pm, pool number C." Lives in your backend.
- Direction detection— when someone dials the pool DID, you need to know whether this is Rider calling Driver or Driver calling Rider, so you know who to bridge them to. The caller-ID of the inbound leg is the key: if caller matches Rider's number, send to Driver; if caller matches Driver's number, send to Rider.
Your backend sets up a session
When a rider is matched with a driver, your backend persists a row that lets Voylo resolve the direction later. Something like:
{
"session_id": "sess_abc123",
"rider_e164": "+971504445566",
"driver_e164": "+971507778899",
"pool_did": "+971200200100",
"expires_at": "2026-11-01T20:00:00Z"
}Keyed for lookup by (pool_did, caller_e164). Your API has two endpoints:
- Create session — called by your product backend when a rider/driver pair is matched. Returns the pool DID that both parties should see.
- Resolve call — called by Voylo when a call lands on the pool DID. Takes
pool_didandcaller_e164, returns the counterparty's number OR a rejection reason (session expired, caller not party to any session, etc.).
The entire VoyloML app
One file, 30 meaningful lines. Point your pool DID at it.
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<!-- Resolve who the caller is talking to -->
<Request url="https://your-api.example.com/masking/resolve"
resultVar="r">
<Header name="Authorization" value="Bearer YOUR_API_KEY"/>
<Param name="pool_did" value="{{To}}"/>
<Param name="caller" value="{{From}}"/>
<Var name="status" path="status"/>
<Var name="other_party" path="other_party_e164"/>
<Var name="session_id" path="session_id"/>
</Request>
<!-- Branch on what the backend said -->
<Switch on="status">
<Case value="ok">
<!-- Known session. Bridge the two parties. Recording on. -->
<Dial callerId="{{To}}" timeout="30" record="always">
<Number>{{other_party}}</Number>
</Dial>
</Case>
<Case value="expired">
<Play>https://your-cdn/prompts/session-expired.wav</Play>
<Hangup/>
</Case>
<Default>
<Play>https://your-cdn/prompts/unknown-caller.wav</Play>
<Hangup/>
</Default>
</Switch>
</Response>Note we aren't wiring up a post-call webhook inside the app XML. On Voylo, every application has a finished URLyou set once in the application settings — Voylo fires a POST to it after every call ends, with the full CDR: duration, caller, callee, dial status, recording URL, your custom variables, everything. For a call masking app, that's the natural place to log the session outcome, record billing, and decrement any usage quota. Nothing extra to wire into each VoyloML flow.
Walkthrough
- A call lands on the pool DID. VoyloML binds
{{To}}to the DID and{{From}}to the caller. - Your backend's
/masking/resolveis called with both values. It looks up the active session for this(pool_did, caller)pair. If the caller is party to a session, it returnsstatus="ok"plus the other party's number. If the session expired,status="expired". If the caller isn't in any session,status="unknown"(handled by<Default>). - On
ok, VoyloML dials the counterparty via<Dial>withcallerId="{{To}}"— the counterparty sees the pool DID as the caller, not the real originator's number.That's the privacy guarantee. Call is auto-recorded. - After the call ends — regardless of which branch ran — Voylo POSTs the full CDR to the application's finished URL (configured once per application, not inside the XML). Your backend logs the session, closes it out, decrements usage, bills — whatever the business layer needs. One hook, every call, no extra VoyloML.
The one gotcha everyone hits on day two
You deploy this. It works. Then on day two you discover that when the driver calls the rider, the counterparty (rider) sees the pool DID — but what if they missed the call and want to call back? They dial the number on their missed-call list — the pool DID. From the pool DID's perspective, they're now the caller; their counterparty should be the driver who called them. Your /masking/resolve looks up (pool_did, rider_e164) and needs to find the same session, same driver.
That works if your session lookup is bidirectional — caller IN (rider, driver) returns the otherparty. If your lookup is one-directional ("find session where rider = caller"), callbacks fail. The schema we showed above handles this correctly: the backend decides who's who based on which side of the session the caller is on.
Variations
- Pool of DIDs instead of one: if your product has thousands of simultaneous active sessions, one shared DID has concurrency limits. Rent a pool of 10 numbers; your backend's
/masking/resolverotates through them. VoyloML app is identical — the pool number just becomes{{To}}, and your lookup keys on it. - Country-local DIDs: for cross-border masking (a Dubai rider matched with a Saudi driver, say), a KSA driver sees a KSA pool DID; a UAE rider sees a UAE pool DID. Rent DIDs in both countries, add
countryas a column on the session, serve the right one from/masking/resolve. - Delayed-reveal: for a dating or recruiting app, you might want the mask to persist for weeks and then optionally reveal the real number by mutual opt-in. The session simply gets a longer
expires_atand arevealed_atcolumn. - SMS to go with it: in-session text relay is the natural pair for voice masking. (Voylo doesn't do SMS natively yet — pair with a messaging provider on the same pool DID if your use case needs it.)
Recordings and compliance
record="always" on the <Dial> captures the call. The recording URL lands in {{RecordingUrl}} and in the webhook payload to your log endpoint. For marketplaces this is table stakes — dispute resolution, trust & safety, fraud investigation. For regulated verticals (telehealth, financial services) the per-jurisdiction consent prompts are on you to prepend before <Dial>.
<Play> prompt before <Dial>informing both parties that the call will be recorded. "This call will be recorded for safety and quality." is the usual phrasing; the specific wording and timing (before vs after answer) depends on the jurisdictions you serve. In the EU, the callee must also hear it before they answer the live conversation — handled via confirmSound on <Dial>.Why not just use a vendor's "proxy service" API?
Two reasons:
- Pricing.Vendor proxy services charge per masked session on top of per-minute. The sessions concept exists because they're billing a software layer, not just carrier minutes. On Voylo, the session is a row in your database; the only cost is carrier minutes and your number rental. Our customers running production call masking see 60-80% cost reduction vs. equivalent Twilio Proxy deployments at similar volumes.
- Flexibility. The vendor API constrains how sessions work — their TTLs, their revelation rules, their SMS integration (or lack of). VoyloML is just XML plus your backend; you shape the behaviour.
The one-line summary
If you're running two-sided marketplaces, delivery, or any product where strangers need to call each other without exposing phone numbers, this pattern is a shared DID + a session table + 30 lines of VoyloML. No vendor subscription required; your backend owns the session lifecycle; the privacy guarantee is enforced at the signalling layer.
Free tier covers everything in this post. No credit card.