Call hunting is the pattern where the Voice API tries to reach a person at one of several phone numbers and stops as soon as someone answers. There is no dedicated hunt command: you build the pattern by chaining a dial command's lifecycle events (onAnswer, onTimeout, onBusy, onReject, onFailure) and using bridgeCall to connect the answering leg back to the caller.
Three flavors of hunt are covered here:
| Pattern | Description | When to use |
|---|---|---|
| Sequential hunt | Try agent A; if no answer, try B; if no answer, try C. | Skill-based routing, cost-sensitive dispatch, on-call escalation. |
| Simultaneous ring (sim-ring) | Ring all agents at once; first to answer wins, others cancelled. | Time-critical alerts, smallest pool, "first available" lead distribution. |
| Hybrid (groups in sequence) | Sim-ring tier 1; if all tier-1 miss, sim-ring tier 2. | Tiered support, follow-the-sun teams. |
All three patterns are pure SVAML; no proprietary extension is needed.
Start here: the fastest path to a working hunt is Pattern 1 (sequential hunt, static SVAML). It runs from one curl with no backend server, no public URL, and no service configuration. Get that working first, then reach for the webhook-driven and tiered variants when you need a dynamic agent pool.
- Sales lead dispatch: a new inbound web lead triggers an outbound campaign; the API hunts through a list of sales reps until one picks up, then bridges the lead in.
- On-call escalation: a monitoring alert triggers an outbound call that hunts primary → secondary → manager until acknowledged.
- Field-service dispatch: a customer requests a callback; the platform hunts available technicians by region until one accepts.
- Personal find-me: one published number is hunted across an employee's desk, mobile, and home line.
Every example below reads its configuration from environment variables. Export them in the shell session you'll run the examples from (or put the export lines in a file you source):
# Credentials and project (Sinch Dashboard: https://dashboard.sinch.com)
export PROJECT_ID="your-project-id"
export KEY_ID="your-access-key-id"
export KEY_SECRET="your-access-key-secret"
# Numbers (E.164)
export SINCH_NUMBER="+15551234567" # Sinch virtual number, caller ID on every outbound leg
export CUSTOMER_NUMBER="+15559876543" # the customer/target you connect the agent to
export AGENT_NUMBERS="+15551110001,+15551110002,+15551110003"
# Patterns 2 and 4 only (webhook-driven)
export SERVICE_ID="your-voice-service-id"
export CALLBACK_URL="https://<your-ngrok-subdomain>.ngrok.io" # public HTTPS base URL
export PORT=3000 # webhook server port| Variable | Used by | Where to get it |
|---|---|---|
PROJECT_ID | all | Sinch Dashboard → your project. |
KEY_ID / KEY_SECRET | all (HTTP Basic auth) | Dashboard → Access Keys. Sent as -u "$KEY_ID:$KEY_SECRET". |
SINCH_NUMBER | all | A Sinch virtual number (E.164) provisioned on the project. Used as the caller ID (from) on every outbound leg. |
CUSTOMER_NUMBER | all | The customer/target you connect the agent to. |
AGENT_NUMBERS | all | Comma-separated E.164 list of agent destinations. The shell examples read the first three; the Node server reads the whole list. |
SERVICE_ID | Patterns 2/4 only | Dashboard → your voice service. Needed to configure the webhook. |
CALLBACK_URL | Patterns 2/4 only | Your publicly reachable webhook base URL. During development run ngrok http $PORT and use the HTTPS URL it prints. |
PORT | Patterns 2/4 Node server | The port the webhook server listens on; defaults to 3000 if unset. Point ngrok at the same port. |
Tools: bash, curl, and (optional but recommended) jq and uuidgen for the shell patterns; node (ES modules) + npm install express and ngrok for the webhook server.
Required environment for the first-success path (Pattern 1): PROJECT_ID, KEY_ID, KEY_SECRET, SINCH_NUMBER, CUSTOMER_NUMBER, AGENT_NUMBERS. No webhook server or CALLBACK_URL is needed for Pattern 1.
- Familiarity with the SVAML v2 command reference, specifically
dial,bridgeCall,hangup,messages/stopMessages, and theeventsblock. - Auth is HTTP Basic (
KEY_ID:KEY_SECRET).
A hunt always involves two roles inside one call session:
- Target: the customer/lead you ultimately want connected. Often this is the first leg you dial. (For hunts that begin with an inbound call, the inbound leg plays this role.)
- Agent pool: the list of internal numbers the platform tries until one answers.
Three SVAML primitives carry the pattern:
dialTimeoutDurationSecondsdecides how long each ring attempt lasts before moving on. When it expires, theonTimeoutevent fires.events.onAnsweris where youstopMessagesandbridgeCallthe winning agent to the target.events.onTimeout/onBusy/onReject/onFailureare where you issue the nextdialin the hunt chain.
events.onHangup is what tears the session down once any leg drops.
Call the customer, and on answer try Agent A → B → C until someone picks up. Because the hunt list is static, you send the entire flow inline with the initial POST /v2/projects/{projectId}/calls request: no webhook server, no public URL, no service configuration. This is the fastest way to see a hunt work end to end.
Paste this into a shell where the Setup variables are exported. It splits AGENT_NUMBERS into three agent variables, builds the SVAML body with your numbers substituted, and posts it:
IFS=',' read -r A1 A2 A3 <<< "$AGENT_NUMBERS"
A2="${A2:-$A1}"; A3="${A3:-$A2}"
BODY=$(cat <<EOF
{
"commands": [
{
"command": "dial",
"callName": "customer",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${CUSTOMER_NUMBER}" } },
"dialTimeoutDurationSeconds": 30,
"events": {
"onAnswer": [
{ "command": "messages", "messagesName": "hold",
"messages": [ { "type": "SAY",
"say": { "text": "Please hold while we connect you to an agent.", "voiceName": "Emma" } } ] },
{ "command": "dial", "callName": "agent-1",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${A1}" } },
"dialTimeoutDurationSeconds": 20,
"events": {
"onAnswer": [
{ "command": "stopMessages", "messagesName": "hold" },
{ "command": "bridgeCall", "bridgeName": "hunt-bridge" }
],
"onTimeout": [
{ "command": "dial", "callName": "agent-2",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${A2}" } },
"dialTimeoutDurationSeconds": 20,
"events": {
"onAnswer": [
{ "command": "stopMessages", "messagesName": "hold" },
{ "command": "bridgeCall", "bridgeName": "hunt-bridge" }
],
"onTimeout": [
{ "command": "dial", "callName": "agent-3",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${A3}" } },
"dialTimeoutDurationSeconds": 20,
"events": {
"onAnswer": [
{ "command": "stopMessages", "messagesName": "hold" },
{ "command": "bridgeCall", "bridgeName": "hunt-bridge" }
],
"onTimeout": [
{ "command": "hangup", "callName": "customer" }
]
}
}
]
}
}
]
}
}
],
"onHangup": [ { "command": "hangup" } ]
}
}
]
}
EOF
)
curl -s -X POST \
-u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/calls" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "$BODY" | jq '.'Key points
- Each nested
dialis independent: whenonTimeoutfires onagent-1, that leg has already been torn down, so the nextdialopens a fresh channel. (Per the spec: "Commands that appear inside event handlers form independent sequences and execute in their own context.") bridgeCallwith a sharedbridgeNameconnects the answering agent tocustomer. The first call to enter the bridge creates it; the second joins (auto-created on first reference, per thebridgeCallschema).- The
customerleg keeps playing theholdmessages (messagesis non-blocking) until the answering agent issuesstopMessages+bridgeCall.
- The
customernumber rings; on answer the caller hears "Please hold…". agent-1rings. Answer it → the hold message stops and the two legs are bridged into a single conversation.- Let
agent-1ring out (or use a number you can leave unanswered) → afterdialTimeoutDurationSeconds,agent-2rings, and so on. - If all three time out, the caller hears the apology message and the call hangs up.
- The hunt list changes per call (skill matching, agent presence, geolocation).
- You need to record the result of each leg in your own systems before deciding the next number.
- You need to retry the same agent later in the day.
Use the webhook pattern below in those cases.
Here the platform calls your backend on every lifecycle event, and your backend decides the next number. This costs an HTTP round-trip per hop but gives you full control over the agent list.
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"'/voice/events",
"fallbackUrl": "'"$CALLBACK_URL"'/voice/events"
}
}
}'When the events property is omitted on a dial, Sinch falls back to the service-level webhook for that leg's lifecycle events, which is exactly what we want.
curl -s -X POST \
-u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/calls" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"commands": [
{
"command": "dial",
"callName": "customer",
"from": { "type": "PHONE", "phone": { "number": "'"$SINCH_NUMBER"'" } },
"to": { "type": "PHONE", "phone": { "number": "'"$CUSTOMER_NUMBER"'" } },
"dialTimeoutDurationSeconds": 30
}
]
}' | jq '.'Webhook delivery uses CloudEvents 1.0 HTTP binary mode. CloudEvent metadata travels in ce-* headers (ce-type is com.sinch.voice.call.control.v1); the JSON body is the event payload with two properties: event and call.
POST /voice/events
ce-specversion: 1.0
ce-type: com.sinch.voice.call.control.v1
ce-source: projects/<projectId>/services/<serviceId>
ce-id: <uuid>
ce-time: 2026-04-01T12:00:00Z
content-type: application/json
{
"event": "call.answered",
"call": { "callId": "...", "sessionId": "01BX...", "direction": "OUTBOUND", "callResult": "...", ... }
}The event enum (verified) includes: call.incoming, call.answered, call.busy, call.rejected, call.timeout, call.hangup, call.failed, plus call.amd.*, call.message.*, call.recording.*.
Respond with HTTP 200 and a { "commands": [...] } body. An empty commands array means "take no action."
The server maintains an in-memory hunt cursor keyed by sessionId (a restart wipes in-flight hunts; persist the cursor in a real store for production). Its flow:
call.answeredfor the customer leg → respond with theholdmessage plus the first agentdial(noevents, so its events also flow back to the webhook).call.timeout/call.busy/call.rejected/call.failedfor an agent leg → look up the hunt cursor for thatsessionId, advance it, and respond with the next agentdial, or amessages+hangupif the list is exhausted.call.answeredfor an agent leg → respond withstopMessages+bridgeCall.call.hangup→ clear the cursor and respond with an emptycommandsarray; the platform tears the session down.
Save this as server.js (it reads SINCH_NUMBER, AGENT_NUMBERS, and PORT from the exported environment):
// Sinch Voice API v2 — Webhook-driven sequential hunt (Pattern 2).
// Maintains an in-memory cursor keyed by sessionId. Restart wipes state.
//
// Requirements: npm install express
// Run: node server.js
// Expose: ngrok http $PORT (then set the service's callBehavior.webhook.url)
import express from "express";
const SINCH_NUMBER = process.env.SINCH_NUMBER || (() => { throw new Error("SINCH_NUMBER not set"); })();
const AGENT_LIST = (process.env.AGENT_NUMBERS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (AGENT_LIST.length === 0) throw new Error("AGENT_NUMBERS not set");
const PORT = process.env.PORT || 3000;
// In-memory hunt cursor: sessionId -> { index }
const cursors = new Map();
function dialAgent(number, name) {
return {
command: "dial",
callName: name,
from: { type: "PHONE", phone: { number: SINCH_NUMBER } },
to: { type: "PHONE", phone: { number } },
dialTimeoutDurationSeconds: 20,
// No `events` => fall back to the service webhook for lifecycle events.
};
}
const app = express();
app.use(express.json());
app.post("/voice/events", (req, res) => {
const { event, call } = req.body || {};
if (!event || !call) return res.status(200).json({ commands: [] });
const sessionId = call.sessionId;
const callName = call.callName;
console.log(`event=${event} sessionId=${sessionId} callName=${callName}`);
// Inbound call arrives — the inbound leg IS the customer; jump straight
// to dialing agent 1. The inbound leg joins the bridge here; the
// answering agent joins it too.
if (event === "call.incoming") {
cursors.set(sessionId, { index: 0 });
return res.status(200).json({
commands: [
{ command: "answer" },
{ command: "messages", messagesName: "hold",
messages: [{ type: "SAY", say: { text: "Please hold while we find an agent.", voiceName: "Emma" } }] },
{ command: "bridgeCall", bridgeName: "hunt-bridge" },
dialAgent(AGENT_LIST[0], "agent-0"),
],
events: { onHangup: [{ command: "hangup" }] },
});
}
// Outbound call answered (customer leg picked up after we dialed them).
if (event === "call.answered" && callName === "customer") {
cursors.set(sessionId, { index: 0 });
return res.status(200).json({
commands: [
{ command: "messages", messagesName: "hold",
messages: [{ type: "SAY", say: { text: "Please hold while we find an agent.", voiceName: "Emma" } }] },
dialAgent(AGENT_LIST[0], "agent-0"),
],
});
}
// Agent leg answered — bridge them in and stop the hold message.
if (event === "call.answered" && callName?.startsWith("agent-")) {
return res.status(200).json({
commands: [
{ command: "stopMessages", messagesName: "hold" },
{ command: "bridgeCall", bridgeName: "hunt-bridge" },
],
});
}
// Agent leg failed to connect — advance the cursor and try the next agent.
const failed = ["call.timeout", "call.busy", "call.rejected", "call.failed"].includes(event);
if (failed && callName?.startsWith("agent-")) {
const cursor = cursors.get(sessionId) || { index: 0 };
cursor.index += 1;
cursors.set(sessionId, cursor);
if (cursor.index >= AGENT_LIST.length) {
// No more agents — apologise and hang up.
cursors.delete(sessionId);
return res.status(200).json({
commands: [
{ command: "messages", messagesName: "noanswer",
messages: [{ type: "SAY",
say: { text: "We are sorry, no agent is available. Please try again later.",
voiceName: "Emma" } }],
events: { onFinish: [{ command: "hangup" }] } },
],
});
}
return res.status(200).json({
commands: [dialAgent(AGENT_LIST[cursor.index], `agent-${cursor.index}`)],
});
}
// Cleanup on hangup of the customer leg.
if (event === "call.hangup") {
cursors.delete(sessionId);
}
return res.status(200).json({ commands: [] });
});
app.listen(PORT, () => {
console.log(`Hunt webhook server listening on :${PORT}`);
console.log(`Configure service callBehavior.webhook.url -> https://<your-ngrok>/voice/events`);
});When call.answered fires for an agent leg, the response it sends is:
HTTP/1.1 200 OK
{
"commands": [
{ "command": "stopMessages", "messagesName": "hold" },
{ "command": "bridgeCall", "bridgeName": "hunt-bridge" }
]
}npm install express
node server.js # logs the port it is listening on
ngrok http $PORT # in a second terminal
# Then run step 1 with CALLBACK_URL set to the ngrok HTTPS URL,
# and step 2 to start the customer call.For a "first available wins" hunt, fan out multiple dial commands in parallel from the customer leg, and let the first onAnswer race the others to bridgeCall. Because dial is non-blocking, all three calls start within a few milliseconds of each other.
Paste this into a shell where the Setup variables are exported:
IFS=',' read -r A1 A2 A3 <<< "$AGENT_NUMBERS"
A2="${A2:-$A1}"; A3="${A3:-$A2}"
BODY=$(cat <<EOF
{
"commands": [
{
"command": "dial",
"callName": "customer",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${CUSTOMER_NUMBER}" } },
"dialTimeoutDurationSeconds": 30,
"events": {
"onAnswer": [
{ "command": "messages", "messagesName": "hold",
"messages": [ { "type": "SAY",
"say": { "text": "Connecting you now.", "voiceName": "Emma" } } ] },
{ "command": "dial", "callName": "agent-A",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${A1}" } },
"dialTimeoutDurationSeconds": 25,
"events": {
"onAnswer": [
{ "command": "stopMessages", "messagesName": "hold" },
{ "command": "hangup", "callName": "agent-B" },
{ "command": "hangup", "callName": "agent-C" },
{ "command": "bridgeCall", "bridgeName": "hunt-bridge" }
]
}
},
{ "command": "dial", "callName": "agent-B",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${A2}" } },
"dialTimeoutDurationSeconds": 25,
"events": {
"onAnswer": [
{ "command": "stopMessages", "messagesName": "hold" },
{ "command": "hangup", "callName": "agent-A" },
{ "command": "hangup", "callName": "agent-C" },
{ "command": "bridgeCall", "bridgeName": "hunt-bridge" }
]
}
},
{ "command": "dial", "callName": "agent-C",
"from": { "type": "PHONE", "phone": { "number": "${SINCH_NUMBER}" } },
"to": { "type": "PHONE", "phone": { "number": "${A3}" } },
"dialTimeoutDurationSeconds": 25,
"events": {
"onAnswer": [
{ "command": "stopMessages", "messagesName": "hold" },
{ "command": "hangup", "callName": "agent-A" },
{ "command": "hangup", "callName": "agent-B" },
{ "command": "bridgeCall", "bridgeName": "hunt-bridge" }
]
}
}
],
"onHangup": [ { "command": "hangup" } ]
}
}
]
}
EOF
)
curl -s -X POST \
-u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/calls" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "$BODY" | jq '.'Race-condition guards
- Losing legs are torn down with
hangup callName: "..."from inside the winner'sonAnswer. Ahanguptargeted at a leg that has already ended is a no-op (thehangupschema permits targeting an ended leg; subsequent media commands simply do not execute), so two near-simultaneous answers cannot deadlock the session. - Only the first leg to enter the bridge is connected to
customer. Late answers are dropped by the explicithangupcalls.
Combine the two: sim-ring tier 1, then on failure of all tier-1 legs, fall through to sim-ring tier 2. The cleanest implementation is webhook-driven: your backend keeps a tally of which tier-1 legs have ended, fires tier 2 when the count reaches the tier-1 size, and uses hangup callName: to clean up any tier-1 stragglers.
Pseudocode:
on call.answered(customer) -> commands: hold + fan out tier 1
on call.timeout|busy|rejected|failed(tier1 leg)
-> tier1_done++
if tier1_done == |tier1| -> commands: fan out tier 2
else -> commands: []
on call.answered(agent) -> commands: stopMessages + hangup-others + bridgeCall
on call.hangup(customer) -> commands: [] (platform tears session down)| Concern | What to do |
|---|---|
| All-miss handling | Ensure every hunt terminates when no agent answers: onTimeout/onBusy/onReject/onFailure on the last sequential leg (or a service-level fallback / maxCallDurationSeconds for sim-ring). The static Pattern 1 above only chains on onTimeout; add the other three. |
| Voicemail / AMD | Insert an amd command before bridgeCall so a voicemail pickup doesn't count as a successful hunt. AMD events are onHuman/onMachine/onBeep/onUnknown (webhook: call.amd.machine etc.). On machine/beep, treat the leg as a miss. |
| Whisper before bridging | Replace bridgeCall in the winner's onAnswer with a messages ("Incoming lead from Madrid…") followed by a webhook SVAML command that bridges only after the agent confirms. |
| Concurrent hunts to the same agent | The platform does not deduplicate. Track agent occupancy in your backend and skip busy agents rather than relying on call.busy. |
| Compliance / call recording | Add startRecording immediately after the winning bridgeCall. See Recording & Transcription. |
| Rate / pacing | For mass outbound (one customer to one of N agents, with M customers in flight) use the Batch API: set batchOptions.maxCps so you don't burst-dial your trunk. |
| Observability | Each leg has its own callId. Persist these keyed by sessionId for post-call reporting. |
| Cost | Each dial you fan out is a billable attempt even if unanswered. Sequential is cheaper than sim-ring; sim-ring is faster. |
| Cancellation | If the customer hangs up during the hunt, wire onHangup on the customer leg (Patterns 1 and 3 do this) or return a hangup from the call.hangup webhook event so the remaining agent legs are torn down. |
Use a sandboxed project and your own mobile number as a stand-in for "agent 1" with dialTimeoutDurationSeconds: 10. Reject the call instead of answering; with full failure-event handling, the next agent should ring immediately. (With the static Pattern 1 as written, only timeout advances the hunt; let it ring out to test progression.)
Run the Pattern 1 example, then pull the resulting session to verify which leg won:
# Note: session calls expose callId/callResult/callReason/answerTime — NOT callName.
curl -s -u "$KEY_ID:$KEY_SECRET" \
"https://voice.api.sinch.com/v2/projects/$PROJECT_ID/sessions/$SESSION_ID" \
| jq '.calls[] | {callId, callResult, callReason, answerTime, to}'