If you run a marketplace, a delivery or ride service, or any product where two strangers need to talk repeatedly around an order, one-shot "call now" bridging isn't quite enough. You want a single virtual number that both people can call, in either direction, for as long as the order is live — and that stops working the moment it's done.
That's exactly what the Session API does. This guide shows you how to build two-way number masking end to end: create a session, handle inbound calls, route them dynamically from your own server, and react to lifecycle events.
New to the concept? Start with What Is Number Masking? and the Number Masking product overview. For the full reference, see the Two-Way Sessions docs.
Sessions vs. click-to-call
There are two ways to mask a call, and they suit different moments:
- Click-to-call (
POST /masking/calls) is outbound: the platform dials Party A, then bridges Party B. Perfect for a "Call the driver" button that places one call right now. - Sessions (
POST /masking/sessions) are inbound: you get a virtual number back, and either party dials it whenever they want, as many times as you allow, until the session expires.
For an ongoing buyer↔seller or customer↔driver relationship, sessions are the right primitive. The rest of this guide focuses on them. (See the Session API feature page for a summary.)
Step 1: Create a session
When an order is created, bind the two numbers and hand the virtual number to both parties. Pass your own order id as reference so the call is idempotent, and an expiry_minutes so the number frees itself when the order is done.
curl -X POST https://voice-api.edesy.in/v1/masking/sessions \
-H "Authorization: Bearer vp_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"party_a": "9876543210",
"party_b": "9123456789",
"reference": "ORD-4821",
"expiry_minutes": 120
}'const res = await fetch("https://voice-api.edesy.in/v1/masking/sessions", {
method: "POST",
headers: {
Authorization: "Bearer vp_YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
party_a: buyer.phone,
party_b: seller.phone,
reference: order.id, // idempotent: safe to retry
expiry_minutes: 120,
}),
});
const { data } = await res.json();
// Give data.virtual_number to both buyer and sellerThe response contains the virtual_number and a session_id:
{
"data": {
"session_id": "3f9a1c72-5b8e-4d21-9c34-7a2e6f0b1d55",
"virtual_number": "918071387146",
"party_a": "9876543210",
"party_b": "9123456789",
"reference": "ORD-4821",
"status": "active",
"expires_at": "2026-07-14T18:30:00Z",
"total_calls": 0,
"created_at": "2026-07-14T18:00:00Z"
}
}Because create is idempotent on reference, retrying the same order id returns the same session — no duplicate numbers, no bookkeeping on your side. If every enrolled number is busy for these parties, you get a 422 telling you to enroll more numbers — your signal to add capacity.
Step 2: Let either party call
Now both people simply call the virtual_number. The platform looks at who is calling and bridges them to the other party; both see the virtual number as caller ID. There's nothing more to build for the happy path — routing is automatic.
One virtual number safely serves many concurrent sessions at once (as long as no party is live on it twice), so you don't need one number per order.
Step 3: Manage the session as the order changes
Orders get delayed, extended, or cancelled. Reflect that on the session:
# Order extended — push the expiry out
curl -X PATCH https://voice-api.edesy.in/v1/masking/sessions/SESSION_ID \
-H "Authorization: Bearer vp_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "expiry_minutes": 120 }'
# Temporarily block calls (e.g. dispute on hold)
curl -X PATCH https://voice-api.edesy.in/v1/masking/sessions/SESSION_ID \
-H "Authorization: Bearer vp_YOUR_API_KEY" \
-d '{ "status": "paused" }'
# Order done — end it now and free the number
curl -X DELETE https://voice-api.edesy.in/v1/masking/sessions/SESSION_ID \
-H "Authorization: Bearer vp_YOUR_API_KEY"If you set an expiry_minutes, you don't even need the DELETE — the session expires on its own and the number returns to the pool. See Manage a Session for the full surface.
Step 4 (optional): Route calls dynamically from your own server
The default routing — connect the two parties on the session — covers most cases. But sometimes you want to decide the target per call, at call time: round-robin across a team, route by time of day, or block a caller you've flagged.
For that, point a number at your own HTTPS endpoint with the dynamic routing webhook. When a call arrives, the platform sends you a signed request:
{
"event": "masking.route",
"call_sid": "d28be7cd-9840-4b23-9627-199f1ef81fc6",
"caller": "9876543210",
"masked_number": "918071387146",
"direction": "inbound",
"timestamp": "2026-07-14T18:00:00Z"
}Your endpoint replies with the number to connect to — or rejects the call:
// Verify X-Edesy-Signature (HMAC-SHA256, "sha256=<hex>") over the raw body first
app.post("/edesy/route", (req, res) => {
const agent = pickAvailableAgent(); // your logic
if (!agent) {
return res.json({ action: "reject", reject_reason: "All agents are busy. Please try later." });
}
res.json({ action: "connect", target_number: agent.phone });
});You have a 5-second budget to respond (the caller is on the line), and you can optionally fall back to the number's internal mapping if your endpoint is down. Every request is HMAC-signed so you can verify it's genuinely from us.
Step 5: React to lifecycle events
Register one event webhook and the platform will POST you session.created, call.incoming, call.connected, call.ended, call.missed, and session.expired — each signed with HMAC-SHA256. Use them to log connected calls against the order, flag missed calls for follow-up, or reconcile usage:
{
"event": "call.ended",
"reference": "ORD-4821",
"virtual_number": "918071387146",
"caller": "9876543210",
"callee": "9123456789",
"direction": "a_to_b",
"call_sid": "d28be7cd-9840-4b23-9627-199f1ef81fc6",
"duration_sec": 92,
"status": "completed",
"timestamp": "2026-07-14T18:05:00Z"
}Because reference is echoed on every event, you can tie each call straight back to the order that created it. A delivery log and a "send test event" endpoint are there for debugging.
A note on scope
Edesy number masking is voice-only and uses 10-digit Indian mobile numbers, billed at Rs 1.50 per masked minute on a prepaid wallet with a GST invoice — you pay only for connected minutes, with no per-seat licensing. There's no SMS masking.
Putting it together
- Order created →
POST /masking/sessionswith your order id asreference; handvirtual_numberto both parties. - They call → the platform bridges them automatically (or asks your routing webhook).
- Order changes →
PATCHto extend, pause, or resume. - Order done → let it expire, or
DELETEto free the number now. - Throughout → event webhooks keep your systems in sync.
That's a complete, privacy-safe, two-way calling channel scoped to the life of an order — in five API calls.
Ready to build? Read the Two-Way Sessions reference, grab an API key in the portal, and see pricing.