The Voice API v2 supports batch calling for any scenario where you dial many recipients with the same call flow, such as appointment reminders, outage notifications, outbound campaigns, or survey waves. Instead of hand-writing one POST /v2/projects/{projectId}/calls request per recipient, you drive the whole list from a single request. This tutorial uses two destinations to keep things concrete. The request carries:
- A
commandsarray describing the call flow (with@placeholderreferences). - A
parametersarray, one object per destination, that fills the placeholders for that destination's call. - A
batchOptionsobject that controls pacing:maxCps(max calls per second) andttlSeconds(how long the platform keeps trying to start queued calls).
Providing parameters puts the request into batch mode; batchOptions are only valid in that mode. The request returns a single sessionId, plus a batchId you use to poll progress or stop the batch. Sinch initiates calls in the background, honoring maxCps so you don't overload your trunk or trip carrier rate limits.
Export your credentials and the two destinations into the shell you'll run the examples from. Nothing here reads a .env file; the examples pick everything up from the environment.
export PROJECT_ID="your-project-id"
export KEY_ID="your-access-key-id"
export KEY_SECRET="your-access-key-secret"
export SINCH_NUMBER="+1XXXXXXXXXX"
# The two destinations to dial (E.164)
export DEST_1="+15551110001"
export DEST_2="+15551110002"
# Optional pacing (defaults shown)
export MAX_CPS=5
export TTL_SECONDS=1800| Variable | Required | Where to get it | Notes |
|---|---|---|---|
PROJECT_ID | yes | Sinch Dashboard → your Voice project | |
KEY_ID | yes | Dashboard → Access Keys | HTTP Basic username |
KEY_SECRET | yes | Dashboard → Access Keys (shown once at creation) | HTTP Basic password |
SINCH_NUMBER | yes | Dashboard → Numbers (a number assigned to the project) | Caller ID (from) |
DEST_1 | yes | Your own list | First destination, E.164 |
DEST_2 | yes | Your own list | Second destination, E.164 |
MAX_CPS | optional | n/a | Defaults to 5 in the examples |
TTL_SECONDS | optional | n/a | Defaults to 1800 in the examples |
Dependencies:
- curl (and optionally jq for pretty output) for the shell example.
- Python:
pip install requestsfor the Python example. - Node 18+ for the Node example (uses built-in
fetchandcrypto.randomUUID).
A batch is one request with one parameters array. Each entry in that array queues one call and supplies the placeholder values for it, so the two destinations here go out as a single POST, not two.
#!/bin/bash
# Submit one batch request containing both destinations, with pacing controls.
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.}"
: "${DEST_1:?ERROR: DEST_1 is not set.}"
: "${DEST_2:?ERROR: DEST_2 is not set.}"
BASE_URL="https://voice.api.sinch.com/v2"
MAX_CPS="${MAX_CPS:-5}"
TTL_SECONDS="${TTL_SECONDS:-1800}"
BODY=$(printf '{
"commands": [
{
"command": "dial",
"callName": "batch-reminder",
"from": { "type": "PHONE", "phone": { "number": "%s" } },
"to": { "type": "PHONE", "phone": { "number": "@toNumber" } },
"dialTimeoutDurationSeconds": 30,
"maxCallDurationSeconds": 120,
"events": {
"onAnswer": [
{
"command": "messages",
"messages": [
{ "type": "SAY",
"say": { "text": "Hello, this is an automated reminder from Sinch. Goodbye.",
"voiceName": "Emma" } }
],
"events": { "onFinish": [ { "command": "hangup" } ] }
}
],
"onHangup": [ { "command": "hangup" } ]
}
}
],
"parameters": [
{ "toNumber": "%s" },
{ "toNumber": "%s" }
],
"batchOptions": {
"maxCps": %d,
"ttlSeconds": %d
}
}' "${SINCH_NUMBER}" "${DEST_1}" "${DEST_2}" "${MAX_CPS}" "${TTL_SECONDS}")
IDEMPOTENCY_KEY="$(command -v uuidgen >/dev/null && uuidgen || date +%s%N)"
echo "Submitting batch to ${DEST_1}, ${DEST_2} (maxCps=${MAX_CPS}, ttlSeconds=${TTL_SECONDS}) ..."
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" \
-H "Idempotency-Key: ${IDEMPOTENCY_KEY}" \
-d "${BODY}")
HTTP_BODY=$(echo "${RESPONSE}" | head -n -1)
HTTP_CODE=$(echo "${RESPONSE}" | tail -n 1)
if [ "${HTTP_CODE}" -eq 201 ]; then
echo "Batch created (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# Submit one batch request containing both destinations, with pacing controls.
# Requires: pip install requests
import json
import os
import sys
import uuid
import requests
PROJECT_ID = os.environ.get("PROJECT_ID")
KEY_ID = os.environ.get("KEY_ID")
KEY_SECRET = os.environ.get("KEY_SECRET")
SINCH_NUMBER = os.environ.get("SINCH_NUMBER")
required = {
"PROJECT_ID": PROJECT_ID, "KEY_ID": KEY_ID, "KEY_SECRET": KEY_SECRET,
"SINCH_NUMBER": SINCH_NUMBER,
"DEST_1": os.environ.get("DEST_1"), "DEST_2": os.environ.get("DEST_2"),
}
missing = [k for k, v in required.items() if not v]
if missing:
print(f"ERROR: missing env vars: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
recipients = [os.environ["DEST_1"], os.environ["DEST_2"]]
max_cps = int(os.environ.get("MAX_CPS", "5"))
ttl_seconds = int(os.environ.get("TTL_SECONDS", "1800"))
url = f"https://voice.api.sinch.com/v2/projects/{PROJECT_ID}/calls"
payload = {
"commands": [
{
"command": "dial",
"callName": "batch-reminder",
"from": {"type": "PHONE", "phone": {"number": SINCH_NUMBER}},
"to": {"type": "PHONE", "phone": {"number": "@toNumber"}},
"dialTimeoutDurationSeconds": 30,
"maxCallDurationSeconds": 120,
"events": {
"onAnswer": [
{
"command": "messages",
"messages": [
{"type": "SAY",
"say": {"text": "Hello, this is an automated reminder from Sinch. Goodbye.",
"voiceName": "Emma"}}
],
"events": {"onFinish": [{"command": "hangup"}]}
}
],
"onHangup": [{"command": "hangup"}]
}
}
],
# `parameters` is an array; each entry queues one call using that
# entry's placeholder values. One request, however many destinations.
"parameters": [{"toNumber": recipient} for recipient in recipients],
"batchOptions": {"maxCps": max_cps, "ttlSeconds": ttl_seconds},
}
headers = {
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
}
print(f"Submitting batch to {', '.join(recipients)} (maxCps={max_cps}, ttlSeconds={ttl_seconds}) ...")
response = requests.post(url, json=payload, headers=headers, auth=(KEY_ID, KEY_SECRET))
data = response.json()
if response.status_code == 201:
print("Batch created:")
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)// Submit one batch request containing both destinations, with pacing controls.
// Node.js 18+ (built-in fetch and crypto.randomUUID).
import { randomUUID } from "crypto";
const required = ["PROJECT_ID", "KEY_ID", "KEY_SECRET", "SINCH_NUMBER", "DEST_1", "DEST_2"];
for (const name of required) {
if (!process.env[name]) {
console.error(`ERROR: ${name} is not set. Run the export commands above.`);
process.exit(1);
}
}
const { PROJECT_ID, KEY_ID, KEY_SECRET, SINCH_NUMBER } = process.env;
const recipients = [process.env.DEST_1, process.env.DEST_2];
const maxCps = Number(process.env.MAX_CPS || 5);
const ttlSeconds = Number(process.env.TTL_SECONDS || 1800);
const url = `https://voice.api.sinch.com/v2/projects/${PROJECT_ID}/calls`;
const authHeader = "Basic " + Buffer.from(`${KEY_ID}:${KEY_SECRET}`).toString("base64");
const payload = {
commands: [
{
command: "dial",
callName: "batch-reminder",
from: { type: "PHONE", phone: { number: SINCH_NUMBER } },
to: { type: "PHONE", phone: { number: "@toNumber" } },
dialTimeoutDurationSeconds: 30,
maxCallDurationSeconds: 120,
events: {
onAnswer: [
{
command: "messages",
messages: [
{ type: "SAY",
say: { text: "Hello, this is an automated reminder from Sinch. Goodbye.",
voiceName: "Emma" } }
],
events: { onFinish: [{ command: "hangup" }] }
}
],
onHangup: [{ command: "hangup" }]
}
}
],
// `parameters` is an array; each entry queues one call using that
// entry's placeholder values. One request, however many destinations.
parameters: recipients.map((toNumber) => ({ toNumber })),
batchOptions: { maxCps, ttlSeconds }
};
console.log(`Submitting batch to ${recipients.join(", ")} (maxCps=${maxCps}, ttlSeconds=${ttlSeconds}) ...`);
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.status === 201) {
console.log("Batch created:");
console.log(JSON.stringify(data, null, 2));
} else {
console.error(`ERROR ${response.status}:`);
console.error(JSON.stringify(data, null, 2));
process.exit(1);
}The single POST /v2/projects/{projectId}/calls request returns 201 Created with a sessionId and a batchId covering both destinations:
{
"projectId": "5c5bf2b1-35ae-4825-ab89-457e07bb60e6",
"serviceId": "6e124178-c29d-46a5-943c-5c2ae544aade",
"sessionId": "01BX5ZZKBKACTAV9WEVGEMMVRB",
"batchId": "01BX5ZZKBKACTAV9WEVGEMMVRC"
}batchId is included only because parameters was provided (batch mode). This top-level sessionId identifies the request itself, not any one destination's call. To see the session created for each destination individually, use the batch details endpoint below.
You have one batchId covering both destinations. Poll it with the loop below, which reads PROJECT_ID, KEY_ID, KEY_SECRET, and BATCH_ID from the environment and prints the summary every 5 seconds until the batch reaches a final state.
export BATCH_ID=01BX5ZZKBKACTAV9WEVGEMMVRC#!/bin/bash
# Poll a batch summary every 5s until all call sessions reach a final state.
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.}"
: "${BATCH_ID:?ERROR: BATCH_ID is not set. Run: export BATCH_ID=01...}"
URL="https://voice.api.sinch.com/v2/projects/${PROJECT_ID}/batches/${BATCH_ID}"
while true; do
BODY=$(curl -s -u "${KEY_ID}:${KEY_SECRET}" "${URL}")
if command -v jq > /dev/null; then
echo "$BODY" | jq '{batchId, sessionCount, completed, expired, inProgress, queued, endTime}'
DONE=$(echo "$BODY" | jq -r 'if .queued==0 and .inProgress==0 then "yes" else "no" end')
else
echo "$BODY"
DONE="no"
fi
if [ "$DONE" = "yes" ]; then
echo "All call sessions finished."
break
fi
sleep 5
doneWith two destinations queued, the summary looks like:
{
"batchId": "01BX5ZZKBKACTAV9WEVGEMMVRC",
"sessionCount": 2,
"queued": 0,
"inProgress": 0,
"completed": 2,
"expired": 0,
"requestedCps": 5,
"ttlSeconds": 1800,
"endTime": "2025-02-10T09:05:00Z"
}When queued and inProgress both reach 0, endTime is set and the poll loop exits.
commands is a normal SVAML payload, except any string value can reference a parameter with @name syntax. For each entry in the parameters array, the API queues one call and replaces each placeholder with that entry's value before initiating it.
{
"commands": [
{
"command": "dial",
"callName": "reminder",
"from": { "type": "PHONE", "phone": { "number": "+1XXXXXXXXXX" } },
"to": { "type": "PHONE", "phone": { "number": "@toNumber" } },
"dialTimeoutDurationSeconds": 30,
"events": {
"onAnswer": [
{
"command": "messages",
"messages": [
{ "type": "SAY",
"say": { "text": "Hi @firstName, this is a reminder that your appointment is on @date.",
"voiceName": "Emma" } }
],
"events": { "onFinish": [ { "command": "hangup" } ] }
}
],
"onHangup": [ { "command": "hangup" } ]
}
}
],
"parameters": [
{ "toNumber": "+15551110001", "firstName": "Ada", "date": "Tuesday March 5" },
{ "toNumber": "+15551110002", "firstName": "Grace", "date": "Wednesday March 6" }
],
"batchOptions": {
"maxCps": 5,
"ttlSeconds": 1800
}
}parameters is an array of objects (up to 10,000 keys each, key names 1-32 chars, string values only). Each object is referenced in commands as @key and queues exactly one call, personalized with that object's values, so this single request queues both destinations with their own firstName and date.
| Field | Type | Default | Meaning |
|---|---|---|---|
maxCps | integer (1-1000) | 1000 | Requested maximum call-initiation rate, in calls per second. Actual CPS may be lower depending on routing, carrier capacity, and account limits. |
ttlSeconds | integer (1-10800) | 10800 (3 h) | How long the platform keeps trying to start queued calls. After TTL expires, queued (not-yet-initiated) call sessions are marked EXPIRED; in-progress calls continue. |
Pick maxCps based on what your trunk and downstream agent pool can handle; pick ttlSeconds based on how time-sensitive the message is.
GET /v2/projects/{projectId}/batches/{batchId} returns a batchSummary:
curl -u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/batches/$BATCH_ID" | jq{
"batchId": "01BX5ZZKBKACTAV9WEVGEMMVRC",
"sessionCount": 2,
"queued": 1,
"inProgress": 1,
"completed": 0,
"expired": 0,
"requestedCps": 5,
"ttlSeconds": 1800,
"endTime": null
}Note the field is requestedCps, not maxCps, and sessionCount is the number of call sessions queued from the parameters array. There's no failed counter here: sessions that never got a chance to start show up under expired once the TTL passes; the eventual outcome of a session that did start (BUSY, NO_ANSWER, FAILED, and so on) is visible per-call via the session endpoint below, not in this summary. The summary is populated incrementally; poll on a sensible cadence (for example, every 10 s for short batches, once a minute for hour-long ones).
For a session-by-session view instead of counts, use GET /v2/projects/{projectId}/batches/{batchId}/details:
curl -u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/batches/$BATCH_ID/details" | jq{
"sessions": [
{ "id": "01F8Z5J4X2G9Y3J4X2G9Y3J4X2G9", "status": "COMPLETED" },
{ "id": "01F8Z5J4X2G9Y3J4X2G9Y3J4X5YJ", "status": "IN_PROGRESS" }
]
}Each id is a session identifier, one per entry in the original parameters array; its status is one of QUEUED, IN_PROGRESS, COMPLETED, or EXPIRED. Use these ids with /v2/projects/{projectId}/sessions/{sessionId} to see how each individual destination's call actually ended.
curl -X DELETE -u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/batches/$BATCH_ID"DELETE /v2/projects/{projectId}/batches/{batchId} returns 202 Accepted with a JSON body confirming the stop:
{ "result": "Batch calls stopped successfully, unprocessed calls will not be initiated" }It stops any queued (not-yet-initiated) call sessions in the batch. Calls already in progress are not affected; they continue until they end naturally. Since one batchId now covers every destination in the request, a single call to this endpoint stops the whole batch.
For each id returned by the batch details endpoint, fetch session details:
curl -u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/sessions/$SESSION_ID" | jqThe response includes a calls[] array, one entry per call leg, with fields like callResult, callReason, startTime, answerTime, and endTime. See Track Call Status for a deeper walkthrough.
- Appointment reminders: TTS reminder to every patient with an appointment tomorrow, as one batch request with one
parametersentry per patient. SetmaxCps: 5so reminders trickle out over the morning instead of dialing 500 numbers in one second. - Outage notifications: Notify all impacted customers in a region in a single request. Use a short
ttlSecondsso calls that didn't reach anyone within the outage window aren't placed an hour later. - Survey waves: Dispatch follow-up surveys after a release.
- Dunning / collection campaigns: Pace outbound attempts to comply with collection-call cadence regulations.
| Concern | What to do |
|---|---|
| Idempotency | Always send the Idempotency-Key header. A network retry of the whole batch request must not double-queue every destination. UUID v4 recommended; the key must be 16-128 chars. All three examples set one automatically per submission. |
| CPS choice | Pick maxCps based on trunk capacity AND any downstream agent pool. If your IVR bridges to live agents, sustained CPS above agent availability just produces abandoned calls. |
| TTL choice | For time-sensitive messages (outage alerts), use a TTL just slightly longer than the relevance window. For non-urgent batches (reminders), longer TTLs absorb temporary carrier slowness. |
| AMD / voicemail | Insert an amd command before the messages block when the recipient might be a mobile that goes to voicemail. See AMD. |
| Recording compliance | If recordings are required, insert startRecording inside onAnswer. The recording is per-session. See Recording & Transcription. |
| Failure observability | Persist the batchId and, for each destination, its session id from the batch details endpoint to your CRM. After a batch ends, join each session id back to its destination to report which messages reached whom. |
| Per-recipient personalization | Each object in parameters fills the placeholders for exactly one queued call, in the same order the entries are listed. Add one entry per destination in a single request rather than sending separate requests. |
parameters limits | Each object supports up to 10,000 keys; key names are 1-32 characters, and values must be strings. Keep the array to the destinations for one batch and use separate batches for logically distinct campaigns. |
POST /v2/projects/{projectId}/calls: body iscallRequest, made ofcommands(required) plus optionalparametersplus optionalbatchOptions. Providingparametersenables batch mode;batchOptionsare only valid then. Returns201withcallResponse(projectId,serviceId,sessionId, andbatchIdwhen batched).parameters(requestParameters): an array of objects (requestParameter), each a dictionary of string keys (1-32 chars, up to 10,000 keys) to string values, referenced incommandsas@key. Each array entry queues exactly one call, so a single request can queue an entire batch.batchOptions.maxCps: integer 1-1000, default 1000.batchOptions.ttlSeconds: integer 1-10800, default 10800.GET /v2/projects/{projectId}/batches/{batchId}: returnsbatchSummarywithbatchId,sessionCount,queued,inProgress,completed,expired,requestedCps,ttlSeconds,endTime.GET /v2/projects/{projectId}/batches/{batchId}/details: returnsbatchDetailswith asessions[]array (id,statusin {QUEUED,IN_PROGRESS,COMPLETED,EXPIRED}).DELETE /v2/projects/{projectId}/batches/{batchId}: stops processing of queued call sessions in the batch;202 Acceptedwith a{ "result": ... }body.



