Quick start
Place an AI phone call with one request, then get a structured result: the outcome, the information you asked for, a summary and the transcript. Build against a test key first: an AI plays the person you call, so nothing is dialed or charged.
- Create a test key under LLM keys & API (Mode: Test).
- Create a call:
curl -X POST https://your-host/api/v1/calls \
-H "Authorization: Bearer ss_test_…" \
-H "content-type: application/json" \
-d '{
"to": "+919876543210",
"agent": { "systemPrompt": "You remind {{name}} about tomorrow'\''s 10 AM appointment and ask if they can make it." },
"variables": { "name": "Asha" },
"collect": [{ "name": "attending", "type": "boolean", "input": "either", "description": "Will they attend?" }],
"simulate": { "persona": "You are Asha. You can come, but 15 minutes late." }
}'
# A minute later, read the result:
curl https://your-host/api/v1/calls/CALL_ID -H "Authorization: Bearer ss_test_…"The call runs in about half a minute. Its result then shows e.g. {"outcome": null, "collected": {"attending": true}, "summary": "Asha will come, 15 minutes late."}. Add a webhookUrl to be told instead of asking.
Base URL: https://your-host/api/v1
API keys & test mode
Send your key as Authorization: Bearer ss_live_…. Keys are shown once when created; revoke them any time.
- Test keys (
ss_test_…) simulate calls: an AI plays the callee followingsimulate.persona. Same statuses, transcript, result and webhooks as live calls; no phone line, caller ID or credits needed. Up to 200 test calls a day.simulate.answercan beno_answer,busyorvoicemailto test those cases. - Live keys (
ss_live_…) place real phone calls from your verified number. - Test and live data are separate: each key only sees calls of its own mode.
Create a call
POST /calls queues a call and returns it with status: "queued". Use a saved bot (botId), an inline agent, or a bot plus per-call overrides in agent.
curl -X POST https://your-host/api/v1/calls \
-H "Authorization: Bearer ss_live_…" \
-H "content-type: application/json" \
-H "Idempotency-Key: order-1042-delivery" \
-d '{
"to": "+919876543210",
"agent": {
"systemPrompt": "You call {{name}} from QuickMart about today'\''s delivery of order {{order_id}}. First ask if they will be home today between 2 and 6 PM. If not, ask for a better time tomorrow. Then say goodbye.",
"optOutKey": "9"
},
"variables": { "name": "Asha", "order_id": "1042" },
"collect": [
{ "name": "home_today", "type": "boolean", "input": "either",
"description": "Will they be home TODAY between 2 and 6 PM?" },
{ "name": "new_time", "type": "date", "input": "voice",
"description": "Only if not home today: the better delivery time" }
],
"outcomes": ["confirmed", "rescheduled"],
"metadata": { "orderId": "1042" },
"webhookUrl": "https://example.com/hooks/calls"
}'| to | The number to call, international format (+91…). Required. |
| botId / agent | A saved bot, or instructions inline (systemPrompt, firstMessage, language, voice, collect, outcomes, optOutKey…). |
| variables | Values for {{placeholders}}. {{company}} (your organization) and {{phone}} are filled in for you. |
| collect, outcomes | What to find out, and how a call can end. See below. |
| metadata | Your own data (up to 50 keys), returned with the call and in webhooks: e.g. your order or customer ID. |
| webhookUrl | Where this call's events go (else the bot's webhook URL). |
| scheduledAt | Call no earlier than this time (ISO 8601). Outside calling hours, the call waits until they open. |
| retry | Call again if unanswered or busy: { "attempts": 3, "delayMinutes": 30 } means up to 3 tries, 30 minutes apart (default: 1 try). |
| Idempotency-Key header | Retrying with the same key and body returns the original call (200) instead of dialing again; a different body gets 409. |
Collect information
Each collect field is something the bot finds out during the call. You get every field back in result.collected (null when the callee didn't say).
name: snake_case key, e.g.new_time.type:text,number,booleanordate.description: what it means, as you'd tell a new receptionist: what, when to ask, the format, what to do if they won't say. This matters most for accurate results: make each field mean exactly one thing.input:voice,keypadoreither. Keypad yes/no is 1 / 2; numbers can havemin/max(a range up to 9 is one key press, otherwise digits then #); text fields listoptionslike[{"key": "1", "label": "confirm"}, {"key": "2", "label": "cancel"}]and you receive the label.outcomes: the possible results of the call, e.g.confirmed,rescheduled,wrong_person. You get one back (ornull).optOutKey(e.g."9"): the bot announces it; pressing it ends the call asopted_outand adds the number to your do-not-call list.
Get results
GET /calls/{id} returns the call, its transcript and, once it has ended and been analysed, its result:
curl https://your-host/api/v1/calls/CALL_ID -H "Authorization: Bearer ss_live_…"{
"id": "…",
"status": "completed",
"endReason": "bot_ended",
"durationSeconds": 46,
"metadata": { "orderId": "1042" },
"result": {
"outcome": "rescheduled",
"collected": { "home_today": false, "new_time": "tomorrow at 11 AM" },
"summary": "Asha won't be home today and asked for delivery tomorrow at 11 AM."
},
"transcript": [{ "role": "assistant", "text": "…", "at": "…" }]
}status: queued → dialing/ringing → in_progress → completed, or no_answer, busy, voicemail, failed, blocked (see blockedReason), cancelled. endReason: bot_ended, hangup (the callee hung up), opted_out, transferred, max_duration, voicemail, error.
Webhooks
Events are POSTed as JSON to the call's webhook URL:
call.answered: the callee picked up.call.completed: an answered call ended;data.call.resultanddata.transcript.call.failed: never connected (data.call.statusno_answer, busy, failed or blocked);data.retryis the next try, or null if this was the last.call.data: the bot sent data during the call.
{
"id": "e5caa02a-…", // unique per event: ignore duplicates
"event": "call.completed",
"created": "2026-10-01T06:35:01Z",
"test": false,
"data": { "call": { "…": "as GET /calls/{id}", "result": { } }, "transcript": [ ] }
}Reply with any 2xx within 10 seconds. Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 15 h. The Webhooks page shows every delivery with its response, and lets you resend or send a test ping.
Verify signatures
Every webhook has X-SingleStaff-Signature: t=<unix time>,v1=<hex>: an HMAC-SHA256 of <t>.<raw body> with your signing secret (Webhooks → Signing secret). Check it over the raw body, compare in constant time, and reject timestamps older than 5 minutes.
import crypto from "node:crypto";
import express from "express";
const app = express();
// Use the raw body: the signature covers the exact bytes we sent.
app.post("/hooks/calls", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("x-singlestaff-signature") ?? "";
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", process.env.SINGLESTAFF_WEBHOOK_SECRET)
.update(`${t}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body);
if (event.event === "call.completed") {
const { metadata, result } = event.data.call; // e.g. look up metadata.orderId, store result
}
res.sendStatus(200); // reply quickly; do slow work afterwards
});Errors & limits
Errors are JSON: {"error": "message"}.
| 400 | Invalid request (the message says which field), number on your do-not-call list |
| 401 | Missing or invalid API key |
| 402 | Not enough credit for a live call |
| 403 | Not allowed (e.g. campaigns with a test key) |
| 404 | Not found (or not yours) |
| 409 | Idempotency-Key reused with a different body |
| 429 | Too many requests (600 a minute), or test-call limit reached |
Live calls also follow your account's calling hours, concurrency and daily limits; a call that can't be placed ends as blocked, with the reason.
API reference & Postman
The API reference lists every endpoint with its fields, responses and a ready-to-copy request, and every webhook event.
The same description is available as an OpenAPI 3.1 file for your tools: https://your-host/api/v1/openapi.json. In Postman choose Import → Link and paste that address to get every request ready to send, or generate a client library for your language with openapi-generator. It's generated from the same rules the API checks requests with, so it's always up to date.