Quickstart: Place a Call
End-to-end walkthrough: sign up, fund your wallet, claim a number, make your first outbound call, and verify the webhook.
This guide walks you through every step from a blank account to a live phone call using the Sautikit REST API. You will create an account, fund your KES wallet, claim a Kenyan phone number, fire your first outbound call with POST /v1/calls/originate, and confirm receipt of the signed webhook payload. The entire flow takes under ten minutes.
curl, Node.js 18+, or Python 3.9+ installed.https://api.sautikit.com.Open https://sautikit.com and complete the sign-up form. Sautikit uses a passwordless (magic link) flow: enter your email address, click the link that arrives in your inbox, and your workspace is created.
Once you are logged in, navigate to Settings → API Keys → New key, give it a label such as dev-test, and copy the key. This is the only time the full key is shown, so store it now.
Export it for use in all subsequent commands:
export SAUTIKIT_API_KEY="tskey_live_..."Verify the key works:
curl -s "https://api.sautikit.com/v1/wallet/balance" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .You should see a response like:
{
"workspace_id": "01900000-0000-7000-8000-000000000001",
"balance_minor": 0,
"currency": "KES",
"low_balance_threshold_minor": 0
}A zero balance is expected for a brand-new workspace. You will fund it in the next step.
Sautikit uses a prepaid wallet denominated in Kenyan shillings (KES). All API amounts use minor units: 100 minor units equal KES 1.00. A top-up of 500000 minor units is KES 500.00.
curl -s -X POST "https://api.sautikit.com/v1/topups" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "mpesa",
"amount_minor": 500000,
"currency": "KES",
"phone_e164": "+254722000001"
}' | jq .phone_e164 is required for M-Pesa STK; that's the number that receives the push prompt. Use any M-Pesa-registered number you control (it does NOT have to be the same number you're topping up for; one finance person can fund many workspaces from their own phone).
Replace +254722000001 with your M-Pesa number. An STK push prompt appears on your phone within ~5 seconds. Confirm the payment with your M-Pesa PIN and wait a few seconds for the wallet to update.
The response includes the topup_id and mpesa_account_ref (your workspace's stable Paybill reference, useful if you'd rather pay manually via the M-Pesa Paybill menu instead).
After payment settles, confirm your balance:
curl -s "https://api.sautikit.com/v1/wallet/balance" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .balance_minorThe value should reflect your top-up (e.g. 500000).
You need to own a number before you can place outbound calls. Browse the inventory first:
curl -s "https://api.sautikit.com/v1/numbers/available?country=KE¤cy=KES" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq '.[0]'Note the inventory_id from the first result. Then claim it:
curl -s -X POST "https://api.sautikit.com/v1/numbers/01900000-0000-7000-8000-000000000001/claim" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .The response returns the claimed number record:
{
"id": "01900000-0000-7000-8000-000000000002",
"number": "+254700000001",
"status": "active",
"monthly_price_minor": 10000,
"currency": "KES"
}Store the id and the number field:
export NUMBER_ID="01900000-0000-7000-8000-000000000002"
export FROM_NUMBER="+254700000001"Claiming charges the first month's rental from your wallet. At a typical monthly rental of KES 100, the charge leaves most of your top-up available for calls.
Now place a call. Replace +254722000001 with the mobile number you want to ring. The voice_callback_url must be a publicly accessible HTTPS endpoint that returns a VoiceAction JSON response. Use a service like webhook.site during development if you do not have a server running.
curl -s -X POST "https://api.sautikit.com/v1/calls/originate" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"to\": \"+254722000001\",
\"from\": \"$FROM_NUMBER\",
\"voice_callback_url\": \"https://your-server.example.com/voice\"
}" | jq .const response = await fetch("https://api.sautikit.com/v1/calls/originate", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: "+254722000001",
from: process.env.FROM_NUMBER,
voice_callback_url: "https://your-server.example.com/voice",
}),
});
const call = await response.json();
console.log(call.id, call.status);import os
import httpx
resp = httpx.post(
"https://api.sautikit.com/v1/calls/originate",
headers={
"Authorization": f"Bearer {os.environ['SAUTIKIT_API_KEY']}",
"Content-Type": "application/json",
},
json={
"to": "+254722000001",
"from": os.environ["FROM_NUMBER"],
"voice_callback_url": "https://your-server.example.com/voice",
},
)
call = resp.json()
print(call["id"], call["status"])A successful response looks like:
{
"id": "01900000-0000-7000-8000-000000000003",
"status": "ringing",
"direction": "outbound",
"to": "+254722000001",
"from": "+254700000001",
"created_at": "2026-06-27T12:00:00Z"
}The call is now dialling. The platform will POST to your voice_callback_url when the remote party answers (or fails). Your server must respond within 10 seconds with a VoiceAction JSON payload.
import express from "express";
const app = express();
app.use(express.json());
// Sautikit POSTs here when the call connects
app.post("/voice", (req, res) => {
// Return a simple greeting
res.json({
actions: [
{ say: { text: "Hello from Sautikit. Your call is connected.", voice: "alice", language: "en-US" } },
{ hangup: {} }
]
});
});
app.listen(3000, () => console.log("Voice server on :3000"));from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/voice")
async def voice():
return JSONResponse({
"actions": [
{"say": {"text": "Hello from Sautikit.", "voice": "alice", "language": "en-US"}},
{"hangup": {}}
]
})After the call completes, Sautikit delivers a call.completed event to the events_url you configured on the number (or to any workspace webhook subscription you have set up). Each delivery is signed with an HMAC-SHA256 signature so you can confirm it genuinely came from Sautikit.
The X-Sautikit-Signature header format is:
t=1751000000,v1=a3f2c7...
The HMAC message is:
raw_body + "." + timestamp
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySignature(rawBody, sigHeader, secret) {
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("=", 2))
);
const ts = parts["t"];
const v1 = parts["v1"];
if (!ts || !v1) throw new Error("missing t or v1 in signature header");
// Reject timestamps older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error("timestamp too old; possible replay attack");
}
const expected = createHmac("sha256", secret)
.update(rawBody) // raw bytes, not parsed JSON
.update(".")
.update(ts)
.digest("hex");
if (!timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
throw new Error("signature mismatch");
}
}
// Usage in Express
app.post("/events", express.raw({ type: "application/json" }), (req, res) => {
verifySignature(
req.body,
req.headers["x-sautikit-signature"],
process.env.WEBHOOK_SECRET
);
const event = JSON.parse(req.body);
console.log(event.event_kind, event.payload);
res.sendStatus(200);
});import hashlib
import hmac
import time
def verify_signature(raw_body: bytes, sig_header: str, secret: str) -> None:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
ts = parts.get("t")
v1 = parts.get("v1")
if not ts or not v1:
raise ValueError("missing t or v1 in signature header")
if abs(time.time() - float(ts)) > 300:
raise ValueError("timestamp too old")
message = raw_body + b"." + ts.encode()
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
raise ValueError("signature mismatch")Check that the call reached the completed state:
curl -s "https://api.sautikit.com/v1/calls?limit=1" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq '.[0] | {id, status, duration_seconds, cost_minor}'Expected output:
{
"id": "01900000-0000-7000-8000-000000000003",
"status": "completed",
"duration_seconds": 12,
"cost_minor": 200
}cost_minor is debited from your wallet at hangup. A 12-second call is billed as 1 minute (ceiling division). Confirm the deduction:
curl -s "https://api.sautikit.com/v1/wallet/balance" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .balance_minorThe balance will be lower by the call cost. Your wallet ledger records every charge:
curl -s "https://api.sautikit.com/v1/wallet/statements?kind=call_charge&limit=5" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .Check that the Authorization: Bearer prefix is present and that the key value has not been truncated. API keys begin with tskey_live_ in production.
Your wallet balance reached zero. Top up before retrying. Remember that claiming a number deducts the first month's rental immediately. Keep enough credit for both the rental and the calls you intend to make.
The voice_callback_url must be an absolute https:// URL. Plain http:// is rejected in production. During local development, use a tunnel tool (ngrok http 3000, cloudflared tunnel, or similar) to expose your local port with an https:// address.
If the remote party does not answer within the Dial timeout (default 30 seconds), the call moves to no_answer. Check the remote number is reachable. If your voice_callback_url returns a non-2xx response or times out, the platform hangs up the call automatically.
events_url is set on the number's routing (PATCH /v1/numbers/{id}/routing).GET /v1/webhooks/{subscription_id}/deliveries.dead_letter.The most common cause is that your web framework parsed the JSON body before you read it, changing the byte representation. Use the raw body middleware (e.g. express.raw() in Express) on your webhook route. See Verify webhook signatures for all four language examples.
Call cost is calculated at hangup using ceiling-division per minute:
cost_minor = ceil(duration_seconds / 60) × rate_per_minute_minor
A 12-second call is billed as 1 minute. A 61-second call is billed as 2 minutes. The rate is set per workspace in KES minor units.
For example, at a rate of KES 2.00/minute (200 minor units):
ceil(45/60) = 1 minute → KES 2.00 (200 minor units)ceil(90/60) = 2 minutes → KES 4.00 (400 minor units)Calls that never reach answered state (ringing, no_answer, busy, failed, canceled) are not charged.
To see the exact rate for your workspace, check the number's pricing fields returned by the inventory browse endpoint (inbound_per_min_minor, outbound_per_min_minor).