Webhook Signature Verification
Every webhook delivered by HookSniff includes a cryptographic signature. Always verify it before processing — this prevents attackers from sending fake webhooks to your endpoint.
Why Verify?
Prevent Spoofing
Without verification, anyone can POST to your endpoint with fake data.
Replay Protection
Timestamps older than 5 minutes are rejected automatically.
Standard Webhooks
HookSniff follows the Standard Webhooks spec — same as Svix, Clerk, and others.
How It Works
Each webhook delivery includes three headers:
| Header | Example | Purpose |
|---|---|---|
| webhook-id | msg_abc123def456 | Unique message identifier |
| webhook-timestamp | 1716100000 | Unix timestamp. Reject if > 5 min old. |
| webhook-signature | v1,abc123... | Space-separated HMAC-SHA256 signatures |
Signature Algorithm
signed_content = "\{webhook-id\}.\{webhook-timestamp\}.\{body\}"
signature = "v1," + base64(hmac_sha256(secret, signed_content))The webhook-signature header may contain multiple space-separated signatures (for key rotation). Your code should verify that at least one matches.
Verification Steps
- Extract
webhook-id,webhook-timestamp, andwebhook-signatureheaders - Check timestamp: reject if older than 5 minutes (300 seconds)
- Compute expected signature:
v1,base64(hmac_sha256(secret, "{id}.{timestamp}.{body}")) - Compare with received signature(s) using constant-time comparison (prevents timing attacks)
- If any signature matches → valid. Otherwise → reject
Verify with SDKs
All HookSniff SDKs handle verification automatically. Just pass the raw body and headers:
import { Webhook } from 'hooksniff';
const wh = new Webhook('whsec_your_endpoint_secret');
// Express handler
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
try {
const payload = wh.verify(req.body, {
'webhook-id': req.headers['webhook-id'],
'webhook-timestamp': req.headers['webhook-timestamp'],
'webhook-signature': req.headers['webhook-signature'],
});
// ✅ Signature valid — process event
console.log('Event:', payload.event);
console.log('Data:', payload.data);
switch (payload.event) {
case 'order.created':
// Handle new order
break;
case 'payment.completed':
// Handle payment
break;
}
res.status(200).json({ received: true });
} catch (err) {
// ❌ Invalid signature — reject
res.status(401).json({ error: 'Invalid signature' });
}
});Manual Verification (No SDK)
If you can't use an SDK, here's the algorithm in Python:
import hmac
import hashlib
import base64
import time
def verify_webhook(body: bytes, headers: dict, secret: str) -> dict:
"""Verify a Standard Webhooks signature."""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature = headers.get("webhook-signature")
if not all([msg_id, timestamp, signature]):
raise ValueError("Missing required headers")
# 1. Check timestamp (reject if older than 5 minutes)
age = abs(time.time() - int(timestamp))
if age > 300:
raise ValueError(f"Webhook too old: {age}s")
# 2. Decode secret (remove whsec_ prefix, base64 decode)
secret_bytes = base64.b64decode(secret.replace("whsec_", ""))
# 3. Compute expected signature
signed_content = f"{msg_id}.{timestamp}.{body.decode()}"
expected_sig = "v1," + base64.b64encode(
hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
).decode()
# 4. Verify (check all signatures, may be space-separated)
sigs = signature.split(" ")
if not any(hmac.compare_digest(s, expected_sig) for s in sigs):
raise ValueError("Invalid signature")
# 5. Parse and return payload
import json
return json.loads(body)Security Tips
Always use HTTPS
Your webhook endpoint must use TLS. HookSniff refuses to deliver to HTTP endpoints.
Use constant-time comparison
Prevents timing attacks. All SDKs do this automatically.
Check timestamp
Reject webhooks older than 5 minutes to prevent replay attacks.
Rotate secrets periodically
Use the dashboard or API to rotate signing secrets. Old secrets remain valid during rotation.
Return 2xx quickly
Process webhooks asynchronously. Return 200 immediately, then handle the event in a background job.
Never log the secret
Signing secrets are credentials. Never log them or expose them in error messages.
Key Rotation
When you rotate an endpoint's signing secret, HookSniff sends webhooks with both old and new signatures (space-separated in the webhook-signature header). This allows zero-downtime rotation:
# webhook-signature header during rotation:
# "v1,old_signature v1,new_signature"
# Your verification code should check if ANY signature matches:
sigs = signature.split(" ")
valid = any(verify(sig, secret) for sig in sigs)Rotate secrets via the dashboard: Endpoints → Select endpoint → Rotate Secret, or via the API:
curl -X POST https://hooksniff-api-1046140057667.europe-west1.run.app/v1/endpoints/EP_ID/rotate-secret \
-H "Authorization: Bearer hr_live_YOUR_KEY"