Verifying Webhook Signatures
All webhook requests are signed using HMAC-SHA256. Always verify signatures to ensure requests are authentic and haven't been tampered with.
Why Verify Signatures?
Without signature verification, anyone who discovers your webhook URL could send fake events to your endpoint. Verification ensures:
- Requests originate from us, not a malicious actor
- Payloads haven't been modified in transit
- Protection against replay attacks (using timestamp validation)
Signature Headers
Each webhook request includes these headers:
| Header | Description |
|---|---|
X-Webhook-Signature |
HMAC-SHA256 signature in format v1=<hex> |
X-Webhook-Timestamp |
Unix timestamp when the request was sent |
X-Webhook-Event |
The event type (e.g., user.created) |
X-Webhook-Delivery |
Unique delivery ID for idempotency |
Verification Steps
- Extract the timestamp and signature from headers
- Construct the signed payload:
{timestamp}.{raw_body} - Compute the expected signature using HMAC-SHA256
- Compare signatures using a timing-safe comparison
- Optionally validate the timestamp to prevent replay attacks
Code Examples
Ruby
require 'openssl'
def verify_webhook(request, secret)
signature = request.headers['X-Webhook-Signature']
timestamp = request.headers['X-Webhook-Timestamp']
body = request.raw_post
return false unless signature&.start_with?('v1=')
expected_sig = OpenSSL::HMAC.hexdigest(
'SHA256',
secret,
"#{timestamp}.#{body}"
)
received_sig = signature.delete_prefix('v1=')
# Timing-safe comparison
ActiveSupport::SecurityUtils.secure_compare(expected_sig, received_sig)
end
Node.js
const crypto = require('crypto');
function verifyWebhook(req, secret) {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const body = req.rawBody; // Ensure you capture the raw body
if (!signature?.startsWith('v1=')) return false;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
const receivedSig = signature.slice(3); // Remove 'v1=' prefix
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(expectedSig),
Buffer.from(receivedSig)
);
}
Python
import hmac
import hashlib
def verify_webhook(request, secret):
signature = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
body = request.data.decode('utf-8')
if not signature.startswith('v1='):
return False
expected_sig = hmac.new(
secret.encode(),
f"{timestamp}.{body}".encode(),
hashlib.sha256
).hexdigest()
received_sig = signature[3:] # Remove 'v1=' prefix
# Timing-safe comparison
return hmac.compare_digest(expected_sig, received_sig)
PHP
function verifyWebhook($headers, $body, $secret) {
$signature = $headers['X-Webhook-Signature'] ?? '';
$timestamp = $headers['X-Webhook-Timestamp'] ?? '';
if (strpos($signature, 'v1=') !== 0) {
return false;
}
$expectedSig = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
$receivedSig = substr($signature, 3); // Remove 'v1=' prefix
// Timing-safe comparison
return hash_equals($expectedSig, $receivedSig);
}
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func verifyWebhook(signature, timestamp, body, secret string) bool {
if !strings.HasPrefix(signature, "v1=") {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "." + body))
expectedSig := hex.EncodeToString(mac.Sum(nil))
receivedSig := strings.TrimPrefix(signature, "v1=")
// Timing-safe comparison
return hmac.Equal([]byte(expectedSig), []byte(receivedSig))
}
Preventing Replay Attacks
Validate the timestamp to reject old requests:
def timestamp_valid?(timestamp, tolerance: 300)
request_time = Time.at(timestamp.to_i)
(Time.current - request_time).abs < tolerance
end
We recommend rejecting requests older than 5 minutes (300 seconds).
Troubleshooting
Signature mismatch - Ensure you're using the raw request body, not a parsed/modified version - Verify you're using the correct signing secret - Check that the secret hasn't been regenerated
Encoding issues - The body must be the exact bytes received, including whitespace - Don't re-serialize JSON - use the original string