Skip to main content
Every webhook delivery carries:
X-Webhook-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>
The signature is computed as:
HMAC_SHA256( signing_secret, "{unix_timestamp}.{raw_body}" )
Where raw_body is the bytes of the request body as received, not a re-serialized JSON string.

Verification steps

  1. Parse t= and v1= from the X-Webhook-Signature header.
  2. Reject the request if |now - t| > 300 seconds (anti-replay window of ±5 minutes).
  3. Recompute the HMAC with your stored signing secret over the string "{t}.{raw_body}".
  4. Compare the recomputed signature with v1 using a constant-time equality function.
If any step fails, return 400 Bad Request and do not process the payload.

Node.js

import crypto from 'crypto';

export function verifyWebhook(
  signatureHeader: string,
  rawBody: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('=') as [string, string]),
  );
  const ts = Number(parts.t);
  const v1 = parts.v1;
  if (!ts || !v1) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
In Express, use express.raw({ type: 'application/json' }) on the webhook route to get the raw body bytes before JSON parsing. Calling JSON.parse then JSON.stringify re-serializes the payload and breaks the signature.

Python

import hmac
import hashlib
import time

def verify_webhook(
    signature_header: str,
    raw_body: bytes,
    secret: str,
    tolerance_seconds: int = 300,
) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    ts = int(parts.get("t", 0))
    v1 = parts.get("v1", "")

    if abs(int(time.time()) - ts) > tolerance_seconds:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{ts}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, v1)

Kotlin

import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

fun verifyWebhook(
    signatureHeader: String,
    rawBody: ByteArray,
    secret: String,
    toleranceSeconds: Long = 300,
): Boolean {
    val parts = signatureHeader.split(",").associate {
        val (k, v) = it.split("=", limit = 2)
        k to v
    }
    val ts = parts["t"]?.toLongOrNull() ?: return false
    val v1 = parts["v1"] ?: return false

    val now = System.currentTimeMillis() / 1000
    if (kotlin.math.abs(now - ts) > toleranceSeconds) return false

    val mac = Mac.getInstance("HmacSHA256").apply {
        init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
    }
    val signed = "$ts.".toByteArray() + rawBody
    val expected = mac.doFinal(signed).joinToString("") { "%02x".format(it) }

    return java.security.MessageDigest.isEqual(
        expected.toByteArray(),
        v1.toByteArray(),
    )
}

Go

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "strconv"
    "strings"
    "time"
)

func Verify(signatureHeader string, rawBody []byte, secret string, tolerance time.Duration) bool {
    parts := map[string]string{}
    for _, p := range strings.Split(signatureHeader, ",") {
        kv := strings.SplitN(p, "=", 2)
        if len(kv) == 2 {
            parts[kv[0]] = kv[1]
        }
    }
    ts, err := strconv.ParseInt(parts["t"], 10, 64)
    if err != nil {
        return false
    }
    if d := time.Since(time.Unix(ts, 0)); d > tolerance || -d > tolerance {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%d.", ts)
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}

Common mistakes

  • Re-serializing JSON. Sign the raw bytes as received, not a pretty-printed or re-normalized JSON string. Middleware in Express, Django, or Spring often re-parses the body; use a raw-body reader specifically on the webhook route.
  • Plain string comparison. Use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, MessageDigest.isEqual, hmac.Equal). Plain == leaks timing information that can be exploited.
  • Skipping the timestamp check. Without the ±5 min check, a captured valid request can be replayed indefinitely by an attacker.
  • Trimming or decoding the header. Pass the X-Webhook-Signature header value verbatim — do not URL-decode or trim whitespace before splitting on ,.