Skip to main content

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:

HeaderMeaning
svix-idStable message identifier. Store it for idempotency.
svix-timestampUnix timestamp in seconds. Reject old timestamps to prevent replay.
svix-signatureOne 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: