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.

  1. Create a test key under LLM keys & API (Mode: Test).
  2. 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 following simulate.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.answer can be no_answer, busy or voicemail to 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"
}'
toThe number to call, international format (+91…). Required.
botId / agentA saved bot, or instructions inline (systemPrompt, firstMessage, language, voice, collect, outcomes, optOutKey…).
variablesValues for {{placeholders}}. {{company}} (your organization) and {{phone}} are filled in for you.
collect, outcomesWhat to find out, and how a call can end. See below.
metadataYour own data (up to 50 keys), returned with the call and in webhooks: e.g. your order or customer ID.
webhookUrlWhere this call's events go (else the bot's webhook URL).
scheduledAtCall no earlier than this time (ISO 8601). Outside calling hours, the call waits until they open.
retryCall again if unanswered or busy: { "attempts": 3, "delayMinutes": 30 } means up to 3 tries, 30 minutes apart (default: 1 try).
Idempotency-Key headerRetrying 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, boolean or date.
  • 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, keypad or either. Keypad yes/no is 1 / 2; numbers can have min/max (a range up to 9 is one key press, otherwise digits then #); text fields list options like [{"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 (or null).
  • optOutKey (e.g. "9"): the bot announces it; pressing it ends the call as opted_out and 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.result and data.transcript.
  • call.failed: never connected (data.call.status no_answer, busy, failed or blocked); data.retry is 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"}.

400Invalid request (the message says which field), number on your do-not-call list
401Missing or invalid API key
402Not enough credit for a live call
403Not allowed (e.g. campaigns with a test key)
404Not found (or not yours)
409Idempotency-Key reused with a different body
429Too 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.