# 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](#quick-start-minimal-end-to-end) 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](#step-0-smoke-test-the-svaml-no-phone-needed).


For the inbound webhook contract itself (CloudEvents headers, the response shape, signature verification), see [Handle Inbound PSTN Calls](/docs/voice-2.0/tutorials/inbound-pstn). This tutorial focuses on the masking pattern layered on top of it.

## 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:

```bash
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](https://dashboard.sinch.com) → 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](https://ngrok.com)** (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:

```bash
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](#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`:

```js
// 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}`);
});
```

details
summary
strong
Python (Flask)
: save as 
code
server.py
, then 
code
pip install flask
and 
code
python server.py
```python
# 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)
```

details
summary
strong
PHP (Slim 4)
: save as 
code
server.php
, 
code
composer require slim/slim slim/psr7 nyholm/psr7 php-di/php-di
, then 
code
php -S 0.0.0.0:3000 server.php
```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();
```

details
summary
strong
Java (Spring Boot)
: save as 
code
Server.java
in a Spring Boot project with 
code
spring-boot-starter-web
, then 
code
mvn spring-boot:run
```java
// 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

```bash
ngrok http 3000
```

Copy the `https://<id>.ngrok-free.app` URL ngrok prints and export it as `CALLBACK_URL`:

```bash
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):

```bash
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](https://dashboard.sinch.com/voice/services) (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):

```json
{
  "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](#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:

```json
{
  "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 DID | Party A | Party B | Expires |
|  --- | --- | --- | --- |
| `+14045001001` | `+15551110001` | `+15552220001` | 2026-06-01 |
| `+14045001002` | `+15551110002` | `+15552220002` | 2026-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:


```json
{ "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](#setup):

```bash
#!/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:

```bash
PARTY_A_NUMBER=+1... PARTY_B_NUMBER=+1... bash test-call.sh
```


details
summary
strong
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.

```js
// 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

| 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](/docs/voice-2.0/tutorials/call-hunting). |
| **Recording** | Insert `startRecording` after the `bridgeCall` if compliance requires an audit log. See [ Recording & Transcription](/docs/voice-2.0/tutorials/recording-and-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](/docs/voice-2.0/tutorials/inbound-pstn). |
| **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. |


## 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`.