A call center on Sautikit is a combination of inbound call routing and outbound dialling, both controlled by voice-action webhooks. For inbound calls, your webhook returns a Queue action and the phone switch's own call-centre queue takes over: it holds the caller, announces their position, picks which agent to offer the call to, and rings them. For outbound calls, your server initiates a POST /v1/calls and uses a webhook to connect the answered call to an agent. No proprietary call center platform is required; the logic lives in your application.
Return a Queue action and the phone switch's own call-centre queue takes the caller: it holds them, announces their position, chooses which of your agents to offer the call to, and rings that agent's device. Your server decides nothing per-call. Actions you place after the Queue run when the caller leaves it, which is where voicemail or a callback offer goes.
When the assignment decision has to be yours — a CRM lookup, a shift roster, a skills matrix — skip the queue and hold the caller in a named conference room instead, dialling an agent in when your own worker picks one:
Agents can be reached three ways: by ordinary phone number, by SIP URI to your own PBX (sip:agent@yourpbx.example.com), or by registering each agent's softphone directly to Sautikit as a SIP credential.
The third is usually the one you want. Each registered device gets a four-digit internal extension, so an agent is reachable as 1001 from a number's forward target or from another agent's handset — and the agent-side leg costs nothing, because it never touches a carrier. From your own Dial verb, target the device by its credential identity (client:<username>), which the credential list returns.
That changes the arithmetic of a call centre. The usual model bills you twice for one conversation: once for the customer's leg and again for the leg out to whichever number the agent is sitting at. With agents registered to Sautikit you pay for the customer leg only.
A team lead can join a live agent call to listen silently, whisper coaching that only the agent hears, or barge into the conversation, and switch between the three without redialling. The supervisor joins from a browser, a SIP phone or their mobile; a browser or SIP supervisor costs KES 0.50 a minute. See Call monitoring, whisper and barge and the developer guide.
Endpoints you call:
POST /v1/calls: place an outbound call (to contact or to agent).GET /v1/calls/{call_sid}: retrieve call metadata, duration, and status.GET /v1/calls: list calls for reporting and queue dashboards.Voice actions used:
Queue: park the caller until an agent is free. This is the inbound queue.Say: greetings and hold messages.Play: hold music audio file.Dial: connect a caller straight to an agent's extension, number, or SIP URI.GetDigits: optional IVR pre-routing (department selection).Redirect: re-route a call while it is in progress, including on the way out of a queue.Conference: bridges you assemble yourself, such as a hold room or a scheduled call.Hangup: end the call, update your CRM status.The short version is one verb. Return a queue action and the switch does the waiting; put whatever should happen to an unanswered caller after it, because actions following a Queue run when the caller leaves it, however that ended:
import express from "express";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.post("/calls/inbound", (req, res) => {
res.json({
actions: [
{ say: { text: "Thank you for calling Acme support." } },
{
queue: {
name: "support",
// A stable, cacheable URL. A freshly-signed URL per call is a new
// cache key every time, and the switch downloads the file before it
// parks the caller.
waitUrl: "https://cdn.example.com/hold-music.mp3",
callerId: req.body.From,
announceVoice: "en-GB-Standard-F",
},
},
// Runs when the caller leaves the queue unanswered.
{ redirect: { url: "https://yourapp.example.com/calls/voicemail" } },
],
});
});
app.listen(3000);There is no hold ceiling: a caller waits until an agent answers, until the queue's configured maximum wait if you set one, or until they hang up. When an agent hangs up, the next caller in line reaches them in roughly 170ms.
If you would rather not write a callback at all, set the queue block on the number itself with PUT /v1/numbers/{id}/routing. That reference also covers sharing_mode — the ring order across your agent pool — and the 202 response you get when the configuration is saved but the switch could not be reached, which is an expected outcome rather than an error.
You do not have to use the queue. If your own system already decides who takes each call — because it reads a CRM, or a shift roster, or a skills matrix you maintain — keep that logic and use Dial and Conference directly. The pattern below dials a free agent, and otherwise parks the caller in a per-call conference room that a background worker joins an agent to:
import express from "express";
import { findAvailableAgent, enqueueCall, dequeueCall } from "./agent-store";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Sautikit calls this when a customer dials your number
app.post("/calls/inbound", async (req, res) => {
const callId = req.body.CallId;
const agent = await findAvailableAgent();
if (agent) {
// Agent is free: dial directly
agent.markBusy();
return res.json({
actions: [
{ say: { text: "Connecting you to an agent." } },
{
dial: {
number: agent.phoneNumber,
callerId: req.body.To,
timeout: 30,
},
},
],
});
}
// No agent: put caller in a named hold conference
await enqueueCall(callId);
return res.json({
actions: [
{ say: { text: "All agents are currently with other customers. Please hold." } },
{
conference: {
name: `queue-${callId}`,
startOnEnter: false,
waitUrl: "https://yourapp.example.com/hold-music",
statusEventsCallbackUrl: "https://yourapp.example.com/calls/queue-events",
statusEvents: "join leave end",
endOnExit: true,
},
},
],
});
});
// Your background queue worker calls this when an agent becomes available
app.post("/calls/connect-agent", async (req, res) => {
const { customerCallId, agentNumber } = req.body;
// Dial the agent; when they answer, join the customer's conference
const agentCallResponse = await fetch("https://api.sautikit.com/v1/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: agentNumber,
from: process.env.SAUTIKIT_NUMBER,
action_url: `https://yourapp.example.com/calls/agent-join?queue=${customerCallId}`,
}),
});
res.json({ ok: true });
});
// When the agent answers, join the customer's hold conference
app.post("/calls/agent-join", (req, res) => {
const customerCallId = req.query.queue;
return res.json({
actions: [
{
conference: {
name: `queue-${customerCallId}`,
startOnEnter: true,
endOnExit: true,
beep: false,
},
},
],
});
});
app.listen(3000);curl -X POST "https://api.sautikit.com/v1/calls" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+254711222333",
"from": "+254700000001",
"action_url": "https://yourapp.example.com/calls/outbound-answer",
"status_url": "https://yourapp.example.com/calls/outbound-status"
}'A call center workload involves multiple call legs per interaction:
Inbound customer leg: billed per minute from answer to hangup.
Outbound agent dial leg: each Dial or POST /v1/calls to an agent number is a separate outbound call billed at the destination rate.
Hold time: the inbound leg continues to accrue per-minute billing while the caller waits, whether in a queue or a hold conference. Nothing cuts a queued caller off, so a deep queue is a real line item — set a maximum wait and an overflow to voicemail if you would rather pay for the message than the wait.
Agent leg, when the agent is registered to Sautikit: not billed at all. A call bridged to a registered device by its extension consumes no carrier minutes, so there is nothing to charge. Only the customer's leg is billed.
Agents reached via a sip: URI to your own PBX are billed at the SIP termination rate rather than the mobile/landline rate — lower than dialling a mobile, but not free, because the call still leaves Sautikit to reach your PBX.
For outbound campaigns, factor in answer rates. Only answered calls proceed to the action_url and get connected to an agent. Unanswered calls (no-answer, busy) are billed only for the ring duration, typically 20–40 seconds.
queue block, sharing_mode, and queue provisioning.