Every webhook delivery is signed with the endpoint’s signing secret using HMAC-SHA256. You must
verify the signature before processing any event to prevent spoofed requests.
Signature scheme
The signature is computed over:
{webhook_id}.{timestamp}.{body}
Where:
webhook_id is the Webhook-Id header
timestamp is the Webhook-Timestamp header (Unix seconds)
body is the raw request body (not parsed JSON)
Verification steps
Extract headers
Read Webhook-Id, Webhook-Timestamp, and Webhook-Signature from the request headers.
Check timestamp
Reject if Webhook-Timestamp is more than 5 minutes old to prevent replay attacks.
Compute expected signature
Build the signed content string and HMAC-SHA256 it with your secret (base64-decoded,
without the whsec_ prefix).
Compare signatures
Use constant-time comparison to match the expected signature against the Webhook-Signature
header. The header may contain multiple signatures separated by spaces — match against any one.
Examples
import crypto from "crypto";
function verifyWebhook(
payload: string,
headers: Record<string, string>,
secret: string, // "whsec_..."
): boolean {
const msgId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!msgId || !timestamp || !signatures) return false;
// Reject stale timestamps (5-minute window)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) return false;
// Decode the secret (strip whsec_ prefix, base64-decode)
const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
// Compute expected signature
const signedContent = `${msgId}.${timestamp}.${payload}`;
const expected = crypto
.createHmac("sha256", secretBytes)
.update(signedContent)
.digest("base64");
// Compare against any signature in the header
const expectedSig = `v1,${expected}`;
return signatures
.split(" ")
.some(
(sig) =>
sig.length === expectedSig.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig)),
);
}
import hmac
import hashlib
import base64
import time
def verify_webhook(
payload: bytes,
headers: dict,
secret: str, # "whsec_..."
) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not all([msg_id, timestamp, signatures]):
return False
# Reject stale timestamps
if abs(time.time() - int(timestamp)) > 300:
return False
# Decode secret
secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))
# Compute expected
signed_content = f"{msg_id}.{timestamp}.{payload.decode()}"
expected = base64.b64encode(
hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
).decode()
expected_sig = f"v1,{expected}"
return any(
hmac.compare_digest(sig, expected_sig)
for sig in signatures.split(" ")
)
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"math"
"strings"
"strconv"
"time"
)
func VerifyWebhook(payload []byte, headers map[string]string, secret string) bool {
msgID := headers["webhook-id"]
timestamp := headers["webhook-timestamp"]
signatures := headers["webhook-signature"]
if msgID == "" || timestamp == "" || signatures == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
secretBytes, err := base64.StdEncoding.DecodeString(
strings.TrimPrefix(secret, "whsec_"),
)
if err != nil {
return false
}
signedContent := msgID + "." + timestamp + "." + string(payload)
mac := hmac.New(sha256.New, secretBytes)
mac.Write([]byte(signedContent))
expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, sig := range strings.Split(signatures, " ") {
if hmac.Equal([]byte(sig), []byte(expected)) {
return true
}
}
return false
}
Express.js middleware example
import express from "express";
app.post("/webhooks/agg", express.raw({ type: "application/json" }), (req, res) => {
const isValid = verifyWebhook(
req.body.toString(),
req.headers as Record<string, string>,
process.env.AGG_WEBHOOK_SECRET!,
);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString());
console.log(`Received ${event.type}:`, event.data);
// Process the event...
res.status(200).json({ received: true });
});
Always use express.raw() or equivalent to get the raw body. Parsing the body as JSON first
and re-stringifying can change whitespace and break signature verification.