Webhooks
How Sautikit webhooks work: signed JSON events posted to your HTTPS endpoint, headers, signature checks, and up to 8 delivery attempts per event.
How Sautikit webhooks work: signed JSON events posted to your HTTPS endpoint, headers, signature checks, and up to 8 delivery attempts per event.
Sautikit POSTs signed JSON to your HTTPS endpoint when something happens in your workspace. Delivery is at-least-once: the same event can arrive more than once, so deduplicate on the X-Sautikit-Event-Id header. A failed delivery is tried up to 8 times.
Subscribe with POST /v1/webhooks, giving a url and an events array. The response includes the signing secret; it is shown once, so store it.
const res = await fetch("https://api.sautikit.com/v1/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://your-server.example.com/events",
events: ["wallet.*", "number.*", "call.recording.available"],
}),
});
const { secret } = await res.json();These are the events you can subscribe to:
| Event | Sent when |
|---|---|
number.provisioned | A number was claimed into your workspace |
number.released | A number was released from your workspace |
number.rental_past_due | A monthly number rental could not be charged |
wallet.top_up | A wallet top-up was confirmed |
wallet.low_balance | The wallet balance is at or below your threshold |
wallet.autopay_succeeded | An automatic top-up charged your saved card |
wallet.autopay_action_required | An automatic top-up needs you to act |
wallet.autopay_disabled | Automatic top-up was switched off |
storage.tier_changed | Your recording storage size changed |
call.recording.available | A compliance recording is ready; the body carries a pull_url |
call.recording.payment_required | A compliance recording is held until the wallet is topped up |
call.recording.expired | A compliance recording passed its retention period and was deleted |
call.supervision.ringing | A supervisor is being rung to join a live call |
call.supervision.started | A supervisor answered and joined a live call |
call.supervision.mode_changed | A supervisor switched between listen, whisper and barge |
call.supervision.ended | A supervision ended, with the reason and billable seconds |
call.supervision.failed | A supervisor leg never connected |
agent.published | An AI agent draft was published as a new revision |
broadcast.started | A broadcast began dialling |
broadcast.paused | A broadcast paused (by you, or on insufficient funds) |
broadcast.resumed | A paused broadcast resumed |
broadcast.completed | Every contact in a broadcast is finished |
broadcast.contact.status_changed | A contact in a broadcast changed status |
broadcast.call.started | A broadcast call was placed |
broadcast.call.completed | A broadcast call ended, with its cost breakdown |
dialer.call.started | A dialer campaign placed a call |
dialer.call.answered | The person called answered |
dialer.call.waiting | A contact answered and no agent was free |
dialer.call.connected | An answered contact was put through to an agent |
dialer.call.handed_to_ai | A waiting caller was handed to an AI agent |
dialer.call.abandoned | A waiting caller hung up before anyone took the call |
dialer.call.completed | A dialer call ended, with its result |
dialer.callback.scheduled | A waiting caller accepted a callback |
dialer.campaign.paused | A campaign was paused (insufficient funds, failure rate or budget) |
dialer.campaign.completed | Every contact in a campaign is finished |
dialer.campaign.ratio_changed | A predictive campaign slowed its dialling rate |
dialer.budget.warning | Campaign spend reached its warning level |
whatsapp.event.received | A WhatsApp webhook from Meta, passed on unchanged |
whatsapp.call_permission.updated | A contact granted or denied WhatsApp call permission |
whatsapp.call.provisioned | WhatsApp Business Calling was set up on a number |
whatsapp.call.provisioning_failed | WhatsApp Business Calling could not be set up |
whatsapp.call.deprovisioned | WhatsApp Business Calling was removed from a number |
Wildcards in the events array:
* matches every event in the table.wallet.*, number.*, storage.*, broadcast.*, dialer.* and agent.* match a family.call.* or whatsapp.* wildcard. List the call.recording.*, call.supervision.* and whatsapp.* events you want by name.Call events are sent to the events_url on a number's routing (set it with PUT /v1/numbers/{id}/routing). You cannot subscribe to them as workspace webhooks.
Every call event is a POST with the same flat JSON body. The callSessionState field says where the call is:
| Event | callSessionState | Sent when |
|---|---|---|
call.started | Ringing or Dialing | A call started |
call.answered | Answered or Active | The other party answered |
call.completed | Completed, with status: "Success" | An answered call ended |
call.failed | Completed, with any other status | The call ended without being answered |
call.recording.ready | Completed, with a recordingUrl | A recorded call ended |
Per-number events are POSTed once to your events_url. They are not signed and not retried, and they carry no X-Sautikit-* headers. The headers, signature and retries described below apply to workspace webhooks.
For a call handled by an AI agent, transcript.turn and transcript.delta arrive on the call's event stream, GET /v1/calls/{id}/stream, and there is no webhook for them. An API key with calls.read can read the stream from your server. The saved transcript is on GET /v1/calls/{id}?transcript=1.
Every workspace webhook delivery carries these headers:
| Header | Value |
|---|---|
X-Sautikit-Event-Kind | The event name, for example wallet.low_balance. Route on this |
X-Sautikit-Event-Id | UUID of the event. Deduplicate on this |
X-Sautikit-Delivery-Id | UUID of this delivery |
X-Sautikit-Timestamp | Unix time (seconds) when the delivery was signed |
X-Sautikit-Attempt | Attempt number, starting at 1 |
X-Sautikit-Signature | t=<timestamp>,v1=<hex> (see below) |
The body is flat JSON: the event's own fields are at the top level, with no wrapper object. A wallet.low_balance delivery looks like this:
{
"workspace_id": "01900000-0000-7000-8000-000000000002",
"balance_minor": 4800,
"currency": "KES",
"threshold": 5000,
"trigger": "cron_scan"
}Not every body names its event, so read the event name from the X-Sautikit-Event-Kind header.
import express from "express";
// verifyWebhook is the function from the "Verify webhook signatures" guide.
import { verifyWebhook } from "./verify-webhook.js";
const app = express();
const seen = new Set(); // use a database or cache in production
app.post("/events", express.raw({ type: "application/json" }), (req, res) => {
try {
verifyWebhook(req.body, req.get("X-Sautikit-Signature"), process.env.WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
const eventId = req.get("X-Sautikit-Event-Id");
if (seen.has(eventId)) return res.sendStatus(200);
seen.add(eventId);
const kind = req.get("X-Sautikit-Event-Kind");
const event = JSON.parse(req.body);
if (kind === "wallet.low_balance") {
console.log(`Balance is KES ${event.balance_minor / 100}`);
}
res.sendStatus(200);
});The X-Sautikit-Signature header has the format:
t=<unix_timestamp>,v1=<hex_hmac>
The HMAC is:
HMAC-SHA256(key=signing_secret, message=raw_body + "." + timestamp)
signing_secret is the secret returned when you created the webhook or rotated its secret.raw_body is the exact bytes received. Do not parse and re-encode them.timestamp is the t= value from the same header.Reject a delivery whose timestamp is more than 5 minutes from your server clock, to stop replays.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(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");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error("timestamp too old");
}
const expected = createHmac("sha256", secret)
.update(rawBody)
.update(".")
.update(ts)
.digest("hex");
const a = Buffer.from(v1);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error("signature mismatch");
}
}Verify webhook signatures has the same function in Python, Go and PHP, and the common mistakes.
Respond with any 2xx status within 10 seconds. A response body is not needed. A non-2xx response or a timeout counts as a failed attempt.
A delivery is attempted up to 8 times. After the 8th failed attempt it is marked dead_letter and not tried again.
| After failed attempt | Wait before the next attempt |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours |
| 8 | No further attempts |
Retry a delivery yourself with POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry.
If your workspace is suspended when a delivery is due, the delivery is marked skipped_suspended and is not retried.
const res = await fetch(
`https://api.sautikit.com/v1/webhooks/${webhookId}/deliveries`,
{ headers: { Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}` } }
);A delivery's status is pending, succeeded, failed (waiting for the next attempt), dead_letter or skipped_suspended.
Send yourself a test delivery with POST /v1/webhooks/{id}/test.