Skip to content

Call Forwarding

Overview

Voice API v2 has no dedicated "forward" or "transfer" command. Instead, you build forwarding from three primitives:

  • dial creates a new outbound call leg within the same session.
  • bridgeCall joins two legs together so both sides hear each other. The bridge is created when the first leg references it, and the second leg joins it.
  • hangup drops a specific named leg.

Every forwarding pattern, whether it's unconditional, no-answer fallback, time-of-day routing, or a mid-call transfer, is just a combination of these three commands with different event triggers.

The basic pattern: forward on no answer

This is the most common scenario. A caller dials in, you try a primary number, and if nobody picks up within a timeout, you try a fallback number instead. Both the primary and the fallback use the same bridge name, so whichever one answers ends up connected to the caller.

Here's what happens step by step:

  1. The caller leg is answered and hears a hold prompt.
  2. The caller joins a named bridge (creating it).
  3. The platform dials the primary number.
  4. If the primary answers, the hold prompt stops and the primary joins the same bridge. Two-way audio begins. Done.
  5. If the primary doesn't answer within the timeout, the platform dials the fallback number.
  6. If the fallback answers, the hold prompt stops and the fallback joins the bridge with the caller.
  7. If the fallback also times out, the caller is hung up.

How the SVAML expresses this

The structure is nested: the fallback dial lives inside the primary's onTimeout event. That nesting is the forwarding logic.

caller dial
  └─ onAnswer
       ├─ play hold prompt
       ├─ bridgeCall "fwd-bridge"      ← caller joins the bridge
       └─ primary dial
            ├─ onAnswer  → stop hold, bridgeCall "fwd-bridge"
            └─ onTimeout → fallback dial
                              ├─ onAnswer  → stop hold, bridgeCall "fwd-bridge"
                              └─ onTimeout → hang up caller

Both the caller and the destination explicitly join the same bridgeName. The caller joins first (creating the bridge); whichever destination answers joins the same one, and two-way audio begins.

Minimal working example (outbound test call)

This places an outbound call to your own phone, then tries a primary number (let it ring out), and falls through to a fallback. No webhook server needed.

Set your environment variables first:

export PROJECT_ID="your-project-id"
export KEY_ID="your-key-id"
export KEY_SECRET="your-key-secret"
export SINCH_NUMBER="+15551234567"       # Your Sinch virtual number
export CALLER_NUMBER="+15550009999"      # Your mobile (answer this one)
export PRIMARY_NUMBER="+15557770001"     # Let this ring out
export FALLBACK_NUMBER="+15557770002"    # Should ring after primary times out

Python

import os, uuid, requests

body = {
    "commands": [{
        "command": "dial",
        "callName": "caller",
        "from": {"type": "PHONE", "phone": {"number": os.environ["SINCH_NUMBER"]}},
        "to":   {"type": "PHONE", "phone": {"number": os.environ["CALLER_NUMBER"]}},
        "dialTimeoutDurationSeconds": 30,
        "events": {
            "onAnswer": [
                # Step 1: play hold music while we try the primary
                {"command": "messages", "messagesName": "hold",
                 "messages": [{"type": "SAY",
                   "say": {"text": "Connecting your call.", "voiceName": "Emma"}}]},

                # Step 2: caller joins the bridge (created on first reference)
                {"command": "bridgeCall", "bridgeName": "fwd-bridge"},

                # Step 3: dial the primary number
                {"command": "dial",
                 "callName": "primary",
                 "from": {"type": "PHONE", "phone": {"number": os.environ["SINCH_NUMBER"]}},
                 "to":   {"type": "PHONE", "phone": {"number": os.environ["PRIMARY_NUMBER"]}},
                 "dialTimeoutDurationSeconds": 20,
                 "events": {
                     # Primary answered: stop hold, bridge them
                     "onAnswer": [
                         {"command": "stopMessages", "messagesName": "hold"},
                         {"command": "bridgeCall", "bridgeName": "fwd-bridge"},
                     ],
                     # Primary didn't answer: try the fallback
                     "onTimeout": [{
                         "command": "dial",
                         "callName": "fallback",
                         "from": {"type": "PHONE", "phone": {"number": os.environ["SINCH_NUMBER"]}},
                         "to":   {"type": "PHONE", "phone": {"number": os.environ["FALLBACK_NUMBER"]}},
                         "dialTimeoutDurationSeconds": 20,
                         "events": {
                             "onAnswer": [
                                 {"command": "stopMessages", "messagesName": "hold"},
                                 {"command": "bridgeCall", "bridgeName": "fwd-bridge"},
                             ],
                             "onTimeout": [
                                 {"command": "hangup", "callName": "caller"},
                             ],
                         },
                     }],
                 }},
            ],
            "onHangup": [
                {"command": "hangup", "callName": "primary"},
                {"command": "hangup", "callName": "fallback"},
            ],
        },
    }]
}

