Number masking lets two parties speak over a bridged phone call without either party ever seeing the other's real phone number. Both parties see only your Sinch virtual number as the caller ID. The Voice API v2 builds this from three primitives:
- Answer an inbound call from Party A on your Sinch number (delivered as a
call.incomingwebhook). dialParty B with the Sinch number asfrom, so Party B sees the Sinch number, not Party A.bridgeCallboth legs into a sharedbridgeNameso audio flows A ↔ B.
When either party hangs up, the other leg is torn down too (via onHangup).
Masking is webhook-driven, so first success is a three-part loop. Unlike a one-shot outbound call, you can't trigger a masked bridge with a single curl and watch it work. First success requires three things running together: (1) your webhook server is up, (2) it's reachable from the internet (ngrok), (3) your Sinch service points its webhook at it. The Quick start below gets all three in place, then you dial your Sinch number. If you'd rather see something work before wiring up a phone, jump to Step 0.
For the inbound webhook contract itself (CloudEvents headers, the response shape, signature verification), see Handle Inbound PSTN Calls. This tutorial focuses on the masking pattern layered on top of it.
Deploy the number-masking template: it implements exactly this pattern — it answers call.incoming from Party A, dials Party B with the Sinch number as the caller ID, bridges both legs, and tears the other leg down on hangup. Fill in the values, then either deploy it as it is or take it into Assistant and change it in chat.
Describe your own instead: open Assistant and describe what the call should do. It writes, runs and deploys the function for you.
Or use the CLI: Execute sinch functions init number-masking, then sinch functions dev so real calls reach your server without a tunnel. Execute sinch functions deploy to put it in production once you're satisfied. See the Sinch Functions quickstart.
- Ride-sharing: Driver and passenger talk without sharing personal numbers. The app hands out a masked Sinch number per ride.
- Marketplace transactions: Buyer and seller call each other through the platform without revealing real numbers.
- Healthcare: A patient calls a Sinch number to reach their doctor, who sees only the clinic's virtual number.
- Delivery services: Agent and recipient coordinate through a disposable Sinch number that expires after delivery.
Every command and server in this tutorial reads its configuration from environment variables. Export them in the shell you'll use to run the server and the curl commands:
export PROJECT_ID="your-project-id"
export KEY_ID="your-key-id"
export KEY_SECRET="your-key-secret"
export SERVICE_ID="your-service-id"
export SINCH_NUMBER="+14045001000" # the masking number both parties see
export DESTINATION_NUMBER="+15551234567" # Party B for the single-pair demo
export CALLBACK_URL="" # fill in after you start ngrok (Quick start step 2)
export PORT="3000"Exports live only in the current shell session. Re-export them in any new terminal you open (or add them to your shell profile). Every server below reads these variables directly from the environment and exits with an error if a required one is missing.
| Variable | What it is | Where to get it |
|---|---|---|
PROJECT_ID | Your Voice project ID | Sinch Dashboard → Voice → your project |
KEY_ID / KEY_SECRET | API credentials for HTTP Basic auth | Dashboard → Access keys |
SERVICE_ID | The service that owns your Sinch number | Dashboard → Voice → Services (or GET /v2/projects/{projectId}/services). This is the service whose callBehavior you switch to WEBHOOK. |
SINCH_NUMBER | Your Sinch virtual number, E.164 (e.g. +14045001000). This is the masking number both parties will see. | Dashboard → Numbers, routed to the service above |
DESTINATION_NUMBER | Party B, the number the inbound caller gets bridged to, E.164 | Any phone you can answer |
CALLBACK_URL | Your public webhook base URL (the ngrok URL) | Generated by ngrok in the Quick start |
PORT | Local server port (defaults to 3000) | (optional) |
Tools and dependencies (pick one server language):
- Node.js 18+ (ES modules +
express):npm install express - Python 3.8+ with Flask:
pip install flask - PHP 8+ with Slim 4:
composer require slim/slim slim/psr7 nyholm/psr7 php-di/php-di - Java 11+ with Spring Boot:
spring-boot-starter-web - ngrok (or any tunnel) to expose your local server.
- HTTP Basic auth: every API/PATCH call authenticates with
-u "$KEY_ID:$KEY_SECRET".
The webhook servers in this tutorial all listen at POST /webhook. So your full webhook URL is CALLBACK_URL + /webhook.
Before any tunneling, confirm the masking SVAML you intend to return is valid. POST /svaml/validate checks a full SVAML payload (commands, optional callName, optional events) with the same rules as a live call and returns { "isValid": true | false, "errors": [...] }. You can optionally pass "validationType": "STRICT" to catch unrecognized properties.
The request wraps the SVAML payload inside a svaml property:
curl -s -X POST \
-u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/svaml/validate" \
-H "Content-Type: application/json" \
-d "$(printf '{
"validationType": "STRICT",
"svaml": {
"callName": "caller",
"commands": [
{ "command": "answer" },
{
"command": "messages",
"messagesName": "greeting",
"messages": [
{ "type": "SAY", "say": { "text": "Please hold while we connect your call.", "voiceName": "Emma" } }
]
},
{ "command": "bridgeCall", "bridgeName": "main-bridge" },
{
"command": "dial",
"callName": "callee",
"from": { "type": "PHONE", "phone": { "number": "%s" } },
"to": { "type": "PHONE", "phone": { "number": "%s" } },
"dialTimeoutDurationSeconds": 30,
"events": {
"onAnswer": [{ "command": "bridgeCall", "bridgeName": "main-bridge" }],
"onHangup": [{ "command": "hangup", "callName": "caller" }]
}
}
],
"events": {
"onHangup": [{ "command": "hangup", "callName": "callee" }]
}
}
}' "$SINCH_NUMBER" "$DESTINATION_NUMBER")"Expected: {"isValid":true}. A 200 means validation ran, not that the payload passed, so always read isValid.
Save one of the servers below to a file and run it. Each reads SINCH_NUMBER and DESTINATION_NUMBER from the environment (see Setup) and exits with an error if either is missing. On call.incoming it answers, plays a hold greeting, bridges Party A, and dials DESTINATION_NUMBER (Party B) from the Sinch number. On any other event it acknowledges with 200 and {"commands": []}.
Node.js: save as server.mjs (the .mjs extension enables ES modules), then npm install express and node server.mjs:
// server.mjs: Sinch Number Masking webhook server (Express).
// Requires: npm install express
// Env: SINCH_NUMBER, DESTINATION_NUMBER, PORT (optional, defaults to 3000)
import express from "express";
const sinchNumber = process.env.SINCH_NUMBER;
const destinationNumber = process.env.DESTINATION_NUMBER;
const PORT = process.env.PORT || 3000;
if (!sinchNumber || !destinationNumber) {
console.error("ERROR: SINCH_NUMBER and DESTINATION_NUMBER must be set.");
process.exit(1);
}
const app = express();
app.use(express.json());
// POST /webhook: receives Sinch call events and responds with SVAML
app.post("/webhook", (req, res) => {
const event = req.body?.event;
const call = req.body?.call;
console.log(`Received webhook event: ${event}`, JSON.stringify(call, null, 2));
if (event === "call.incoming") {
// Inbound call to the Sinch number from Party A. Respond with SVAML to:
// answer, play a hold greeting, bridge Party A, dial Party B from the Sinch
// number (masking Party A), and bridge Party B in when they answer.
// Commands run directly at the top level. "callName" names the inbound
// (caller) leg; "events.onHangup" handles the caller hanging up.
return res.status(200).json({
callName: "caller",
commands: [
{ command: "answer" },
{
command: "messages",
messagesName: "greeting",
messages: [
{ type: "SAY", say: { text: "Please hold while we connect your call.", voiceName: "Emma" } }
]
},
// Add Party A to a named bridge (auto-created if it does not exist)
{ command: "bridgeCall", bridgeName: "main-bridge" },
{
command: "dial",
callName: "callee",
// Party B sees the Sinch number, not Party A's real number
from: { type: "PHONE", phone: { number: sinchNumber } },
// In production, look up Party B from your DB keyed on call.to.phone.number
to: { type: "PHONE", phone: { number: destinationNumber } },
dialTimeoutDurationSeconds: 30,
events: {
onAnswer: [{ command: "bridgeCall", bridgeName: "main-bridge" }],
onHangup: [{ command: "hangup", callName: "caller" }]
}
}
],
// Caller hangs up -> end the outbound (callee) leg too
events: { onHangup: [{ command: "hangup", callName: "callee" }] }
});
}
console.log(`Unhandled event: ${event}`);
res.status(200).json({ commands: [] });
});
app.listen(PORT, () => {
console.log(`Number masking webhook server listening on port ${PORT}`);
});Python (Flask): save as server.py, then pip install flask and python server.py
# server.py: Sinch Number Masking webhook server (Flask).
# Requires: pip install flask
# Env: SINCH_NUMBER, DESTINATION_NUMBER, PORT (optional, defaults to 3000)
import os
import sys
import json
from flask import Flask, request, jsonify
sinch_number = os.environ.get("SINCH_NUMBER")
destination_number = os.environ.get("DESTINATION_NUMBER")
if not sinch_number or not destination_number:
print("ERROR: SINCH_NUMBER and DESTINATION_NUMBER must be set.", file=sys.stderr)
sys.exit(1)
app = Flask(__name__)
@app.route("/webhook", methods=["POST"])
def webhook():
"""Receives Sinch call events and responds with SVAML commands."""
body = request.get_json(force=True)
event = body.get("event")
call = body.get("call", {})
print(f"Received webhook event: {event}")
print(json.dumps(call, indent=2))
if event == "call.incoming":
# Commands run directly at the top level; "callName" names the inbound
# (caller) leg and "events.onHangup" handles the caller hanging up.
return jsonify({
"callName": "caller",
"commands": [
{"command": "answer"},
{
"command": "messages",
"messagesName": "greeting",
"messages": [
{"type": "SAY", "say": {"text": "Please hold while we connect your call.", "voiceName": "Emma"}}
]
},
# Add Party A to a named bridge
{"command": "bridgeCall", "bridgeName": "main-bridge"},
{
"command": "dial",
"callName": "callee",
# Party B sees the Sinch number
"from": {"type": "PHONE", "phone": {"number": sinch_number}},
# In production, look up the destination from your DB (call["to"])
"to": {"type": "PHONE", "phone": {"number": destination_number}},
"dialTimeoutDurationSeconds": 30,
"events": {
"onAnswer": [{"command": "bridgeCall", "bridgeName": "main-bridge"}],
"onHangup": [{"command": "hangup", "callName": "caller"}]
}
}
],
"events": {"onHangup": [{"command": "hangup", "callName": "callee"}]}
}), 200
print(f"Unhandled event: {event}")
return jsonify({"commands": []}), 200
if __name__ == "__main__":
port = int(os.environ.get("PORT", 3000))
print(f"Number masking webhook server listening on port {port}")
app.run(host="0.0.0.0", port=port)PHP (Slim 4): save as server.php, composer require slim/slim slim/psr7 nyholm/psr7 php-di/php-di, then php -S 0.0.0.0:3000 server.php
<?php
// server.php: Sinch Number Masking webhook server (Slim 4).
// Requires: composer require slim/slim slim/psr7 nyholm/psr7 php-di/php-di
// Env: SINCH_NUMBER, DESTINATION_NUMBER, PORT (optional, defaults to 3000)
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
require __DIR__ . '/vendor/autoload.php';
$sinchNumber = getenv('SINCH_NUMBER') ?: die("ERROR: SINCH_NUMBER not set.\n");
$destinationNumber = getenv('DESTINATION_NUMBER') ?: die("ERROR: DESTINATION_NUMBER not set.\n");
$app = AppFactory::create();
$app->addBodyParsingMiddleware();
// POST /webhook: receives Sinch call events and responds with SVAML
$app->post('/webhook', function (Request $request, Response $response) use ($sinchNumber, $destinationNumber) {
$body = $request->getParsedBody();
$event = $body['event'] ?? null;
$call = $body['call'] ?? [];
error_log("Received webhook event: {$event}");
error_log(json_encode($call, JSON_PRETTY_PRINT));
if ($event === 'call.incoming') {
// Commands run directly at the top level; "callName" names the inbound
// (caller) leg and "events.onHangup" handles the caller hanging up.
$svaml = [
'callName' => 'caller',
'commands' => [
['command' => 'answer'],
[
'command' => 'messages',
'messagesName' => 'greeting',
'messages' => [
['type' => 'SAY', 'say' => ['text' => 'Please hold while we connect your call.', 'voiceName' => 'Emma']],
],
],
// Add Party A to a named bridge
['command' => 'bridgeCall', 'bridgeName' => 'main-bridge'],
[
'command' => 'dial',
'callName' => 'callee',
// Party B sees the Sinch number
'from' => ['type' => 'PHONE', 'phone' => ['number' => $sinchNumber]],
// In production, look up the destination from your DB ($call['to'])
'to' => ['type' => 'PHONE', 'phone' => ['number' => $destinationNumber]],
'dialTimeoutDurationSeconds' => 30,
'events' => [
'onAnswer' => [['command' => 'bridgeCall', 'bridgeName' => 'main-bridge']],
'onHangup' => [['command' => 'hangup', 'callName' => 'caller']],
],
],
],
'events' => [
'onHangup' => [['command' => 'hangup', 'callName' => 'callee']],
],
];
$response->getBody()->write(json_encode($svaml));
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
}
error_log("Unhandled event: {$event}");
$response->getBody()->write(json_encode(['commands' => []]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
});
$app->run();Java (Spring Boot): save as Server.java in a Spring Boot project with spring-boot-starter-web, then mvn spring-boot:run
// Server.java: Sinch Number Masking webhook server (Spring Boot).
// Maven: spring-boot-starter-web
// Env: SINCH_NUMBER, DESTINATION_NUMBER, PORT (optional, defaults to 3000)
package com.sinch.tutorials.numbermasking;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@SpringBootApplication
@RestController
public class Server {
private final String sinchNumber;
private final String destinationNumber;
public Server() {
this.sinchNumber = requireEnv("SINCH_NUMBER");
this.destinationNumber = requireEnv("DESTINATION_NUMBER");
}
public static void main(String[] args) {
String port = System.getenv().getOrDefault("PORT", "3000");
System.setProperty("server.port", port);
SpringApplication.run(Server.class, args);
System.out.println("Number masking webhook server listening on port " + port);
}
/** POST /webhook: receives Sinch call events and responds with SVAML */
@PostMapping("/webhook")
public ResponseEntity<Map<String, Object>> webhook(@RequestBody Map<String, Object> body) {
String event = (String) body.getOrDefault("event", "");
Object call = body.getOrDefault("call", Map.of());
System.out.println("Received webhook event: " + event);
System.out.println("Call: " + call);
if ("call.incoming".equals(event)) {
// Commands run directly at the top level; "callName" names the inbound
// (caller) leg and "events.onHangup" handles the caller hanging up.
Map<String, Object> svaml = Map.of(
"callName", "caller",
"commands", List.of(
Map.of("command", "answer"),
Map.of(
"command", "messages",
"messagesName", "greeting",
"messages", List.of(
Map.of("type", "SAY", "say", Map.of(
"text", "Please hold while we connect your call.",
"voiceName", "Emma"))
)
),
// Add Party A to a named bridge
Map.of("command", "bridgeCall", "bridgeName", "main-bridge"),
Map.of(
"command", "dial",
"callName", "callee",
// Party B sees the Sinch number, not Party A's real number
"from", Map.of("type", "PHONE", "phone", Map.of("number", sinchNumber)),
// In production, look up from your DB based on the called Sinch number
"to", Map.of("type", "PHONE", "phone", Map.of("number", destinationNumber)),
"dialTimeoutDurationSeconds", 30,
"events", Map.of(
"onAnswer", List.of(Map.of("command", "bridgeCall", "bridgeName", "main-bridge")),
"onHangup", List.of(Map.of("command", "hangup", "callName", "caller"))
)
)
),
"events", Map.of(
"onHangup", List.of(Map.of("command", "hangup", "callName", "callee"))
)
);
return ResponseEntity.ok(svaml);
}
System.out.println("Unhandled event: " + event);
return ResponseEntity.ok(Map.of("commands", List.of()));
}
private static String requireEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
System.err.println("ERROR: " + name + " is not set.");
System.exit(1);
}
return value;
}
}ngrok http 3000Copy the https://<id>.ngrok-free.app URL ngrok prints and export it as CALLBACK_URL:
export CALLBACK_URL="https://<id>.ngrok-free.app"Your full webhook URL is that base plus /webhook.
Set the service's callBehavior.type to WEBHOOK via the API (or the Dashboard):
curl -X PATCH \
-u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/services/$SERVICE_ID" \
-H "Content-Type: application/json" \
-d '{
"callBehavior": {
"type": "WEBHOOK",
"webhook": {
"url": "'"$CALLBACK_URL"'/webhook",
"fallbackUrl": "'"$CALLBACK_URL"'/webhook"
}
}
}'fallbackUrl is optional but recommended. When the primary url fails, Sinch immediately re-sends that same event to fallbackUrl. After several consecutive primary failures, Sinch bypasses the primary entirely and sends all requests to the fallback until the primary recovers (retried once every 60 seconds). See the Webhooks Timeouts and failover section in the API reference for the authoritative algorithm.
You can also set this from the Sinch Dashboard (Voice → Services → your service → Call behavior). The dashboard is the quickest path for a one-off test.
Call SINCH_NUMBER from a phone (this is Party A).
- Party A hears "Please hold while we connect your call," then
DESTINATION_NUMBER(Party B) rings. - Party B's phone shows the Sinch number as the caller ID, not Party A's number.
- Party A and Party B are bridged; audio flows both ways.
- Party A only ever dialed the Sinch number, so Party A never sees Party B's number either.
- When either party hangs up, the other leg drops.
Your server terminal logs the incoming event and the SVAML it returned.
When Party A calls your Sinch number:
1. Sinch POSTs a call.incoming event to your webhook (CloudEvents ce-* headers + JSON body):
{
"event": "call.incoming",
"call": {
"callId": "01AN4Z07BY79KA1307SR9X4MV3",
"sessionId": "01AN4Z07BY79KA1307SR9X4MV2",
"from": { "type": "PHONE", "phone": { "number": "+1PARTY_A_NUMBER" } },
"to": { "type": "PHONE", "phone": { "number": "+1SINCH_NUMBER" } },
"direction": "INBOUND",
"originationType": "PHONE",
"callType": "PHONE",
"callResult": "INITIATED",
"startTime": "2025-06-01T10:00:00Z"
}
}call.from.phone.number is Party A. call.to.phone.number is the Sinch number Party A dialed. This is your routing key (see Number mapping in production).
2. Your server responds with the masking flow. Commands run directly at the top level; there is no wrapper command. Top-level callName names the inbound (caller) leg; top-level events.onHangup fires when Party A hangs up:
{
"callName": "caller",
"commands": [
{ "command": "answer" },
{
"command": "messages",
"messagesName": "greeting",
"messages": [
{ "type": "SAY",
"say": { "text": "Please hold while we connect your call.", "voiceName": "Emma" } }
]
},
{ "command": "bridgeCall", "bridgeName": "main-bridge" },
{
"command": "dial",
"callName": "callee",
"from": { "type": "PHONE", "phone": { "number": "+1SINCH_NUMBER" } },
"to": { "type": "PHONE", "phone": { "number": "+1PARTY_B_NUMBER" } },
"dialTimeoutDurationSeconds": 30,
"events": {
"onAnswer": [{ "command": "bridgeCall", "bridgeName": "main-bridge" }],
"onHangup": [{ "command": "hangup", "callName": "caller" }]
}
}
],
"events": {
"onHangup": [{ "command": "hangup", "callName": "callee" }]
}
}3. Party B receives a call from the Sinch number (the from on the dial leg), not Party A's real number.
4. When Party B answers, the onAnswer bridgeCall joins them to main-bridge; audio flows A ↔ B.
5. When either party hangs up, the matching onHangup terminates the other named leg.
Why this masks both parties. Party A only ever dialed the Sinch number, so A never learns B's number. Party B's caller ID is set to the Sinch number via
fromon the outbounddial, so B never learns A's number. The masking number (CLI) is therefore set per leg via thedialcommand'sfrom. There is no separate "CLI" field;fromis the presented caller ID.
Bridges are auto-created by name: the first leg into a bridgeName creates the bridge, subsequent legs join it. That's why Party A's bridgeCall runs immediately (creating main-bridge) and Party B's runs in its onAnswer (joining it).
Because the dial defines an inline events block, the platform runs those commands and no call.answered / call.hangup webhook fires for the Party B leg. Omit events to receive those webhooks instead; an explicit events: {} suppresses them without running anything.
The example servers always dial DESTINATION_NUMBER from the environment, which is fine for a single-pair demo. A real masking service maps the Sinch number that was called (call.to.phone.number) to a Party B.
Typical schema:
| Sinch DID | Party A | Party B | Expires |
|---|---|---|---|
+14045001001 | +15551110001 | +15552220001 | 2026-06-01 |
+14045001002 | +15551110002 | +15552220002 | 2026-06-15 |
In your call.incoming handler:
- Read
call.to.phone.number(the Sinch DID dialed). - Look up the row; resolve Party B.
- If no mapping exists or it has expired, reject the call by returning an empty
commandsarray:
{ "commands": [] }Robustness gap in the examples (flagged): the sample servers do not implement the lookup or the no-mapping rejection. They unconditionally dial
DESTINATION_NUMBERfor anycall.incoming. Add the lookup-and-reject logic above before going to production. (For non-call.incomingevents the servers already return{"commands": []}, which is the correct "take no action" response.)
You can also build a masked bridge your platform initiates, dialing both parties programmatically. This is the right shape for outreach where neither party started the call.
The script below POSTs to POST /v2/projects/$PROJECT_ID/calls a single dial for Party A; inside that leg's onAnswer it bridges Party A and issues a second dial for Party B. Both dials use the Sinch number as from and share bridgeName: masked-bridge, so each party sees only the Sinch number.
Save as test-call.sh and run with bash test-call.sh. It reads the same exported variables from Setup:
#!/bin/bash
# test-call.sh: trigger a programmatic masked bridge call via the API.
# Dials Party A and Party B and bridges them, masking each other's number.
# Env: PROJECT_ID, KEY_ID, KEY_SECRET, SINCH_NUMBER, DESTINATION_NUMBER
# PARTY_A_NUMBER / PARTY_B_NUMBER (optional overrides)
set -e
: "${PROJECT_ID:?ERROR: PROJECT_ID is not set.}"
: "${KEY_ID:?ERROR: KEY_ID is not set.}"
: "${KEY_SECRET:?ERROR: KEY_SECRET is not set.}"
: "${SINCH_NUMBER:?ERROR: SINCH_NUMBER is not set.}"
: "${DESTINATION_NUMBER:?ERROR: DESTINATION_NUMBER is not set.}"
# Default the two legs; override with two real phones for a proper test.
PARTY_A_NUMBER="${PARTY_A_NUMBER:-${DESTINATION_NUMBER}}"
PARTY_B_NUMBER="${PARTY_B_NUMBER:-${SINCH_NUMBER}}"
BASE_URL="https://voice.api.sinch.com/v2"
echo "Initiating masked bridge call between ${PARTY_A_NUMBER} and ${PARTY_B_NUMBER} ..."
echo "(Both parties will see ${SINCH_NUMBER} as the caller ID)"
BODY=$(printf '{
"commands": [
{
"command": "dial",
"callName": "party-a",
"from": { "type": "PHONE", "phone": { "number": "%s" } },
"to": { "type": "PHONE", "phone": { "number": "%s" } },
"dialTimeoutDurationSeconds": 30,
"events": {
"onAnswer": [
{
"command": "messages",
"messagesName": "greeting",
"messages": [
{ "type": "SAY",
"say": { "text": "Please hold while we connect the other party.", "voiceName": "Emma" } }
]
},
{ "command": "bridgeCall", "bridgeName": "masked-bridge" },
{
"command": "dial",
"callName": "party-b",
"from": { "type": "PHONE", "phone": { "number": "%s" } },
"to": { "type": "PHONE", "phone": { "number": "%s" } },
"dialTimeoutDurationSeconds": 30,
"events": {
"onAnswer": [{ "command": "bridgeCall", "bridgeName": "masked-bridge" }],
"onHangup": [{ "command": "hangup", "callName": "party-a" }]
}
}
],
"onHangup": [{ "command": "hangup", "callName": "party-b" }]
}
}
]
}' "${SINCH_NUMBER}" "${PARTY_A_NUMBER}" "${SINCH_NUMBER}" "${PARTY_B_NUMBER}")
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST \
-u "${KEY_ID}:${KEY_SECRET}" \
"${BASE_URL}/projects/${PROJECT_ID}/calls" \
-H "Content-Type: application/json" \
-d "${BODY}")
HTTP_BODY=$(echo "${RESPONSE}" | head -n -1)
HTTP_CODE=$(echo "${RESPONSE}" | tail -n 1)
if [ "${HTTP_CODE}" -eq 201 ]; then
echo "Bridge call created successfully (HTTP ${HTTP_CODE}):"
echo "${HTTP_BODY}" | (command -v jq > /dev/null && jq '.' || cat)
else
echo "ERROR: API returned HTTP ${HTTP_CODE}:" >&2
echo "${HTTP_BODY}" >&2
exit 1
fiNote on the demo defaults: the script defaults
PARTY_A_NUMBERtoDESTINATION_NUMBERandPARTY_B_NUMBERtoSINCH_NUMBER, so out of the box one leg dials your Sinch number itself. For a real two-party test, set both explicitly to phones you can answer:PARTY_A_NUMBER=+1... PARTY_B_NUMBER=+1... bash test-call.sh
Browser JS equivalent (demonstration only; see the CORS and credentials caveats)
Browsers cannot read exported environment variables and calling the Sinch API directly from a browser hits CORS. Replace the placeholders with values injected by your backend, and in production proxy these calls through your server so API keys never reach the client.
// Demonstration only. Do not ship API keys to the browser.
(async function sinchMaskedBridgeCall() {
const projectId = "YOUR_PROJECT_ID";
const keyId = "YOUR_KEY_ID";
const keySecret = "YOUR_KEY_SECRET";
const sinchNumber = "+1XXXXXXXXXX";
const partyANumber = "+1AAAAAAAAAA"; // First person to call
const partyBNumber = "+1BBBBBBBBBB"; // Second person to connect
const baseUrl = "https://voice.api.sinch.com/v2";
const authHeader = "Basic " + btoa(`${keyId}:${keySecret}`);
const payload = {
commands: [
{
command: "dial",
callName: "party-a",
from: { type: "PHONE", phone: { number: sinchNumber } },
to: { type: "PHONE", phone: { number: partyANumber } },
dialTimeoutDurationSeconds: 30,
events: {
onAnswer: [
{
command: "messages",
messagesName: "greeting",
messages: [
{ type: "SAY", say: { text: "Please hold while we connect the other party.", voiceName: "Emma" } }
]
},
{ command: "bridgeCall", bridgeName: "masked-bridge" },
{
command: "dial",
callName: "party-b",
from: { type: "PHONE", phone: { number: sinchNumber } },
to: { type: "PHONE", phone: { number: partyBNumber } },
dialTimeoutDurationSeconds: 30,
events: {
onAnswer: [{ command: "bridgeCall", bridgeName: "masked-bridge" }],
onHangup: [{ command: "hangup", callName: "party-a" }]
}
}
],
onHangup: [{ command: "hangup", callName: "party-b" }]
}
}
]
};
const response = await fetch(`${baseUrl}/projects/${projectId}/calls`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authHeader },
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.status === 201) {
console.log("Bridge call created successfully:", data);
} else {
console.error(`ERROR ${response.status}:`, data);
}
})();The POST /calls body is a callRequest: a top-level commands array (the same SVAML primitives), optionally with parameters / batchOptions for batches. A 201 returns { projectId, serviceId, sessionId }.
| Concern | What to do |
|---|---|
| Number mapping | Look up Party B from call.to.phone.number. The example hard-codes DESTINATION_NUMBER; replace it. |
| Mapping expiry | Disposable masks should expire. Reject calls to expired DIDs by returning an empty commands array. |
| Two-way masking | Each direction needs a Sinch DID. Allocate one per pair; rotate when the relationship ends. |
| No-answer fallback | Add onTimeout / onBusy / onReject handlers on the Party B dial to leave a voicemail or fall through to Call Hunting. |
| Recording | Insert startRecording after the bridgeCall if compliance requires an audit log. See Recording & Transcription. |
| Webhook latency | Sinch enforces a per-webhook response timeout (treat ~5 s as the budget). Cache your mapping in memory and respond fast. |
| Header / signature validation | Verify the CloudEvents headers and the request signature before acting. See Handle Inbound PSTN Calls. |
| Idempotent webhook handlers | A failed primary delivery is re-sent to fallbackUrl, so the same event can arrive more than once. Deduplicate on the ce-id and ce-source header pair. |
| Carrier caller-ID rules | If you ever pass Party A's real caller ID through, confirm your carrier accepts it. Most don't. |
- The
call.incomingresponse (webhookResponse) is{ "commands": [...], "callName"?, "events"?: { "onHangup": [...] } }. Commands run directly; no wrapper command.callNameandeventsare honored only in responses tocall.incoming. bridgeCallrequiresbridgeName; the bridge is auto-created on first use and joined thereafter.dialrequiresto;from/toare typed endpoints (type: PHONEwithphone.numberin E.164). The presented caller ID is the leg'sfrom. Lifecycle is handled viaevents(onAnswer,onBusy,onReject,onTimeout,onHangup,onFailure).hangupaccepts acallNameto drop a specific named leg while keeping the session and other legs alive.POST /v2/projects/{projectId}/callstakes acallRequest(top-levelcommands) and returns{ projectId, serviceId, sessionId }on201.PATCH /v2/projects/{projectId}/services/{serviceId}(updateService) setscallBehavior(NONE|WEBHOOK|STATIC).POST /v2/projects/{projectId}/svaml/validatevalidates a full SVAML payload ({ "svaml": { "commands": [...], "callName"?: ..., "events"?: {...} }, "validationType"?: "NORMAL" | "STRICT" }) and returns{ "isValid", "errors" }. A200means validation ran, not that the payload is valid; always readisValid.



