Verifying signatures
Check that a webhook delivery came from Tanitar and was not altered, with samples in Node.js, Python and Go.
Every delivery is signed with your endpoint's secret. Verify the signature on every request before
you trust the body: the URL of your endpoint is not a secret, so anyone can POST to it.
The rules
- Read three headers:
webhook-id,webhook-timestampandwebhook-signature. Reject the request if any is missing. - Check freshness.
webhook-timestampis Unix time in seconds. Reject it if it differs from your clock by more than 5 minutes in either direction. This stops a captured request from being replayed later. Each retry is signed afresh with a new timestamp, so a legitimate retry passes. - Derive the key. Remove the
whsec_prefix from the secret and base64url-decode the rest (no padding). The result is 32 bytes. Do not use the secret string itself as the key. - Build the signed content:
<webhook-id>.<webhook-timestamp>.<raw body>. Join the two header values and the body joined with.. Use the body exactly as received, before any JSON parsing or re-serialisation; reformatting a single space breaks the signature. - Compute HMAC-SHA256 of that content with the key, and base64-encode it (standard alphabet, with padding).
- Compare.
webhook-signatureholds one or more space-separated entries of the formv1,<base64>. Accept the request if thev1signature of any entry equals yours. Ignore entries with another version prefix. Compare in constant time.
During a secret rotation the header carries two entries, one per secret, for example:
webhook-signature: v1,fqM1zvDrPfiCYH4CqlC1k+5CYE9qx8TG3bFYaIop8Bc= v1,oFR+dH53Jk3A2ucgB2cyDkc5btd5S1G1uMQ9k0xjtAU=A receiver that holds either the old or the new secret accepts the delivery, so you can switch secrets at any point inside the overlap window without losing a delivery.
Because Tanitar uses the Standard Webhooks header and signing scheme, an off-the-shelf Standard
Webhooks library also works, with one adjustment: those libraries decode the secret as standard
base64, while a Tanitar secret is base64url without padding. Give the library the key bytes from
step 3 (in Node, new Webhook(key, { format: 'raw' })), or the secret converted to standard
base64: keep the whsec_ prefix, replace - with + and _ with / in the rest, and add one
= at the end.
Node.js
The sample takes the raw body as a string. With Express, read it with
express.raw({ type: 'application/json' }) or express.text({ type: 'application/json' }) on the
webhook route, not express.json().
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
/**
* Verifies a Tanitar webhook delivery.
*
* @param secret the endpoint's signing secret, as shown once when you created or rotated it (`whsec_…`)
* @param headers the request headers (lower-case names)
* @param rawBody the request body exactly as received, before any JSON parsing
* @returns true when one of the signatures matches and the timestamp is fresh
*/
export function verifyWebhook(
secret: string,
headers: Record<string, string | undefined>,
rawBody: string,
now: number = Math.floor(Date.now() / 1000),
): boolean {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
// Reject old or future-dated deliveries so a captured request cannot be replayed later.
const sentAt = Number(timestamp);
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_SECONDS) return false;
// The key is the secret without its `whsec_` prefix, base64url-decoded.
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64url');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
// During a secret rotation the header carries one `v1,<signature>` entry per active secret.
return signatures.split(' ').some((entry) => {
const [version, signature] = entry.split(',', 2);
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
Python
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret: str, headers: dict, raw_body: bytes, now: int | None = None) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
return False
try:
sent_at = int(timestamp)
except ValueError:
return False
now = int(time.time()) if now is None else now
if abs(now - sent_at) > TOLERANCE_SECONDS:
return False
# The key is the secret without its `whsec_` prefix, base64url-decoded.
encoded = secret.removeprefix("whsec_")
key = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version != "v1" or not signature:
continue
try:
received = base64.b64decode(signature)
except ValueError:
continue
if hmac.compare_digest(received, expected):
return True
return FalseIn Flask pass request.get_data(); in Django request.body; in FastAPI await request.body().
Go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/http"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 5 * 60
// VerifyWebhook reports whether rawBody was signed by Tanitar with secret (whsec_...).
func VerifyWebhook(secret string, header http.Header, rawBody []byte, now time.Time) bool {
id := header.Get("webhook-id")
timestamp := header.Get("webhook-timestamp")
signatures := header.Get("webhook-signature")
if id == "" || timestamp == "" || signatures == "" {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if diff := now.Unix() - sentAt; diff > toleranceSeconds || diff < -toleranceSeconds {
return false
}
// The key is the secret without its whsec_ prefix, base64url-decoded (no padding).
key, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, entry := range strings.Split(signatures, " ") {
version, signature, found := strings.Cut(entry, ",")
if !found || version != "v1" {
continue
}
received, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
continue
}
if hmac.Equal(received, expected) {
return true
}
}
return false
}Read the body with io.ReadAll(r.Body) before decoding it, and pass time.Now() as now.
Worked check
Run your verifier against these values before you connect it to a live endpoint. The secret is the example secret used on every page of the event catalog; it is not a real key. It is readable text rather than a generated secret, so its last character carries non-zero padding bits. A decoder that enforces canonical base64url refuses it; a verifier built on one can take the key from the Key bytes (hex) row below, and every real secret decodes normally.
| Input | Value |
|---|---|
| Secret | whsec_placeholder_example_webhooks_signing_secret |
| Key bytes (hex) | a6569c7a1a2575eaff7b16a6a657bfc1e6e1a2892cfec8a09e29e0fec79cadeb |
webhook-id | whd_00000000-0000-4000-8000-000000000001 |
webhook-timestamp | 1800000000 |
| Raw body | {"hello":"world"} |
| Signed content | whd_00000000-0000-4000-8000-000000000001.1800000000.{"hello":"world"} |
Expected webhook-signature | v1,azLaaIUOE4G9AfGm6Kb4TSLDhu/B1ILCADc+SmB/Rwo= |
The timestamp is fixed, so pass now equal to 1800000000 (Node and Python) or
time.Unix(1800000000, 0) (Go) to get past the freshness check. Your verifier must return true for
this input, and false if you change one character of the body, the id or the timestamp. Every
example request on the event catalog pages uses the same secret and timestamp, so you can check those
too.
Common mistakes
| Symptom | Cause |
|---|---|
| Every signature fails | The body was parsed and re-serialised, or the secret string was used as the key without removing whsec_ and decoding it. |
| Fails only during a rotation | Only the first v1, entry is checked. Check every entry. |
| Fails only on some deliveries | The body was decoded with a non-UTF-8 charset, or a proxy rewrote it. |
| Old deliveries rejected | The receiver's clock is off by more than 5 minutes. Sync it with NTP. |