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
- Parse
t= and v1= from the X-Webhook-Signature header.
- Reject the request if
|now - t| > 300 seconds (anti-replay window of ±5 minutes).
- Recompute the HMAC with your stored signing secret over the string
"{t}.{raw_body}".
- 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(),
)
}
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 ,.