sautikit
PricingDevelopersBlogAbout
Sign inStart building
Guides
  • Browser Calling with WebRTC
  • Build a Call Center with Conferences
  • Build a Voice IVR
  • Claim and Route a Number
  • Quickstart: Place a Call
  • Record and Stream to S3
  • Verify Webhook Signatures
Concepts
  • Browser Calling with WebRTC
  • Calls
  • Phone Numbers
  • Voice Actions DSL
  • Wallet and Billing
  • Webhooks
  • Workspaces
Voice Actions
  • Conference
  • Dial
  • GetDigits
  • Hangup
  • Play
  • Record
  • Redirect
  • Reject
  • Say
Webhooks
  • call.answered
  • call.completed
  • call.failed
  • call.recording.ready
  • call.started
  • number.provisioned
  • number.released
  • storage.tier_changed
  • wallet.low_balance
  • wallet.top_up
SDKs
  • Go SDK
  • Node.js SDK
  • PHP SDK
  • Python SDK
Errors
  • account.suspended
  • accounts.admin_required
  • accounts.last_owner
  • accounts.remove_denied
  • accounts.role_change_denied
  • accounts.write_denied
  • api_key.already_revoked
  • api_key.expired
  • api_key.invalid
  • api_key.name_required
  • api_key.not_found
  • api_key.revoked
  • api_key.scope_denied
  • api_key.scope_invalid
  • audit.invalid_action_kind
  • auth.admin_required
  • auth.cannot_unlink_only_auth
  • auth.email_taken
  • auth.google_exchange_failed
  • auth.google_not_configured
  • auth.google_state_mismatch
  • auth.invite_email_mismatch
  • auth.no_workspace
  • auth.session_invalid
  • auth.token_expired
  • auth.token_invalid
  • auth.token_used
  • auth.workspace_forbidden
  • calls.events_failed
  • calls.get_failed
  • calls.list_failed
  • calls.not_found
  • calls.recording_expired
  • calls.recording_failed
  • calls.recording_not_found
  • calls.stats_failed
  • currency.unsupported
  • idempotency.conflict
  • internal_error
  • invitations.not_found
  • invite_requires_login
  • method_not_allowed
  • mpesa.unavailable
  • not_found
  • numbers.already_claimed
  • numbers.invalid_routing_url
  • numbers.not_assigned
  • numbers.not_found
  • numbers.price_missing
  • numbers.retired
  • numbers.series_invalid
  • numbers.series_not_active
  • numbers.sip_config_unavailable
  • numbers.sip_credentials.already_revoked
  • numbers.sip_credentials.not_found
  • numbers.sip_token_unavailable
  • numbers.suffix_collision
  • numbers.wallet_unavailable
  • paystack.unavailable
  • pbx.body_read_failed
  • pbx.ingest_failed
  • pbx.payload_decode_failed
  • pbx.resolve_failed
  • pbx.unknown_workspace
  • provider.has_active_series
  • provider.invalid_credentials
  • storage.tier_invalid
  • storage.tier_switch_failed
  • topup.not_found
  • validation.bad_request
  • validation.invalid_cursor
  • wallet.adjustment_invalid_kind
  • wallet.alert_subscriber_exists
  • wallet.alert_subscriber_invalid_email
  • wallet.alert_subscriber_not_found
  • wallet.alert_subscribers_max
  • wallet.currency_mismatch
  • wallet.insufficient_funds
  • wallet.invalid_amount
  • wallet.not_found
  • wallet.topup_below_minimum
  • webhooks.create_failed
  • webhooks.delete_failed
  • webhooks.deliver.secret_unset
  • webhooks.delivery_not_retryable
  • webhooks.dispatcher_unavailable
  • webhooks.event_unsupported
  • webhooks.list_failed
  • webhooks.not_found
  • webhooks.patch_failed
  • webhooks.rotate_secret_failed
Changelog
  • 2026-06-15: Public Pricing Endpoint
  • 2026-06-20: WebRTC SIP Token (HS256 5-min TTL + Key Rotation)
  • 2026-06-27: sautikit.com Launch
  • Inbound voice is now free; outbound drops to KES 3/min

Webhooks

Sautikit webhook delivery semantics: at-least-once, retry schedule, signature verification, and event types.

2026-06-27

Next Steps

  • Verify Webhook SignaturesSautikit signs every webhook delivery with HMAC-SHA256. The signature header format is t=<timestamp>,v1=<hex>. Verify using raw body bytes concatenated with a dot and the timestamp.
sautikit

Programmable voice infrastructure for Africa. Buy numbers, place calls, and bill per second, all in KES, via API.

Product

NumbersCalls & routingRecordingsWallet & billingPricing

Developers

DocumentationAPI referenceQuickstartAI promptChangelog

Company

AboutBlogCareersConsole

© 2026 Sautikit. All rights reserved.

Sautikit provides voice API services for application developers. Numbers provisioned on this platform are not configured for emergency calling (e.g. 999 / 112). Do not use Sautikit numbers as a replacement for a primary phone line.

Summary

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.

Event types

There are two delivery paths:

Workspace webhooks: subscribe via POST /v1/webhooks. Receive account-level events:

EventTrigger
number.provisionedA number was claimed to your workspace
number.releasedA number was released from your workspace
wallet.top_upA wallet top-up settled successfully
wallet.low_balanceWallet balance dropped below your configured threshold
storage.tier_changedYour storage tier changed

Per-number events: configure events_url on the number's routing. Receive call lifecycle events:

EventTrigger
call.startedCall leg created in the platform
call.answeredRemote party answered
call.completedCall ended normally
call.failedCall ended with an error
call.recording.readyRecording file is available

Call lifecycle events are not subscribable as workspace webhooks. Configure them via the Routing tab on your number.

Delivery semantics

At-least-once

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.

Timeout

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.

Retry schedule

Failed attempts are retried with exponential backoff. After 8 failed attempts the delivery row moves to dead_letter status and is not retried again.

AttemptWait before next retry
1 (first failure)30 seconds
22 minutes
310 minutes
430 minutes
52 hours
66 hours
724 hours
87 days
9+dead_letter (no further attempts)

You can manually trigger a retry via POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry.

Suspension behaviour

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.

Request headers

Every delivery includes these headers:

HeaderValue
X-Sautikit-Event-IdUUID of the source event (use for deduplication)
X-Sautikit-Event-KindLiteral event name, e.g. call.completed
X-Sautikit-Delivery-IdUUID of this specific delivery attempt
X-Sautikit-TimestampUnix timestamp (seconds) when the delivery was signed
X-Sautikit-AttemptAttempt number, starting at 1
X-Sautikit-SignatureHMAC-SHA256 signature (see below)

Signature verification

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.

Verification examples

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');
    }
}

Wildcard subscriptions

When creating a webhook subscription you may use:

  • *: subscribe to all workspace webhook events
  • wallet.*: subscribe to all wallet events
  • number.*: subscribe to all number events
  • storage.*: subscribe to all storage events

Call lifecycle events (call.*) are excluded from workspace webhook subscriptions; route them per-number via events_url.

Monitoring deliveries

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.

Next steps

  • Verify webhook signatures (guide)
  • Webhooks API reference
  • Call event types