Phone Numbers
How Sautikit numbers work: browse inventory, claim, configure SIP credentials, set routing, and release.
A phone number in Sautikit is a Kenya (+254) DID that moves through a defined lifecycle: available in inventory → claimed to a workspace → active with SIP credentials → optionally released. Each active number carries its own routing configuration that tells the platform where to deliver inbound call events.
Search the inventory before claiming. Filter by country (Kenya = KE) and capability (e.g. voice).
curl -X GET "https://api.sautikit.com/v1/numbers/available?country=KE¤cy=KES" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY"Response includes monthly_price_minor (KES, in minor units; divide by 100 for shillings), inbound_per_min_minor, and outbound_per_min_minor.
Claiming atomically charges the first month's rental from your KES wallet and creates a tenant_number record.
curl -X POST "https://api.sautikit.com/v1/numbers/01900000-0000-7000-8000-000000000001/claim" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY"If your wallet balance is insufficient the API returns 402 wallet_insufficient_funds. Top up via M-Pesa STK push before retrying.
| State | Meaning |
|---|---|
available | In the public inventory pool; claimable |
reserved | Held for a few seconds during a claim transaction |
assigned | Owned by a workspace; billing is active |
released | Returned to the pool; billing stops immediately |
A tenant_number record separately tracks:
| Status | Meaning |
|---|---|
active | Normal, in use |
suspended | Temporarily suspended (admin action) |
released | Deactivated; number returned to inventory |
A SIP credential lets your own equipment — a desk phone, an office PBX, Odoo, a softphone — register with Sautikit and place or receive calls on the number.
curl -X POST "https://api.sautikit.com/v1/numbers/{id}/sip-credentials" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "Reception desk phone"}'The registrar generates the username and password and returns them with everything the device needs:
{
"id": "…",
"label": "Reception desk phone",
"username": "ep3f9a1c2b4d5e6f7a8b9c",
"password": "…",
"host": "sip.sautikit.com",
"port": 5080,
"transport": "udp",
"realm": "sip.sautikit.com"
}Configure host, port, transport, realm, username and password on the device. Take these values from the response rather than hard-coding them — they come from the registrar itself, so they cannot drift from what it actually listens on.
Credentials are scoped to a number, and a number can hold several — one per device, so each has its own password and its own lifecycle. Label them ("Reception desk phone", "Odoo prod") so you can tell them apart later. Revoking one leaves the others registered and working.
There is a per-number cap on active credentials. The list response reports active_count and limit, and creating past the limit returns numbers.credential_limit_reached. Revoking a credential frees a slot.
Give a credential an extension and your other registered devices reach it by dialling those four digits. Reception dials 1001 and the handset in the back office rings.
curl -X POST "https://api.sautikit.com/v1/numbers/{id}/sip-credentials" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "Back office", "extension": "1001"}'Those calls are bridged directly between the two devices and are not charged. They consume no carrier minutes, so there is nothing to bill — and because they never leave the platform, they do not appear in your call log either.
Three things follow from how extensions are scoped:
1001 means one device regardless of which of your numbers each was issued against. A clash returns numbers.extension_taken.+. Anything else returns numbers.extension_invalid.An extension is resolved by Sautikit rather than by the registrar, so changing one applies immediately and needs no change on the device. Pass an empty string to remove it. Revoking a credential frees its extension for reuse, which is what you want when a handset is replaced.
You can also send an inbound call from the outside world to an internal handset by using an extension as a forward destination — see Configure routing. That call is a real external call: it is logged and billed as normal, because one end of it is a carrier.
If the device is switched off when someone dials it, the caller hears a short message rather than silence.
You do not have to store the password to keep it. The registrar holds the secret and returns it on request, so you can display it again at any time:
curl -X POST "https://api.sautikit.com/v1/numbers/{id}/sip-credentials/{cred_id}/reveal" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY"Revealing changes nothing — an already-registered device keeps working. Reach for this rather than rotation when a password has simply been mislaid.
POST …/sip-credentials/{cred_id}/rotate replaces the password. The username is unchanged, but any device using the old password is disconnected and must be reconfigured.
Creating a credential provisions it on the registrar before responding, so a 201 means the credential already exists there and is ready to register. If the registrar cannot be reached the request fails with numbers.sip_provisioning_failed and nothing is created — you are never handed a password that will not work.
Credential statuses:
| Status | Meaning |
|---|---|
active | Valid for SIP registration |
revoked | Permanently disabled; the device is disconnected immediately |
Routing tells Sautikit where to POST inbound call events (voice actions) and call lifecycle events.
curl -X PATCH "https://api.sautikit.com/v1/numbers/{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"
}'voice_callback_url: Sautikit POSTs here on each call step. Your server returns a VoiceAction DSL response.events_url: Sautikit POSTs call lifecycle events (call.started, call.answered, call.completed, call.failed, call.recording.ready). These are per-number, not workspace webhooks.Both URLs must be https:// in production. The platform rejects plain http:// endpoints.
Rental is charged on the first day of each calendar month from your KES prepaid wallet. The charge is recorded as a number_rental ledger entry. If the wallet is empty at renewal time the number is suspended until you top up.
Trial numbers provisioned by Sautikit support have a trial_until timestamp. When that timestamp passes the number is automatically released and a reminder email is sent 24 hours before expiry.
curl -X DELETE "https://api.sautikit.com/v1/numbers/{id}" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY"Billing stops immediately on release. The number re-enters the inventory pool and may be claimed by another workspace.
Everything above assumes a Sautikit-provisioned number — rented from us, billed monthly. A workspace can instead bring its own SIP trunk: numbers it already owns through its own carrier, connected to Sautikit for call handling.
A workspace either buys its numbers from Sautikit or brings its own trunk — not both. The first successful action on either path locks the workspace to it: claiming a platform number locks it to platform numbers; a SIP trunk reaching a proven working state locks it to BYOC. Every later attempt to take the other path fails closed:
workspace.locked_to_byoc.workspace.locked_to_platform_numbers.The lock exists because rating, rental billing and support each treat a number as either fully ours or fully yours — mixing the two on one workspace would put an implicit "which kind is this?" branch on every operation. If you need both, use two workspaces: one buys from Sautikit, the other brings its own trunk.
There is no self-service way to undo the lock. If a workspace took the wrong path, release every number on it and contact support.
A workspace connects exactly one SIP trunk — PUT /v1/sip-trunk upserts it, and there is no trunk ID in the URL because there is nothing to disambiguate. Every BYO number the workspace adds rides that same trunk.
const res = await fetch("https://api.sautikit.com/v1/sip-trunk", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
label: "Head office trunk",
auth_mode: "registration",
proxy: "sip.example-carrier.com",
transport: "udp",
username: "sautikit_100",
password: "…",
}),
});
const trunk = await res.json();
console.log(trunk.status); // "active", "pending", or "failed"Which mode you use depends on how your carrier authenticates you.
Your provider issued you a SIP username and password. Sautikit's box registers to your provider with those credentials, the same way a desk phone would. This is the common case for a hosted or virtual carrier.
PUT /v1/sip-trunk pushes the registration and polls for the carrier to confirm it — synchronously, inside the same request. A 200 response tells you the outcome immediately: status: "active" means it registered; status: "failed" (with last_error set) means the carrier rejected it or was unreachable, most often a mistyped username or password.
Your provider identifies you by IP address instead of a password — common with on-premise PBXs and some carrier-direct trunks. Both sides allowlist each other, and your carrier has two addresses that need allowlisting, not one:
signaling_ips — where your provider's SIP messages (call setup) come from.media_ips — where your provider's call audio (RTP) comes from.These are frequently the same address, but not always — some carriers route signaling and media through different boxes. Submit both; if your provider uses one address for everything, put that same address in both fields. Leaving media_ips empty gets the request rejected outright (sip_trunk.invalid) rather than silently producing a trunk that connects but carries no audio.
const res = await fetch("https://api.sautikit.com/v1/sip-trunk", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
label: "On-prem PBX",
auth_mode: "ip_allowlist",
proxy: "pbx.example.com",
transport: "udp",
username: "sautikit_101",
country_code: "+254",
signaling_ips: ["203.0.113.10", "203.0.113.0/24"],
media_ips: ["203.0.113.10", "203.0.113.0/24"],
}),
});Signaling mode requires a person on Sautikit's operations team to review and approve it before it goes live — allowlisting a customer's IPs is a platform-wide trust grant on the box, since the carrier-facing profile has no per-call authentication of its own and the allowlist is the only gate on who can send it a call. Approval installs both signaling_ips and media_ips into that allowlist together; a newly submitted signaling trunk sits at status: "pending_approval" until that review completes, and there is no way to skip it.
Neither mode locks the workspace on submission — only on proof:
PUT call that submitted it.A trunk that never proves itself — wrong password, unreachable proxy, a signaling submission still awaiting review — leaves the workspace unlocked and free to buy a platform number instead. A mistyped password on your first attempt does not commit you to anything.
A BYO number carries no rental charge — you already pay your own carrier for the number itself, so Sautikit doesn't charge rent on top of that. Calls are billed per minute, same as a platform number, at rates set on your trunk or falling back to the platform's BYOC default for your wallet currency.
Once your trunk is active, add a number you already own to it:
const res = await fetch("https://api.sautikit.com/v1/numbers/byo", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
e164: "+254712345678",
friendly_name: "Head office main line",
}),
});
const number = await res.json();This fails with numbers.byo_requires_trunk if your trunk isn't active yet, and numbers.e164_taken if the number is already in Sautikit's inventory — yours, or another workspace's.