resp = requests.post(
    f"https://voice.api.sinch.com/v2/projects/{os.environ['PROJECT_ID']}/calls",
    auth=(os.environ["KEY_ID"], os.environ["KEY_SECRET"]),
    headers={
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json=body,
)
print(resp.status_code, resp.json())

Node.js

import { randomUUID } from "crypto";

const { PROJECT_ID, KEY_ID, KEY_SECRET,
        SINCH_NUMBER, CALLER_NUMBER, PRIMARY_NUMBER, FALLBACK_NUMBER } = process.env;

const body = {
  commands: [{
    command: "dial",
    callName: "caller",
    from: { type: "PHONE", phone: { number: SINCH_NUMBER } },
    to:   { type: "PHONE", phone: { number: CALLER_NUMBER } },
    dialTimeoutDurationSeconds: 30,
    events: {
      onAnswer: [
        // Step 1: play hold music while we try the primary
        { command: "messages", messagesName: "hold",
          messages: [{ type: "SAY", say: { text: "Connecting your call.", voiceName: "Emma" } }] },

        // Step 2: caller joins the bridge (created on first reference)
        { command: "bridgeCall", bridgeName: "fwd-bridge" },

        // Step 3: dial the primary number
        { command: "dial",
          callName: "primary",
          from: { type: "PHONE", phone: { number: SINCH_NUMBER } },
          to:   { type: "PHONE", phone: { number: PRIMARY_NUMBER } },
          dialTimeoutDurationSeconds: 20,
          events: {
            // Primary answered: stop hold, bridge them
            onAnswer: [
              { command: "stopMessages", messagesName: "hold" },
              { command: "bridgeCall", bridgeName: "fwd-bridge" },
            ],
            // Primary didn't answer: try the fallback
            onTimeout: [{
              command: "dial",
              callName: "fallback",
              from: { type: "PHONE", phone: { number: SINCH_NUMBER } },
              to:   { type: "PHONE", phone: { number: FALLBACK_NUMBER } },
              dialTimeoutDurationSeconds: 20,
              events: {
                onAnswer: [
                  { command: "stopMessages", messagesName: "hold" },
                  { command: "bridgeCall", bridgeName: "fwd-bridge" },
                ],
                onTimeout: [{ command: "hangup", callName: "caller" }],
              },
            }],
          },
        },
      ],
      onHangup: [
        { command: "hangup", callName: "primary" },
        { command: "hangup", callName: "fallback" },
      ],
    },
  }],
};

const auth = "Basic " + Buffer.from(`${KEY_ID}:${KEY_SECRET}`).toString("base64");

const resp = await fetch(
  `https://voice.api.sinch.com/v2/projects/${PROJECT_ID}/calls`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: auth, "Idempotency-Key": randomUUID() },
    body: JSON.stringify(body),
  }
);
console.log(resp.status, await resp.json());

What to expect when you run it

  1. Your phone (CALLER_NUMBER) rings. Answer it and you'll hear "Connecting your call."
  2. PRIMARY_NUMBER rings for ~20 seconds with no answer.
  3. Within about a second, FALLBACK_NUMBER rings.
  4. Answer the fallback and you get two-way audio with the caller leg.

Key concepts to remember

Every leg needs a callName. This is how you reference a specific leg later, for example when hanging up just the agent without dropping the caller.

Both sides must join the bridge. The caller joins the bridge explicitly via bridgeCall (which creates it on first reference). Whichever destination answers also calls bridgeCall with the same bridgeName, joining the existing bridge. Using the same name across primary and fallback is what makes the handoff seamless.

Events drive the forwarding logic. The events block on a dial command gives you hooks for what happens next. The ones relevant to forwarding:

EventWhen it firesTypical action
onAnswerDestination picked upBridge the legs together
onTimeoutRang for the full timeout, no answerDial the next number
onBusyDestination returned busyDial the next number
onRejectDestination actively declinedDial the next number
onFailureCall setup failed (carrier error, unreachable)Dial the next number
onHangupA leg disconnectedClean up the session

Always send an Idempotency-Key on POST /calls so a retried request doesn't accidentally place a duplicate call.

Adapting this pattern

To handle busy, reject, or carrier failure in addition to no-answer, add the same fallback dial block under onBusy, onReject, and onFailure on the primary leg. SVAML doesn't support references, so each event handler needs its own copy of the fallback commands.

To make the routing dynamic (time-of-day, per-caller lookup, agent availability), switch from a static outbound call to an inbound webhook. Your server receives a call.incoming event and returns the same SVAML structure, but you choose the destination at runtime based on whatever your backend knows.

We'd love to hear from you!
Rate this content: