Create a webhook subscription
Mints a signing secret (32 random bytes, hex). The cleartext
secret is returned ONCE in the response and must be stored
client-side immediately — the API only persists sha256(secret)
and the first 8 chars for display.
Delivery contract
Every delivery is a POST <subscription.url> with
Content-Type: application/json. The body is the raw event
payload (see the WebhookEvent discriminator below for the
per-kind schema). The following request headers are emitted
on every delivery:
| Header | Description |
|---|---|
X-Sautikit-Signature | HMAC envelope t=<unix>,v1=<hex> (see "Signature" below). |
X-Sautikit-Timestamp | Unix epoch seconds the signature was minted at — same value as the t= term. |
X-Sautikit-Event-Id | UUID of the originating event. Identical across retries to the same subscription; use this for at-most-once application logic. |
X-Sautikit-Event-Kind | The event kind (e.g. call.completed). Matches the body's kind field. |
X-Sautikit-Delivery-Id | UUID of THIS delivery attempt. Unique per subscription and event. |
X-Sautikit-Attempt | 1-indexed attempt counter. 1 on the first send; increments on each retry. |
User-Agent | Sautikit-Webhooks/1.0 |
Retries follow an exponential schedule: 30s, 2m, 10m, 30m, 2h,
6h, 24h, 7d, then dead-letter. A 2xx response marks the
delivery succeeded; any other response (or a transport error)
triggers the next backoff.
Signature
The signature header is canonically X-Sautikit-Signature with
shape:
X-Sautikit-Signature: t=<unix>,v1=<hex>Where:
t— Unix epoch seconds. MUST be within ±300s of the customer's clock to defeat replay attacks.v1— hex-encoded HMAC-SHA256 over<body> + "." + <t>, keyed by the subscription's signing secret (the cleartext returned on POST/v1/webhooksor POST/v1/webhooks/{id}/rotate-secret).
Customer verification (reject if any check fails):
- Parse
tandv1from the header. - Reject if
abs(now - t) > 300. - Recompute
expected = hmac_sha256(secret, raw_body + "." + t). - Reject if
expected!=v1(constant-time compare).
Python
import hmac, hashlib, timedef verify(secret: str, header: str, raw_body: bytes, max_skew_seconds: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts["t"]) if abs(time.time() - t) > max_skew_seconds: return False mac = hmac.new(secret.encode(), raw_body + b"." + str(t).encode(), hashlib.sha256) return hmac.compare_digest(mac.hexdigest(), parts["v1"])Node.js
const crypto = require("crypto");function verify(secret, header, rawBody, maxSkewSeconds = 300) { const parts = Object.fromEntries( header.split(",").map((p) => p.split("=")), ); const t = parseInt(parts.t, 10); if (Math.abs(Date.now() / 1000 - t) > maxSkewSeconds) return false; const expected = crypto .createHmac("sha256", secret) .update(Buffer.concat([Buffer.from(rawBody), Buffer.from("." + t)])) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(parts.v1), );}Go
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "time")func Verify(secret, rawBody []byte, header string, maxSkew time.Duration) bool { parts := map[string]string{} for _, p := range strings.Split(header, ",") { kv := strings.SplitN(p, "=", 2) if len(kv) == 2 { parts[kv[0]] = kv[1] } } t, err := strconv.ParseInt(parts["t"], 10, 64) if err != nil { return false } if d := time.Since(time.Unix(t, 0)); d > maxSkew || d < -maxSkew { return false } mac := hmac.New(sha256.New, secret) mac.Write(rawBody) mac.Write([]byte(".")) mac.Write([]byte(strconv.FormatInt(t, 10))) want := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(want), []byte(parts["v1"]))}Authorization
bearerAuth Long-lived ES256 JWT minted from the dashboard (https://app.sautikit.com/developers/api-keys). Signed by the
platform keyring. Carries workspace_id and scopes claims;
revoked via the platform deny-list.
In: header
Header Parameters
Optional client-supplied idempotency key. Webhook subscription create is idempotent at the DB level (URL+events uniqueness is NOT enforced — the key is accepted for client-side replay safety and surfaced in logs).
length <= 64Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
curl -X POST "https://example.com/v1/webhooks" \ -H "Content-Type: application/json" \ -d '{ "url": "http://example.com", "events": [ "string" ] }'{ "subscription": { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", "url": "http://example.com", "secret_prefix": "string", "events": [ "string" ], "status": "active", "created_at": "2019-08-24T14:15:22Z", "updated_at": "2019-08-24T14:15:22Z" }, "secret": "string"}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string" }}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string" }}