TANITARDevelopers
Webhooks

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

  1. Read three headers: webhook-id, webhook-timestamp and webhook-signature. Reject the request if any is missing.
  2. Check freshness. webhook-timestamp is 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.
  3. 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.
  4. 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.
  5. Compute HMAC-SHA256 of that content with the key, and base64-encode it (standard alphabet, with padding).
  6. Compare. webhook-signature holds one or more space-separated entries of the form v1,<base64>. Accept the request if the v1 signature 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 False

In 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.

InputValue
Secretwhsec_placeholder_example_webhooks_signing_secret
Key bytes (hex)a6569c7a1a2575eaff7b16a6a657bfc1e6e1a2892cfec8a09e29e0fec79cadeb
webhook-idwhd_00000000-0000-4000-8000-000000000001
webhook-timestamp1800000000
Raw body{"hello":"world"}
Signed contentwhd_00000000-0000-4000-8000-000000000001.1800000000.{"hello":"world"}
Expected webhook-signaturev1,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

SymptomCause
Every signature failsThe 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 rotationOnly the first v1, entry is checked. Check every entry.
Fails only on some deliveriesThe body was decoded with a non-UTF-8 charset, or a proxy rewrote it.
Old deliveries rejectedThe receiver's clock is off by more than 5 minutes. Sync it with NTP.

On this page