Claim and Route a Number
Browse the Sautikit DID inventory, claim a Kenyan number, set routing config, and verify inbound call delivery.
Claiming a Sautikit number takes two API calls: browse the inventory to find an available DID, then POST to claim it. Once claimed, configure routing to tell the platform where to deliver inbound call events and call lifecycle webhooks. This guide covers the full flow including SIP credential creation.
voice_callback_url. During development a tunnel like ngrok or cloudflared works.Query the number inventory filtered by country:
curl -s "https://api.sautikit.com/v1/numbers/available?country=KE¤cy=KES" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq '.[0:3]'Each entry includes:
{
"id": "01900000-0000-7000-8000-000000000001",
"number": "+254700000001",
"country": "KE",
"capabilities": ["voice"],
"monthly_price_minor": 10000,
"inbound_per_min_minor": 0,
"outbound_per_min_minor": 300,
"currency": "KES"
}Amounts are in minor units (KES × 100). monthly_price_minor: 10000 = KES 100.00/month. Pick a number and note its id.
Claiming atomically debits the first month's rental and creates a tenant_number record in your workspace:
curl -s -X POST "https://api.sautikit.com/v1/numbers/01900000-0000-7000-8000-000000000001/claim" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .Successful response:
{
"id": "01900000-0000-7000-8000-000000000002",
"number": "+254700000001",
"status": "active",
"currency": "KES",
"monthly_price_minor": 10000,
"created_at": "2026-06-27T12:00:00Z"
}Store the id:
export NUMBER_ID="01900000-0000-7000-8000-000000000002"If your wallet balance is insufficient the API returns:
{ "error": { "code": "wallet.insufficient_funds", "message": "insufficient wallet balance" } }Top up your wallet before retrying. See Wallet and Billing.
Routing connects your claimed number to your webhook handlers. Two URLs are involved:
voice_callback_url: Sautikit POSTs here on each call step. Your server returns a VoiceAction DSL response that controls the call.events_url: Sautikit POSTs call lifecycle events (call.started, call.answered, call.completed, call.failed, call.recording.ready) here.curl -s -X PATCH "https://api.sautikit.com/v1/numbers/$NUMBER_ID/routing" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"voice_callback_url": "https://ivr.example.com/voice",
"events_url": "https://ivr.example.com/events"
}' | jq .Both URLs must be https:// in production. The platform rejects http:// endpoints.
You can set one or both URLs. If you omit events_url, call lifecycle events are not delivered for this number.
curl -s "https://api.sautikit.com/v1/numbers/$NUMBER_ID" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq '{number, status, voice_callback_url, events_url}'If you want a softphone or SIP client to register the number directly, create SIP credentials:
curl -s -X POST "https://api.sautikit.com/v1/numbers/$NUMBER_ID/sip-credentials" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "main-agent"}' | jq .Response:
{
"id": "01900000-0000-7000-8000-000000000003",
"label": "main-agent",
"username": "ts_7f3a92b4",
"password": "s3cr3t-shown-once",
"status": "active"
}The password is shown once only. Store it immediately. If you lose it, rotate via:
curl -s -X POST \
"https://api.sautikit.com/v1/numbers/$NUMBER_ID/sip-credentials/01900000-0000-7000-8000-000000000003/rotate" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .passwordOnly an Argon2id hash is retained server-side. Sautikit cannot recover the plaintext.
Your voice_callback_url must respond within 10 seconds with a VoiceAction JSON payload. Here is the minimal implementation in Node.js (Express) and Python (FastAPI):
import express from "express";
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.post("/voice", (req, res) => {
const callerNumber = req.body.From ?? req.body.from ?? "unknown";
console.log(`Inbound call from ${callerNumber}`);
res.json({
actions: [
{
say: {
text: `Hello! Thank you for calling. Your number is ${callerNumber.split("").join(". ")}.`,
voice: "alice",
language: "en-US",
},
},
{ hangup: {} },
],
});
});
app.listen(3000);from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/voice")
async def voice(request: Request):
form = await request.form()
caller = form.get("From") or form.get("from") or "unknown"
return JSONResponse({
"actions": [
{"say": {"text": f"Hello, thank you for calling.", "voice": "alice", "language": "en-US"}},
{"hangup": {}}
]
})Your events_url receives call lifecycle events (signed with HMAC-SHA256):
import { createHmac, timingSafeEqual } from "node:crypto";
app.post("/events", express.raw({ type: "*/*" }), (req, res) => {
const sig = req.headers["x-sautikit-signature"] ?? "";
const secret = process.env.WEBHOOK_SECRET;
// Parse timestamp and signature
const parts = Object.fromEntries(sig.split(",").map(p => p.split("=", 2)));
const ts = parts["t"];
const v1 = parts["v1"];
if (!ts || !v1) return res.status(401).end("missing signature");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end("stale");
const expected = createHmac("sha256", secret)
.update(req.body).update(".").update(ts).digest("hex");
if (!timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
return res.status(401).end("bad signature");
}
const event = JSON.parse(req.body);
console.log(event.event_kind, event.payload?.call_id, event.payload?.status);
res.sendStatus(200);
});Place a test call to your number from any mobile phone. Then retrieve the call record:
curl -s "https://api.sautikit.com/v1/calls?limit=1" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | \
jq '.[0] | {direction, status, from, to, duration_seconds}'Check the routing is correct:
curl -s "https://api.sautikit.com/v1/numbers/$NUMBER_ID" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | \
jq '{number, voice_callback_url, events_url, status}'