Skip to content

Mask a Phone Call (Number Masking)

Overview

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:

  1. Answer an inbound call from Party A on your Sinch number (delivered as a call.incoming webhook).
  2. dial Party B with the Sinch number as from, so Party B sees the Sinch number, not Party A.
  3. bridgeCall both legs into a shared bridgeName so 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.

Build this with AI or the CLI

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.

Real-life examples

  • 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.

Setup

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.

VariableWhat it isWhere to get it
PROJECT_IDYour Voice project IDSinch Dashboard → Voice → your project
KEY_ID / KEY_SECRETAPI credentials for HTTP Basic authDashboard → Access keys
SERVICE_IDThe service that owns your Sinch numberDashboard → Voice → Services (or GET /v2/projects/{projectId}/services). This is the service whose callBehavior you switch to WEBHOOK.
SINCH_NUMBERYour 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_NUMBERParty B, the number the inbound caller gets bridged to, E.164Any phone you can answer
CALLBACK_URLYour public webhook base URL (the ngrok URL)Generated by ngrok in the Quick start
PORTLocal 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.


Step 0: Smoke-test the SVAML (no phone needed)

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.


Quick start: minimal end-to-end

1. Start the webhook server

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;
    }
}

2. Expose it with ngrok

ngrok http 3000

Copy 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.

3. Point the service webhook at your server

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.

4. Dial your Sinch number

Call SINCH_NUMBER from a phone (this is Party A).

What success looks like

  • 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.


Reference: the inbound webhook flow

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 from on the outbound dial, so B never learns A's number. The masking number (CLI) is therefore set per leg via the dial command's from. There is no separate "CLI" field; from is the presented caller ID.

How bridgeCall works

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).

Inline events suppress the per-leg webhook

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.


Number mapping in production

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 DIDParty AParty BExpires
+14045001001+15551110001+155522200012026-06-01
+14045001002+15551110002+155522200022026-06-15

In your call.incoming handler:

  1. Read call.to.phone.number (the Sinch DID dialed).
  2. Look up the row; resolve Party B.
  3. If no mapping exists or it has expired, reject the call by returning an empty commands array:
{ "commands": [] }

Robustness gap in the examples (flagged): the sample servers do not implement the lookup or the no-mapping rejection. They unconditionally dial DESTINATION_NUMBER for any call.incoming. Add the lookup-and-reject logic above before going to production. (For non-call.incoming events the servers already return {"commands": []}, which is the correct "take no action" response.)


Trigger a fully outbound masked bridge

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
fi

Note on the demo defaults: the script defaults PARTY_A_NUMBER to DESTINATION_NUMBER and PARTY_B_NUMBER to SINCH_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 }.

Production-readiness checklist

ConcernWhat to do
Number mappingLook up Party B from call.to.phone.number. The example hard-codes DESTINATION_NUMBER; replace it.
Mapping expiryDisposable masks should expire. Reject calls to expired DIDs by returning an empty commands array.
Two-way maskingEach direction needs a Sinch DID. Allocate one per pair; rotate when the relationship ends.
No-answer fallbackAdd onTimeout / onBusy / onReject handlers on the Party B dial to leave a voicemail or fall through to Call Hunting.
RecordingInsert startRecording after the bridgeCall if compliance requires an audit log. See Recording & Transcription.
Webhook latencySinch enforces a per-webhook response timeout (treat ~5 s as the budget). Cache your mapping in memory and respond fast.
Header / signature validationVerify the CloudEvents headers and the request signature before acting. See Handle Inbound PSTN Calls.
Idempotent webhook handlersA 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 rulesIf you ever pass Party A's real caller ID through, confirm your carrier accepts it. Most don't.

What the OpenAPI spec says: at a glance

  • The call.incoming response (webhookResponse) is { "commands": [...], "callName"?, "events"?: { "onHangup": [...] } }. Commands run directly; no wrapper command. callName and events are honored only in responses to call.incoming.
  • bridgeCall requires bridgeName; the bridge is auto-created on first use and joined thereafter.
  • dial requires to; from / to are typed endpoints (type: PHONE with phone.number in E.164). The presented caller ID is the leg's from. Lifecycle is handled via events (onAnswer, onBusy, onReject, onTimeout, onHangup, onFailure).
  • hangup accepts a callName to drop a specific named leg while keeping the session and other legs alive.
  • POST /v2/projects/{projectId}/calls takes a callRequest (top-level commands) and returns { projectId, serviceId, sessionId } on 201.
  • PATCH /v2/projects/{projectId}/services/{serviceId} (updateService) sets callBehavior (NONE | WEBHOOK | STATIC).
  • POST /v2/projects/{projectId}/svaml/validate validates a full SVAML payload ({ "svaml": { "commands": [...], "callName"?: ..., "events"?: {...} }, "validationType"?: "NORMAL" | "STRICT" }) and returns { "isValid", "errors" }. A 200 means validation ran, not that the payload is valid; always read isValid.
We'd love to hear from you!
Rate this content: