Signature verification
Every webhook endpoint has a signing secret. Store it in your receiver environment and verify every request before you parse or process the event.
Request headers
Webhook requests include these signature headers:
| Header | Meaning |
|---|---|
svix-id | Stable message identifier. Store it for idempotency. |
svix-timestamp | Unix timestamp in seconds. Reject old timestamps to prevent replay. |
svix-signature | One or more HMAC-SHA256 signatures, formatted as v1,<base64>. |
Some deployments may expose the same values with a webhook- prefix. A robust receiver should accept both names.
Rules
- Verify against the raw request body, not parsed JSON.
- Reject timestamps more than 5 minutes away from your server time.
- Compute
HMAC_SHA256("{id}.{timestamp}.{rawBody}"). - Use the base64 part of the endpoint secret after the
whsec_prefix as the HMAC key. - Compare signatures with a constant-time comparison.
- Store the message ID before doing side effects so retries do not duplicate work.
Node.js receiver
import crypto from "node:crypto";
import express from "express";
const app = express();
const endpointSecret = process.env.SWITERA_WEBHOOK_SECRET;
function header(headers, name) {
return headers[name] ?? headers[name.toLowerCase()];
}
function getWebhookHeaders(headers) {
return {
id: header(headers, "svix-id") ?? header(headers, "webhook-id"),
timestamp: header(headers, "svix-timestamp") ?? header(headers, "webhook-timestamp"),
signature: header(headers, "svix-signature") ?? header(headers, "webhook-signature"),
};
}
function verifyWebhook(rawBody, headers, secret) {
const { id, timestamp, signature } = getWebhookHeaders(headers);
if (!id || !timestamp || !signature || !secret?.startsWith("whsec_")) {
return false;
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
return false;
}
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const signedContent = `${id}.${timestamp}.${rawBody}`;
const expected = crypto.createHmac("sha256", key).update(signedContent).digest();
return signature.split(" ").some((part) => {
const candidate = part.startsWith("v1,") ? part.slice(3) : part;
const received = Buffer.from(candidate, "base64");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
app.post("/webhooks/switera", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyWebhook(rawBody, req.headers, endpointSecret)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(rawBody);
// Store event.id or the svix-id header before running side effects.
console.log("received", event.type);
return res.status(204).send();
});
Go receiver
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func webhookHeader(r *http.Request, svixName, webhookName string) string {
if value := r.Header.Get(svixName); value != "" {
return value
}
return r.Header.Get(webhookName)
}
func VerifyWebhook(body []byte, r *http.Request, secret string) bool {
id := webhookHeader(r, "svix-id", "webhook-id")
timestamp := webhookHeader(r, "svix-timestamp", "webhook-timestamp")
signature := webhookHeader(r, "svix-signature", "webhook-signature")
if id == "" || timestamp == "" || signature == "" || !strings.HasPrefix(secret, "whsec_") {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if delta := time.Since(time.Unix(sentAt, 0)); delta > 5*time.Minute || delta < -5*time.Minute {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
signedContent := fmt.Sprintf("%s.%s.%s", id, timestamp, string(body))
mac := hmac.New(sha256.New, key)
mac.Write([]byte(signedContent))
expected := mac.Sum(nil)
for _, part := range strings.Split(signature, " ") {
candidate := strings.TrimPrefix(part, "v1,")
received, err := base64.StdEncoding.DecodeString(candidate)
if err == nil && hmac.Equal(received, expected) {
return true
}
}
return false
}
Python receiver
import base64
import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
endpoint_secret = os.environ["SWITERA_WEBHOOK_SECRET"]
def header(headers, svix_name, webhook_name):
return headers.get(svix_name) or headers.get(webhook_name)
def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
msg_id = header(headers, "svix-id", "webhook-id")
timestamp = header(headers, "svix-timestamp", "webhook-timestamp")
signature = header(headers, "svix-signature", "webhook-signature")
if not msg_id or not timestamp or not signature or not secret.startswith("whsec_"):
return False
if abs(int(time.time()) - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = b".".join([msg_id.encode(), timestamp.encode(), raw_body])
expected = hmac.new(key, signed_content, hashlib.sha256).digest()
for part in signature.split(" "):
candidate = part.removeprefix("v1,")
try:
received = base64.b64decode(candidate)
except Exception:
continue
if hmac.compare_digest(received, expected):
return True
return False
@app.post("/webhooks/switera")
def switera_webhook():
raw_body = request.get_data()
if not verify_webhook(raw_body, request.headers, endpoint_secret):
return "invalid signature", 401
event = request.get_json()
print("received", event["type"])
return "", 204
Receiver acceptance checklist
- A bad signature returns
401. - A replayed request older than 5 minutes returns
401. - The raw body is logged only in development and never contains secrets.
- Duplicate message IDs do not create duplicate records.
- Unknown event types are safe no-ops or explicit failures.
Related pages: