Verify Webhook Signatures
Verify Sautikit HMAC-SHA256 webhook signatures in Node, Python, Go, and PHP. Covers raw body, header casing, and replay window.
Every Sautikit webhook delivery is signed with HMAC-SHA256. The signature appears in the X-Sautikit-Signature header as t=<unix_timestamp>,v1=<hex_hmac>. You verify it by re-computing the HMAC over raw_body + "." + timestamp using your webhook subscription secret. This guide provides working verifier code in Node.js, Python, Go, and PHP, plus a list of common pitfalls.
curl -s -X POST "https://api.sautikit.com/v1/webhooks" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-server.example.com/events", "events": ["*"]}' | jq .The response includes secret; store it now. It is shown only once.
export WEBHOOK_SECRET="whsec_..."The X-Sautikit-Signature header looks like:
t=1751000000,v1=a3f2c7b8d94e1f25368ab0c6d7e8f9a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7
| Part | Meaning |
|---|---|
t | Unix timestamp (seconds) when the delivery was signed |
v1 | Hex-encoded HMAC-SHA256 of the signed message |
The HMAC message is constructed as:
HMAC-SHA256(
key = webhook_signing_secret,
message = raw_request_body + "." + t_value
)
Written out step by step:
. (dot).t timestamp string from the header.v1 value using a constant-time comparison function.import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Verifies a Sautikit webhook signature.
*
* @param {Buffer|string} rawBody - The raw request body bytes, before any JSON.parse.
* @param {string} sigHeader - The X-Sautikit-Signature header value.
* @param {string} secret - Your webhook subscription signing secret.
* @throws {Error} if the signature is invalid or the timestamp is too old.
*/
function verifyWebhook(rawBody, sigHeader, secret) {
// 1. Parse the header
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("X-Sautikit-Signature missing t or v1");
// 2. Replay attack prevention: reject timestamps older than 5 minutes
const drift = Math.abs(Date.now() / 1000 - Number(ts));
if (drift > 300) throw new Error(`timestamp too old (drift: ${Math.round(drift)}s)`);
// 3. Compute expected HMAC
const expected = createHmac("sha256", secret)
.update(rawBody) // Buffer, not a parsed object
.update(".")
.update(ts)
.digest("hex");
// 4. Constant-time comparison
const vBuf = Buffer.from(v1, "utf8");
const eBuf = Buffer.from(expected, "utf8");
if (vBuf.length !== eBuf.length || !timingSafeEqual(vBuf, eBuf)) {
throw new Error("webhook signature mismatch");
}
}import express from "express";
const app = express();
// IMPORTANT: use express.raw() so req.body is a Buffer, not a parsed object
app.post("/events", express.raw({ type: "application/json" }), (req, res) => {
try {
verifyWebhook(
req.body,
req.headers["x-sautikit-signature"] ?? "",
process.env.WEBHOOK_SECRET
);
} catch (err) {
console.error("signature verification failed:", err.message);
return res.status(401).json({ error: "invalid signature" });
}
const event = JSON.parse(req.body);
console.log(event.event_kind, event.event_id);
res.sendStatus(200);
});import hashlib
import hmac
import time
from typing import Union
def verify_webhook(raw_body: bytes, sig_header: str, secret: str) -> None:
"""
Verify a Sautikit webhook signature.
Args:
raw_body: The raw request body bytes, read before any json.loads().
sig_header: The X-Sautikit-Signature header value.
secret: Your webhook subscription signing secret.
Raises:
ValueError: if the signature is invalid or the timestamp is too old.
"""
# 1. Parse the header
try:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
except ValueError:
raise ValueError("malformed X-Sautikit-Signature header")
ts = parts.get("t")
v1 = parts.get("v1")
if not ts or not v1:
raise ValueError("X-Sautikit-Signature missing t or v1")
# 2. Replay protection: reject timestamps older than 5 minutes
drift = abs(time.time() - float(ts))
if drift > 300:
raise ValueError(f"timestamp too old (drift: {drift:.0f}s)")
# 3. Compute expected HMAC
message = raw_body + b"." + ts.encode("utf-8")
expected = hmac.new(
secret.encode("utf-8"), message, hashlib.sha256
).hexdigest()
# 4. Constant-time comparison
if not hmac.compare_digest(v1, expected):
raise ValueError("webhook signature mismatch")from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/events")
async def receive_event(request: Request):
raw_body = await request.body() # bytes, read before .json()
sig_header = request.headers.get("x-sautikit-signature", "")
try:
verify_webhook(raw_body, sig_header, os.environ["WEBHOOK_SECRET"])
except ValueError as exc:
raise HTTPException(status_code=401, detail=str(exc))
event = await request.json()
print(event["event_kind"], event["event_id"])
return {"ok": True}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// Verify checks the X-Sautikit-Signature header against the raw body.
// rawBody must be the exact bytes received; do not parse or re-encode.
// sigHeader is the value of the X-Sautikit-Signature header.
// secret is the webhook subscription signing secret.
func Verify(rawBody []byte, sigHeader, secret string) error {
// 1. Parse the header
parts := make(map[string]string)
for _, seg := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(strings.TrimSpace(seg), "=", 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("webhook: missing t or v1 in signature header")
}
// 2. Replay protection
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return fmt.Errorf("webhook: invalid timestamp: %w", err)
}
drift := math.Abs(float64(time.Now().Unix() - tsInt))
if drift > 300 {
return fmt.Errorf("webhook: timestamp too old (drift %.0fs)", drift)
}
// 3. Compute expected HMAC
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
mac.Write([]byte("."))
mac.Write([]byte(ts))
expected := hex.EncodeToString(mac.Sum(nil))
// 4. Constant-time comparison
if !hmac.Equal([]byte(v1), []byte(expected)) {
return errors.New("webhook: signature mismatch")
}
return nil
}func handleEvents(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
if err := webhook.Verify(rawBody, r.Header.Get("X-Sautikit-Signature"), os.Getenv("WEBHOOK_SECRET")); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
var event map[string]any
if err := json.Unmarshal(rawBody, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
log.Printf("event_kind=%s event_id=%s", event["event_kind"], event["event_id"])
w.WriteHeader(http.StatusOK)
}<?php
/**
* Verify a Sautikit webhook signature.
*
* @param string $rawBody Raw request body, read before json_decode().
* @param string $sigHeader The X-Sautikit-Signature header value.
* @param string $secret Your webhook subscription signing secret.
* @throws \InvalidArgumentException if the signature is invalid.
*/
function verifySautikitWebhook(string $rawBody, string $sigHeader, string $secret): void
{
// 1. Parse the header
$parts = [];
foreach (explode(',', $sigHeader) as $seg) {
$kv = explode('=', trim($seg), 2);
if (count($kv) === 2) {
$parts[trim($kv[0])] = trim($kv[1]);
}
}
$ts = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if (!$ts || !$v1) {
throw new \InvalidArgumentException('X-Sautikit-Signature missing t or v1');
}
// 2. Replay protection
$drift = abs(time() - (int) $ts);
if ($drift > 300) {
throw new \InvalidArgumentException("Timestamp too old (drift: {$drift}s)");
}
// 3. Compute expected HMAC
$message = $rawBody . '.' . $ts;
$expected = hash_hmac('sha256', $message, $secret);
// 4. Constant-time comparison
if (!hash_equals($expected, $v1)) {
throw new \InvalidArgumentException('Webhook signature mismatch');
}
}
// Usage
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_X_SAUTIKIT_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
try {
verifySautikitWebhook($rawBody, $sigHeader, $secret);
$event = json_decode($rawBody, true);
error_log("event_kind: {$event['event_kind']}");
http_response_code(200);
} catch (\InvalidArgumentException $e) {
http_response_code(401);
echo json_encode(['error' => $e->getMessage()]);
}The HMAC is over the raw bytes received on the wire. If your framework parses the JSON body (or re-encodes it) before you read it, the bytes will differ from what Sautikit signed and verification will always fail.
express.raw({ type: "application/json" }) on the webhook route, not express.json().await request.body() before await request.json().file_get_contents("php://input"), not $_POST.r.Body before any JSON unmarshalling.HTTP headers are case-insensitive by spec, but your framework may normalise them differently. Always look up the header by lowercase name or use your framework's case-insensitive accessor.
req.headers keys are already lowercase.r.Header.Get("X-Sautikit-Signature") is case-insensitive.$_SERVER converts hyphens to underscores and uppercases: HTTP_X_SAUTIKIT_SIGNATURE.Omitting the timestamp verification exposes your endpoint to replay attacks. An attacker who captures a valid delivery can re-send it repeatedly. Always reject deliveries with a timestamp older than 5 minutes (300 seconds).
A regular === or == string comparison short-circuits on the first differing byte, enabling timing attacks. Always use timingSafeEqual (Node.js), hmac.compare_digest (Python), hmac.Equal (Go), or hash_equals (PHP).
The signing secret is per-subscription. If you have multiple webhook subscriptions, use the secret that corresponds to the subscription receiving the delivery. The X-Sautikit-Delivery-Id header identifies the delivery, which you can look up to find the subscription ID.
Send a test delivery from the dashboard or via:
curl -s -X POST \
"https://api.sautikit.com/v1/webhooks/$SUBSCRIPTION_ID/test" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .Your endpoint should log the event_kind and return 200. If verification fails, check: