When you receive webhook notifications from BotSubscription, you must verify their authenticity and integrity before processing any payload or database side effects. This ensures that the requests originate from our secure servers and have not been tampered with in transit.
When integrating webhooks to synchronize events with your own custom backend or database, verifying signatures is a critical requirement for protecting your system against spoofing and replay attacks.
When you create or inspect a webhook endpoint in your dashboard, you are issued a 64-character hexadecimal Signing Secret (e.g., 64_character_hex_string_here...).
Keep this secret secure. Do not commit it to version control or expose it on client-side code. Treat it like an API key.
Concatenate the timestamp t, a literal period ., and the exact raw HTTP request body bytes:
<timestamp_value>.<raw_http_request_body>
Warning
Use raw bytes: You must read the request body in its raw, unparsed form. Standard web application frameworks often parse JSON into objects automatically, which changes the whitespace, key order, or byte representation. Using parsed or re-serialized JSON will cause signature validation to fail.
Extract the Header: Parse the X-Webhook-Signature header to extract the values of t and v1.
Compute the Local Signature: Using the secret and the raw payload string <t>.<raw_body>, compute the HMAC-SHA256 signature.
Perform a Safe Comparison: Compare your computed signature with the v1 value using a constant-time comparison helper. This prevents timing analysis attacks.
Warning
Do not enforce a tight timestamp window. The t value is set once when the event is first prepared for delivery and does not change across retries. Because retries can stretch over roughly 3 days, a strict "timestamp must be within N seconds of now" rule will reject legitimate retried deliveries. Treat the signature itself as the authenticity proof, use t only for logging or very generous staleness bounds (wider than the full retry window), and rely on the envelope id for replay protection — see Deduplication & Retries.
Use this purely client-side sandbox to verify your signature computation logic or to generate valid signatures for manual testing. None of your input data or secrets are sent to any server.
Your webhook endpoint's secret key. Stays entirely local in your browser.
Unix epoch timestamp in seconds representing when the signature was created.
Paste the full X-Webhook-Signature header value from the received request.
The exact raw HTTP request body string. Make sure there are no modified whitespaces.
Verification Result
Fill out the signing secret, signature header, and JSON payload above to run verification.
Signature Verified Successfully!
The computed HMAC signature matches the received signature perfectly. This request is verified, authentic, and untampered.
Signature Verification Failed
The computed signature does not match the signature provided in the header. Please verify your secret key, timestamp, and raw payload.
Timestamp Drift Alert (Replay Vulnerability)
The signature is cryptographically valid, but the timestamp differs by more than 5 minutes (300s) from current time. In production, your server should reject this request to prevent replay attacks.
Generated Signature Header
t=...,v1=...
Inject this exact value as the X-Webhook-Signature header in your test request.
Select your programming language to view a complete, copy-pasteable example of HMAC-SHA256 signature verification following the flow detailed above.
import crypto from 'crypto';/** * Verify Webhook Signature * @param {string} rawBody - Raw unparsed HTTP request body string * @param {string} signatureHeader - Value of X-Webhook-Signature header * @param {string} secret - Outbound signing secret from your project dashboard * @returns {boolean} True if signature matches and request is authentic */function verifyWebhook(rawBody, signatureHeader, secret) { // 1. Parse signature header const parts = signatureHeader.split(',').reduce((acc, part) => { const [key, val] = part.split('='); if (key && val) acc[key] = val; return acc; }, {}); const timestamp = parts.t; const signature = parts.v1; if (!timestamp || !signature) { throw new Error('Missing timestamp or signature'); } // 2. Optional staleness bound. `t` is fixed at preparation and does NOT change // across retries (which span ~3 days), so use a window wider than the full // retry window and rely on the envelope `id` for replay protection. const maxAge = 3 * 24 * 60 * 60; // ~3 days (longest plausible retry window) const now = Math.floor(Date.now() / 1000); if (now - parseInt(timestamp, 10) > maxAge) { throw new Error('Event older than the retry window'); } // 3. Construct string to sign const stringToSign = `${timestamp}.${rawBody}`; // 4. Compute the expected HMAC-SHA256 signature const expectedSignature = crypto .createHmac('sha256', secret) .update(stringToSign, 'utf8') .digest('hex'); // 5. Compare using a constant-time check const isMatch = crypto.timingSafeEqual( Buffer.from(signature, 'utf8'), Buffer.from(expectedSignature, 'utf8') ); return isMatch;}
import hmacimport hashlibimport timedef verify_webhook(raw_body: str, signature_header: str, secret: str) -> bool: """ Verify Webhook Signature :param raw_body: Raw unparsed HTTP request body string :param signature_header: Value of X-Webhook-Signature header :param secret: Outbound signing secret from your project dashboard :return: True if signature matches and request is authentic """ # 1. Parse signature header parts = {} for part in signature_header.split(','): if '=' in part: k, v = part.split('=', 1) parts[k.strip()] = v.strip() timestamp = parts.get('t') signature = parts.get('v1') if not timestamp or not signature: raise ValueError("Missing timestamp or signature in header") # 2. Optional staleness bound. `t` is fixed at preparation and does NOT change # across retries (which span ~3 days), so use a window wider than the full # retry window and rely on the envelope `id` for replay protection. max_age = 3 * 24 * 60 * 60 # ~3 days (longest plausible retry window) now = int(time.time()) if now - int(timestamp) > max_age: raise ValueError("Event older than the retry window") # 3. Construct payload string string_to_sign = f"{timestamp}.{raw_body}".encode('utf-8') # 4. Compute the expected HMAC-SHA256 expected_signature = hmac.new( secret.encode('utf-8'), string_to_sign, hashlib.sha256 ).hexdigest() # 5. Compare using constant-time safe comparison return hmac.compare_digest(signature, expected_signature)
<?php/** * Verify Webhook Signature * @param string $rawBody Raw unparsed HTTP request body string * @param string $signatureHeader Value of X-Webhook-Signature header * @param string $secret Outbound signing secret from your project dashboard * @return bool True if signature matches and request is authentic */function verifyWebhook($rawBody, $signatureHeader, $secret) { // 1. Parse signature header $parts = []; foreach (explode(',', $signatureHeader) as $part) { $keyValue = explode('=', $part, 2); if (count($keyValue) === 2) { $parts[trim($keyValue[0])] = trim($keyValue[1]); } } $timestamp = $parts['t'] ?? null; $signature = $parts['v1'] ?? null; if (!$timestamp || !$signature) { throw new Exception('Missing timestamp or signature'); } // 2. Optional staleness bound. `t` is fixed at preparation and does NOT change // across retries (which span ~3 days), so use a window wider than the full // retry window and rely on the envelope `id` for replay protection. $maxAge = 3 * 24 * 60 * 60; // ~3 days (longest plausible retry window) $now = time(); if ($now - (int)$timestamp > $maxAge) { throw new Exception('Event older than the retry window'); } // 3. Construct string to sign $stringToSign = $timestamp . '.' . $rawBody; // 4. Compute expected signature $expectedSignature = hash_hmac('sha256', $stringToSign, $secret); // 5. Safe comparison return hash_equals($signature, $expectedSignature);}
package mainimport ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "time")// VerifyWebhook verifies the authenticity of BotSubscription webhook requests.// rawBody: Raw unparsed HTTP request body bytes// signatureHeader: Value of X-Webhook-Signature header// secret: Outbound signing secret from your project dashboardfunc VerifyWebhook(rawBody []byte, signatureHeader string, secret string) (bool, error) { // 1. Parse signature header parts := make(map[string]string) for _, part := range strings.Split(signatureHeader, ",") { kv := strings.SplitN(part, "=", 2) if len(kv) == 2 { parts[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) } } timestampStr, okt := parts["t"] signatureStr, okv := parts["v1"] if !okt || !okv { return false, errors.New("missing timestamp or signature") } // 2. Optional staleness bound. `t` is fixed at preparation and does NOT change // across retries (which span ~3 days), so use a window wider than the full // retry window and rely on the envelope `id` for replay protection. var timestamp int64 _, err := fmt.Sscanf(timestampStr, "%d", ×tamp) if err != nil { return false, err } const maxAge = 3 * 24 * 60 * 60 // ~3 days (longest plausible retry window) now := time.Now().Unix() if now-timestamp > maxAge { return false, errors.New("event older than the retry window") } // 3. Construct string to sign stringToSign := fmt.Sprintf("%s.%s", timestampStr, string(rawBody)) // 4. Compute the expected HMAC-SHA256 h := hmac.New(sha256.New, []byte(secret)) h.Write([]byte(stringToSign)) expectedSignature := hex.EncodeToString(h.Sum(nil)) // 5. Compare using constant-time comparison sigBytes := []byte(signatureStr) expectedBytes := []byte(expectedSignature) return hmac.Equal(sigBytes, expectedBytes), nil}
require 'openssl'# Verify Webhook Signature# raw_body: Raw unparsed HTTP request body string# signature_header: Value of X-Webhook-Signature header# secret: Outbound signing secret from your project dashboarddef verify_webhook(raw_body, signature_header, secret) # 1. Parse signature header parts = signature_header.split(',').each_with_object({}) do |part, acc| key, val = part.split('=', 2) acc[key.strip] = val.strip if key && val end timestamp = parts['t'] signature = parts['v1'] if timestamp.nil? || signature.nil? raise "Missing timestamp or signature" end # 2. Optional staleness bound. `t` is fixed at preparation and does NOT change # across retries (which span ~3 days), so use a window wider than the full # retry window and rely on the envelope `id` for replay protection. max_age = 3 * 24 * 60 * 60 # ~3 days (longest plausible retry window) now = Time.now.to_i if now - timestamp.to_i > max_age raise "Event older than the retry window" end # 3. Construct payload string string_to_sign = "#{timestamp}.#{raw_body}" # 4. Compute expected signature expected_signature = OpenSSL::HMAC.hexdigest( OpenSSL::Digest.new('sha256'), secret, string_to_sign ) # 5. Secure constant-time comparison (both values are equal-length hex strings) OpenSSL.fixed_length_secure_compare(signature, expected_signature)end