Skip to content

Call Pacing (Batch Calls)

Overview

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 commands array describing the call flow (with @placeholder references).
  • A parameters array, one object per destination, that fills the placeholders for that destination's call.
  • A batchOptions object that controls pacing: maxCps (max calls per second) and ttlSeconds (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.

Setup

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
VariableRequiredWhere to get itNotes
PROJECT_IDyesSinch Dashboard → your Voice project
KEY_IDyesDashboard → Access KeysHTTP Basic username
KEY_SECRETyesDashboard → Access Keys (shown once at creation)HTTP Basic password
SINCH_NUMBERyesDashboard → Numbers (a number assigned to the project)Caller ID (from)
DEST_1yesYour own listFirst destination, E.164
DEST_2yesYour own listSecond destination, E.164
MAX_CPSoptionaln/aDefaults to 5 in the examples
TTL_SECONDSoptionaln/aDefaults to 1800 in the examples

Dependencies:

  • curl (and optionally jq for pretty output) for the shell example.
  • Python: pip install requests for the Python example.
  • Node 18+ for the Node example (uses built-in fetch and crypto.randomUUID).

Create a batch call

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.

Bash + curl

#!/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

Python

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

Node.js (18+)

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

What success looks like

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.

Poll until a batch finishes

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
done

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

How it works

1. Placeholders

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.

2. Pacing (batchOptions)

FieldTypeDefaultMeaning
maxCpsinteger (1-1000)1000Requested maximum call-initiation rate, in calls per second. Actual CPS may be lower depending on routing, carrier capacity, and account limits.
ttlSecondsinteger (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.

3. Monitor batch progress

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.

4. Stop the batch (optional)

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.

5. Inspect individual sessions

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" | jq

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

Real-life examples

  • Appointment reminders: TTS reminder to every patient with an appointment tomorrow, as one batch request with one parameters entry per patient. Set maxCps: 5 so 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 ttlSeconds so 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.

Production-readiness checklist

ConcernWhat to do
IdempotencyAlways 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 choicePick 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 choiceFor 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 / voicemailInsert an amd command before the messages block when the recipient might be a mobile that goes to voicemail. See AMD.
Recording complianceIf recordings are required, insert startRecording inside onAnswer. The recording is per-session. See Recording & Transcription.
Failure observabilityPersist 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 personalizationEach 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 limitsEach 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.

API reference (at a glance)

  • POST /v2/projects/{projectId}/calls: body is callRequest, made of commands (required) plus optional parameters plus optional batchOptions. Providing parameters enables batch mode; batchOptions are only valid then. Returns 201 with callResponse (projectId, serviceId, sessionId, and batchId when 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 in commands as @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}: returns batchSummary with batchId, sessionCount, queued, inProgress, completed, expired, requestedCps, ttlSeconds, endTime.
  • GET /v2/projects/{projectId}/batches/{batchId}/details: returns batchDetails with a sessions[] array (id, status in {QUEUED, IN_PROGRESS, COMPLETED, EXPIRED}).
  • DELETE /v2/projects/{projectId}/batches/{batchId}: stops processing of queued call sessions in the batch; 202 Accepted with a { "result": ... } body.
We'd love to hear from you!
Rate this content: