The Sinch Voice API lets you programmatically dial any phone number from your project. You issue a single POST /v2/projects/{projectId}/calls request carrying SVAML (Sinch Voice Application Markup Language) commands that describe what happens during the call. For example: dial a phone number and, once the call is answered, play a recorded audio file, synthesize a Text-to-Speech message, then hang up.
The simplest outbound call is a single dial command with an onAnswer event that plays a messages block. No webhook server is needed because the full call flow is included inline in the request.
In under five minutes you'll place a real outbound call that plays an audio clip and a Text-to-Speech message to a phone you control, then hangs up. You'll see an HTTP 201 response carrying a sessionId you can later use to track the call.
- Appointment reminders: "Hello, this is a reminder that your dentist appointment is tomorrow at 2 PM."
- One-time passcodes (OTP): "Your verification code is 4 8 3 7. Do not share this code with anyone."
- Delivery notifications: "Your package has arrived at the locker. Your pickup code is 1 2 3 4."
- Outage alerts: "We are experiencing a service disruption in your area."
Before running anything, gather these values. You'll set them as environment variables with export (covered in the next section).
| Variable | Where to find it |
|---|---|
PROJECT_ID | Your Sinch project UUID, shown in the Sinch dashboard. |
KEY_ID | API key ID. Create a key pair under Settings -> Access Keys in the dashboard. Used as the Basic Auth username. |
KEY_SECRET | API key secret, shown once when you create the key pair. Used as the Basic Auth password. |
SINCH_NUMBER | A Sinch virtual phone number assigned to your project, in E.164 format (e.g. +14045001000). Provision one under Numbers in the dashboard. Used as the caller ID. |
DESTINATION_NUMBER | The number you want to call, in E.164 format (e.g. +14155551234). For your first test, use a phone you can answer. |
Authentication is HTTP Basic using KEY_ID:KEY_SECRET. The API base URL is https://voice.api.sinch.com (regional hosts such as https://us1.voice.api.sinch.com, eu1, br1, sg1, and au1 are also available).
Tooling per language:
- Shell:
curl(and optionallyjqfor pretty output). - Python:
pip install requests. - Node.js: Node 18+ (built-in
fetch); no dependencies. Run as an ES module ("type": "module"inpackage.json, or use a.mjsextension). On Node < 18,npm install node-fetch. - PHP: PHP 8+ with the
curlextension. - Java: JDK 11+ (uses
java.net.http.HttpClient); no external dependencies.
Each example reads its configuration from environment variables. Set them once in your shell with export. Because export marks the variables for the environment, any program you launch from that same shell (Python, Node, PHP, Java, or curl) inherits them automatically.
export PROJECT_ID="your-project-uuid"
export KEY_ID="your-key-id"
export KEY_SECRET="your-key-secret"
export SINCH_NUMBER="+14045001000"
export DESTINATION_NUMBER="+14155551234"These last only for the current shell session. Open a new terminal and you'll need to export them again. To make them persistent, add the lines to your shell profile (~/.bashrc, ~/.zshrc, or similar). On Windows PowerShell, use $env:PROJECT_ID = "..." instead.
Verify they're set before continuing:
echo "$PROJECT_ID $SINCH_NUMBER $DESTINATION_NUMBER"Run any of the examples below from the same shell where you exported the variables.
Paste this directly into your terminal, or save it as callout.sh and run bash callout.sh.
#!/bin/bash
set -e
: "${PROJECT_ID:?ERROR: PROJECT_ID is not set. Run: export PROJECT_ID=...}"
: "${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.}"
echo "Placing callout from ${SINCH_NUMBER} to ${DESTINATION_NUMBER} ..."
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST \
-u "${KEY_ID}:${KEY_SECRET}" \
"https://voice.api.sinch.com/v2/projects/${PROJECT_ID}/calls" \
-H "Content-Type: application/json" \
-d @- <<EOF
{
"commands": [
{
"command": "dial",
"callName": "audio-notification",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${DESTINATION_NUMBER}" } },
"dialTimeoutDurationSeconds": 30,
"maxCallDurationSeconds": 300,
"events": {
"onAnswer": [
{
"command": "messages",
"messagesName": "notification",
"messages": [
{ "type": "PLAY", "play": { "url": "https://samplelib.com/mp3/sample-12s.mp3" } },
{ "type": "SAY", "say": {
"text": "Hello! This is a test notification from Sinch. Your verification code is 4 8 3 7.",
"voiceName": "Emma"
} }
],
"events": { "onFinish": [ { "command": "hangup" } ] }
}
]
}
}
]
}
EOF
)
HTTP_BODY=$(echo "${RESPONSE}" | head -n -1)
HTTP_CODE=$(echo "${RESPONSE}" | tail -n 1)
if [ "${HTTP_CODE}" -eq 201 ]; then
echo "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
fiSave as callout.py, then run python callout.py.
# Requirements: pip install requests
import json
import os
import sys
import requests
def env(name):
value = os.environ.get(name)
if not value:
print(f"ERROR: {name} is not set. Run: export {name}=...", file=sys.stderr)
sys.exit(1)
return value
project_id = env("PROJECT_ID")
key_id = env("KEY_ID")
key_secret = env("KEY_SECRET")
sinch_number = env("SINCH_NUMBER")
destination_number = env("DESTINATION_NUMBER")
url = f"https://voice.api.sinch.com/v2/projects/{project_id}/calls"
# SVAML payload: dial -> on answer play audio file then TTS -> hangup
payload = {
"commands": [
{
"command": "dial",
"callName": "audio-notification",
"from": {"type": "PHONE", "phone": {"number": sinch_number}},
"to": {"type": "PHONE", "phone": {"number": destination_number}},
"dialTimeoutDurationSeconds": 30,
"maxCallDurationSeconds": 300,
"events": {
"onAnswer": [
{
"command": "messages",
"messagesName": "notification",
"messages": [
{"type": "PLAY", "play": {"url": "https://samplelib.com/mp3/sample-12s.mp3"}},
{"type": "SAY", "say": {
"text": "Hello! This is a test notification from Sinch. Your verification code is 4 8 3 7.",
"voiceName": "Emma",
}},
],
"events": {"onFinish": [{"command": "hangup"}]},
}
]
},
}
]
}
print(f"Placing callout from {sinch_number} to {destination_number} ...")
try:
response = requests.post(url, json=payload, auth=(key_id, key_secret))
data = response.json()
if response.status_code == 201:
print("Call created successfully:")
print(json.dumps(data, indent=2))
else:
print(f"ERROR {response.status_code}:", file=sys.stderr)
print(json.dumps(data, indent=2), file=sys.stderr)
sys.exit(1)
except requests.RequestException as e:
print(f"Request failed: {e}", file=sys.stderr)
sys.exit(1)Save as callout.mjs (the .mjs extension enables ES module syntax), then run node callout.mjs. Uses the built-in fetch from Node 18+; no dependencies.
function env(name) {
const value = process.env[name];
if (!value) {
console.error(`ERROR: ${name} is not set. Run: export ${name}=...`);
process.exit(1);
}
return value;
}
const projectId = env("PROJECT_ID");
const keyId = env("KEY_ID");
const keySecret = env("KEY_SECRET");
const sinchNumber = env("SINCH_NUMBER");
const destinationNumber = env("DESTINATION_NUMBER");
const url = `https://voice.api.sinch.com/v2/projects/${projectId}/calls`;
// Basic Auth: base64("keyId:keySecret")
const authHeader = "Basic " + Buffer.from(`${keyId}:${keySecret}`).toString("base64");
// SVAML payload: dial -> on answer play audio file then TTS -> hangup
const payload = {
commands: [
{
command: "dial",
callName: "audio-notification",
from: { type: "PHONE", phone: { number: sinchNumber } },
to: { type: "PHONE", phone: { number: destinationNumber } },
dialTimeoutDurationSeconds: 30,
maxCallDurationSeconds: 300,
events: {
onAnswer: [
{
command: "messages",
messagesName: "notification",
messages: [
{ type: "PLAY", play: { url: "https://samplelib.com/mp3/sample-12s.mp3" } },
{ type: "SAY", say: {
text: "Hello! This is a test notification from Sinch. Your verification code is 4 8 3 7.",
voiceName: "Emma",
} },
],
events: { onFinish: [ { command: "hangup" } ] },
},
],
},
},
],
};
console.log(`Placing callout from ${sinchNumber} to ${destinationNumber} ...`);
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authHeader },
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.status === 201) {
console.log("Call created successfully:", JSON.stringify(data, null, 2));
} else {
console.error(`ERROR ${response.status}:`, JSON.stringify(data, null, 2));
process.exit(1);
}Save as callout.php, then run php callout.php. Requires PHP 8+ with the curl extension.
<?php
// Requirements: PHP 8+ with the curl extension enabled.
function env(string $name): string {
$value = getenv($name);
if ($value === false || $value === '') {
fwrite(STDERR, "ERROR: {$name} is not set. Run: export {$name}=...\n");
exit(1);
}
return $value;
}
$projectId = env('PROJECT_ID');
$keyId = env('KEY_ID');
$keySecret = env('KEY_SECRET');
$sinchNumber = env('SINCH_NUMBER');
$destinationNumber = env('DESTINATION_NUMBER');
$url = "https://voice.api.sinch.com/v2/projects/{$projectId}/calls";
// SVAML payload: dial -> on answer play audio file then TTS -> hangup
$payload = [
'commands' => [
[
'command' => 'dial',
'callName' => 'audio-notification',
'from' => ['type' => 'PHONE', 'phone' => ['number' => $sinchNumber]],
'to' => ['type' => 'PHONE', 'phone' => ['number' => $destinationNumber]],
'dialTimeoutDurationSeconds' => 30,
'maxCallDurationSeconds' => 300,
'events' => [
'onAnswer' => [
[
'command' => 'messages',
'messagesName' => 'notification',
'messages' => [
['type' => 'PLAY', 'play' => ['url' => 'https://samplelib.com/mp3/sample-12s.mp3']],
['type' => 'SAY', 'say' => [
'text' => 'Hello! This is a test notification from Sinch. Your verification code is 4 8 3 7.',
'voiceName' => 'Emma',
]],
],
'events' => ['onFinish' => [['command' => 'hangup']]],
],
],
],
],
],
];
echo "Placing callout from {$sinchNumber} to {$destinationNumber} ...\n";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_USERPWD => "{$keyId}:{$keySecret}",
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
]);
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
fwrite(STDERR, "curl error: {$curlError}\n");
exit(1);
}
$data = json_decode($responseBody, true);
if ($httpCode === 201) {
echo "Call created successfully:\n";
echo json_encode($data, JSON_PRETTY_PRINT) . "\n";
} else {
fwrite(STDERR, "ERROR {$httpCode}:\n");
fwrite(STDERR, json_encode($data, JSON_PRETTY_PRINT) . "\n");
exit(1);
}Save as Callout.java, then compile and run:
javac -d out Callout.java && java -cp out com.sinch.tutorials.ttscallout.CalloutRequires JDK 11+ (uses java.net.http.HttpClient); no external dependencies.
package com.sinch.tutorials.ttscallout;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
public class Callout {
public static void main(String[] args) throws Exception {
String projectId = requireEnv("PROJECT_ID");
String keyId = requireEnv("KEY_ID");
String keySecret = requireEnv("KEY_SECRET");
String sinchNumber = requireEnv("SINCH_NUMBER");
String destinationNumber = requireEnv("DESTINATION_NUMBER");
String url = "https://voice.api.sinch.com/v2/projects/" + projectId + "/calls";
// Basic Auth header: base64("keyId:keySecret")
String credentials = Base64.getEncoder()
.encodeToString((keyId + ":" + keySecret).getBytes());
// SVAML payload: dial -> on answer play audio file then TTS -> hangup
String body = String.format("""
{
"commands": [
{
"command": "dial",
"callName": "audio-notification",
"from": { "type": "PHONE", "phone": { "number": "%s" } },
"to": { "type": "PHONE", "phone": { "number": "%s" } },
"dialTimeoutDurationSeconds": 30,
"maxCallDurationSeconds": 300,
"events": {
"onAnswer": [
{
"command": "messages",
"messagesName": "notification",
"messages": [
{ "type": "PLAY", "play": { "url": "https://samplelib.com/mp3/sample-12s.mp3" } },
{ "type": "SAY", "say": {
"text": "Hello! This is a test notification from Sinch. Your verification code is 4 8 3 7.",
"voiceName": "Emma"
} }
],
"events": { "onFinish": [ { "command": "hangup" } ] }
}
]
}
}
]
}
""", sinchNumber, destinationNumber);
System.out.println("Placing callout from " + sinchNumber + " to " + destinationNumber + " ...");
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Authorization", "Basic " + credentials)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 201) {
System.out.println("Call created successfully:");
System.out.println(response.body());
} else {
System.err.println("ERROR " + response.statusCode() + ":");
System.err.println(response.body());
System.exit(1);
}
}
// Reads a variable from the environment (set it with `export NAME=...`).
private static String requireEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
System.err.println("ERROR: " + name + " is not set. Run: export " + name + "=...");
System.exit(1);
}
return value;
}
}Browser note: a browser
fetchversion is also possible, but browsers have no environment variables, soexportdoes not apply. You would inject the values at build time and, because calling the Sinch API directly from a browser hits CORS restrictions, route the request through your own backend proxy in production.
The destination phone rings. When answered, it plays the sample audio clip followed by the TTS message "Hello! This is a test notification from Sinch. Your verification code is 4 8 3 7." then hangs up.
On the console you'll see an HTTP 201 response:
{
"projectId": "5c5bf2b1-35ae-4825-ab89-457e07bb60e6",
"serviceId": "6e124178-c29d-46a5-943c-5c2ae544aade",
"sessionId": "01BX5ZZKBKACTAV9WEVGEMMVRB"
}If you get a non-201 status, check the Troubleshooting section below.
The request body sent to POST /v2/projects/{projectId}/calls is:
{
"commands": [
{
"command": "dial",
"callName": "audio-notification",
"from": {
"type": "PHONE",
"phone": { "number": "+1XXXXXXXXXX" }
},
"to": {
"type": "PHONE",
"phone": { "number": "+1YYYYYYYYYY" }
},
"dialTimeoutDurationSeconds": 30,
"maxCallDurationSeconds": 300,
"events": {
"onAnswer": [
{
"command": "messages",
"messagesName": "notification",
"messages": [
{
"type": "PLAY",
"play": { "url": "https://samplelib.com/mp3/sample-12s.mp3" }
},
{
"type": "SAY",
"say": {
"text": "Hello! This is a test notification from Sinch. Your verification code is 4 8 3 7.",
"voiceName": "Emma"
}
}
],
"events": {
"onFinish": [ { "command": "hangup" } ]
}
}
]
}
}
]
}Key fields (cross-referenced with the OpenAPI spec):
command: "dial": initiates a new outbound call leg. Non-blocking: the next command runs in parallel while the call is being established. Onlycommandandtoare required.callName: identifier for this call leg within the session (1 to 32 chars, no whitespace). Must be unique across active legs. Other commands (e.g.hangup) can reference it.from: your Sinch virtual number, used as the caller ID. Requires atypediscriminator. SupportsPHONEorSIPonly. Note thatfromdoes not supportSTREAM/VOICE_RELAY.to: the destination. Requires atypediscriminator:PHONE,SIP,STREAM, orVOICE_RELAY. For PSTN this isPHONEwith an E.164 number.dialTimeoutDurationSeconds: how long to wait for the recipient to answer (integer, seconds). On expiry theonTimeoutevent fires.maxCallDurationSeconds: hard ceiling on the duration of the answered call (integer, seconds). The call is terminated automatically when reached.events.onAnswer: SVAML commands executed once the call is answered. Other call lifecycle events available ondial:onBusy,onReject,onTimeout,onHangup,onFailure.command: "messages": non-blocking; queues one or more messages and continues.messagesName(1 to 32 chars, no whitespace) names the sequence so it can be targeted bystopMessages. Each item isSAY(TTS) orPLAY(audio URL).events.onFinishonmessages: fires when all queued items have finished playing. Hanging up here ends the call cleanly.
Message item types (type discriminator):
SAY: requiressay.textandsay.voiceName. Optionalsay.formatisTEXT(default) orSSML.PLAY: requiresplay.url, the URL of the media to play.
The example above only handles the happy path (onAnswer). A production flow should handle the other outcomes so a call that is never answered doesn't silently disappear. Add sibling handlers under the dial command's events:
"events": {
"onAnswer": [ { "command": "messages", "messages": [ /* ... */ ] } ],
"onTimeout": [ { "command": "hangup" } ],
"onBusy": [ { "command": "hangup" } ],
"onReject": [ { "command": "hangup" } ],
"onFailure": [ { "command": "hangup" } ]
}createCall accepts an optional Idempotency-Key header (16 to 128 characters; a random UUID v4 is strongly recommended). If a request with the same key is received within 10 minutes, the server returns the cached response from the original request instead of placing a second call, which makes retries on network errors safe.
curl -H "Idempotency-Key: $(uuidgen)" ...Because a dropped connection can leave you unsure whether a call was actually created, generate the key once before the first attempt and reuse the same key for every retry of that call. Generating a new key per attempt defeats the purpose.
A successful response is HTTP 201 with body:
{
"projectId": "...",
"serviceId": "...",
"sessionId": "..."
}For a batch of calls, the response additionally includes a batchId. Use the sessionId to inspect the call legs via GET /v2/projects/{projectId}/sessions/{sessionId}. See Track Call Status for details.
createCall can return these error statuses; the response body describes the problem:
| Status | Likely cause |
|---|---|
400 | Malformed SVAML (e.g. missing destination, invalid field). |
401 | Bad or missing Basic Auth credentials (KEY_ID/KEY_SECRET). |
402 | Billing/payment issue on the account. |
403 | Credentials valid but not authorized for this project/action. |
404 | Unknown projectId. |
429 | Rate limited. Back off and retry (reuse the same Idempotency-Key). |
500 | Server error. Retry with backoff. |
If a script reports that a variable is not set, confirm you exported it in the same shell you're running from. Run echo "$PROJECT_ID" to check, and remember that export does not carry over to new terminal windows unless you added it to your shell profile.
Swap the audio file: replace play.url with any publicly accessible MP3 or WAV:
{ "type": "PLAY", "play": { "url": "https://example.com/your-audio.mp3" } }Change the TTS voice or text: voiceName accepts values such as Emma and Brian.
Reorder or remove messages: messages is a plain array; entries play sequentially:
"messages": [
{ "type": "SAY", "say": { "text": "Welcome.", "voiceName": "Emma" } },
{ "type": "PLAY", "play": { "url": "https://example.com/hold-music.mp3" } }
]