Webhooks
Sautikit webhook delivery semantics: at-least-once, retry schedule, signature verification, and event types.
Sautikit webhooks deliver signed JSON payloads to your HTTPS endpoint when platform events occur. Delivery is at-least-once: the same event may arrive more than once and you should deduplicate by event_id. The dispatcher retries up to 8 times with exponential backoff before moving the delivery to dead_letter.
There are two delivery paths:
Workspace webhooks: subscribe via POST /v1/webhooks. Receive account-level events:
| Event | Trigger |
|---|---|
number.provisioned | A number was claimed to your workspace |
number.released | A number was released from your workspace |
wallet.top_up | A wallet top-up settled successfully |
wallet.low_balance | Wallet balance dropped below your configured threshold |
storage.tier_changed | Your storage tier changed |
Per-number events: configure events_url on the number's routing. Receive call lifecycle events:
| Event | Trigger |
|---|---|
call.started | Call leg created in the platform |
call.answered | Remote party answered |
call.completed | Call ended normally |
call.failed | Call ended with an error |
call.recording.ready | Recording file is available |
Call lifecycle events are not subscribable as workspace webhooks. Configure them via the Routing tab on your number.
The dispatcher guarantees that a matching delivery row will be attempted at least once. Network conditions or retries may cause duplicate deliveries. Always deduplicate using the event_id field in the payload body or the X-Sautikit-Event-Id header.
Your endpoint must respond with any 2xx status within 10 seconds. A response body is not required. A non-2xx response or a connection timeout both count as a failed attempt.
Failed attempts are retried with exponential backoff. After 8 failed attempts the delivery row moves to dead_letter status and is not retried again.
| Attempt | Wait before next retry |
|---|---|
| 1 (first failure) | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours |
| 8 | 7 days |
| 9+ | dead_letter (no further attempts) |
You can manually trigger a retry via POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry.
If your workspace is suspended at delivery time, the row is moved to skipped_suspended (a terminal state) and is not retried. Re-enable your workspace and re-trigger events manually if needed.
Every delivery includes these headers:
| Header | Value |
|---|---|
X-Sautikit-Event-Id | UUID of the source event (use for deduplication) |
X-Sautikit-Event-Kind | Literal event name, e.g. call.completed |
X-Sautikit-Delivery-Id | UUID of this specific delivery attempt |
X-Sautikit-Timestamp | Unix timestamp (seconds) when the delivery was signed |
X-Sautikit-Attempt | Attempt number, starting at 1 |
X-Sautikit-Signature | HMAC-SHA256 signature (see below) |
The X-Sautikit-Signature header has the format:
t=<unix_timestamp>,v1=<hex_hmac>
The HMAC is computed as:
HMAC-SHA256(key=signing_secret, message=raw_body + "." + timestamp)
Where:
signing_secret is the secret returned when you created or rotated the webhook subscription.raw_body is the exact bytes received (do not parse/re-encode).timestamp is the value of the t= field from the same header.Always verify the timestamp is within a few minutes of your server clock to prevent replay attacks.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
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");
// Reject stale timestamps (5-minute tolerance)
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");
if (!timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
throw new Error("signature mismatch");
}
}Python
import hashlib
import hmac
import time
def verify_webhook(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")
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")Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"math"
"strconv"
"strings"
"time"
)
func verifyWebhook(rawBody []byte, sigHeader, secret string) error {
parts := map[string]string{}
for _, p := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
ts, v1 := parts["t"], parts["v1"]
if ts == "" || v1 == "" {
return errors.New("missing t or v1")
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-tsInt)) > 300 {
return errors.New("timestamp too old or invalid")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
mac.Write([]byte("."))
mac.Write([]byte(ts))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(v1), []byte(expected)) {
return errors.New("signature mismatch")
}
return nil
}PHP
function verifyWebhook(string $rawBody, string $sigHeader, string $secret): void {
$parts = [];
foreach (explode(',', $sigHeader) as $p) {
[$k, $v] = explode('=', $p, 2);
$parts[trim($k)] = trim($v);
}
$ts = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if (!$ts || !$v1) {
throw new \InvalidArgumentException('missing t or v1');
}
if (abs(time() - (int)$ts) > 300) {
throw new \InvalidArgumentException('timestamp too old');
}
$expected = hash_hmac('sha256', $rawBody . '.' . $ts, $secret);
if (!hash_equals($v1, $expected)) {
throw new \InvalidArgumentException('signature mismatch');
}
}When creating a webhook subscription you may use:
*: subscribe to all workspace webhook eventswallet.*: subscribe to all wallet eventsnumber.*: subscribe to all number eventsstorage.*: subscribe to all storage eventsCall lifecycle events (call.*) are excluded from workspace webhook subscriptions; route them per-number via events_url.
View recent deliveries and their status via:
curl "https://api.sautikit.com/v1/webhooks/{subscription_id}/deliveries" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY"Delivery statuses: pending → succeeded or failed → dead_letter.