Verify webhook signatures
Your webhook URL is a public endpoint. Without a signature check, anyone who learns it can post whatever they like, and your system will believe an order was delivered, a customer replied, or a campaign finished.
Every delivery is signed with the secret shown once when the webhook was created.
What we send
Section titled “What we send”X-KM-Timestamp: 1755648000X-KM-Signature: v1=4a3f8c2e91b7d0...The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex encoded.
The timestamp is inside the signed string on purpose. Signing only the body produces a signature that stays valid forever, so anyone who captured one delivery could replay it whenever they liked and your endpoint could not tell. With the timestamp bound in, a replay is detectable by age.
Verifying
Section titled “Verifying”import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verify(rawBody, headers, secret) { const timestamp = headers['x-km-timestamp'] const signature = (headers['x-km-signature'] ?? '').replace(/^v1=/, '') if (!timestamp || !signature) return false
// Reject anything older than five minutes, which is what makes the // timestamp worth signing. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest('hex')
// Constant time: a plain === leaks how much of the signature matched. const a = Buffer.from(signature, 'hex') const b = Buffer.from(expected, 'hex') return a.length === b.length && crypto.timingSafeEqual(a, b)}function verify(string $rawBody, array $headers, string $secret): bool { $timestamp = $headers['X-KM-Timestamp'] ?? ''; $signature = preg_replace('/^v1=/', '', $headers['X-KM-Signature'] ?? ''); if ($timestamp === '' || $signature === '') return false;
if (abs(time() - (int) $timestamp) > 300) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature);}import hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, headers, secret: str) -> bool: timestamp = headers.get("X-KM-Timestamp", "") signature = headers.get("X-KM-Signature", "").removeprefix("v1=") if not timestamp or not signature: return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False
expected = hmac.new( secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256 ).hexdigest()
return hmac.compare_digest(expected, signature)func Verify(rawBody []byte, header http.Header, secret string) bool { timestamp := header.Get("X-KM-Timestamp") signature := strings.TrimPrefix(header.Get("X-KM-Signature"), "v1=") if timestamp == "" || signature == "" { return false }
sent, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-sent)) > 300 { return false }
mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp)) mac.Write([]byte(".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))}The mistake everyone makes once
Section titled “The mistake everyone makes once”Second most common: comparing with ==. Use a constant-time comparison. A
normal string comparison returns faster the earlier it finds a difference,
which leaks enough to forge a signature byte by byte.
Rotating a secret
Section titled “Rotating a secret”PUT /v1/webhooks/{id} with a new secret. It takes effect on the next
delivery, so accept both the old and the new one for a few minutes if you
cannot deploy the change atomically.
The older header
Section titled “The older header”X-Webhook-Signature: sha256=<hex> signs the body alone, without a timestamp.
It is still sent so that consumers already verifying it keep working. New
integrations should use X-KM-Signature: without a timestamp there is no way
to detect a replay.