Signature Verification
Verify that incoming webhooks are authentic using HMAC-SHA256 signatures.
Why Verify Signatures?
Anyone who knows your webhook endpoint URL could send forged requests to it. Signature verification ensures that every webhook delivery genuinely originated from Deliverty Hub and has not been tampered with in transit. You should always verify the signature before processing a webhook payload.
How Signing Works
Deliverty Hub signs every webhook delivery using HMAC-SHA256. The signing process works as follows:
Generate timestamp
A Unix timestamp (seconds since epoch) is generated at the time of delivery.
Build the signed content
The signed content is formed by concatenating the timestamp and the JSON-stringified payload with a period separator: ${timestamp}.${JSON.stringify(payload)}
Compute HMAC-SHA256
The HMAC-SHA256 digest is computed over the signed content using your webhook subscription's secret key, producing a hex-encoded signature string.
Send in headers
The signature and timestamp are sent in the X-Webhook-Signature header in the format t=<timestamp>,v1=<hex_signature>. The timestamp is also sent separately in the X-Webhook-Timestamp header.
Signature Details
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Secret format | whsec_<random_base64url_string> (32 random bytes, base64url-encoded) |
| Signed content | ${timestamp}.${JSON.stringify(payload)} |
| Signature header | X-Webhook-Signature: t=<unix_timestamp>,v1=<hex_signature> |
| Timestamp header | X-Webhook-Timestamp: <unix_timestamp> |
| Signature encoding | Hexadecimal |
| Timestamp tolerance | 5 minutes (300,000 ms) — reject if the timestamp differs from current time by more than this |
| Comparison method | Timing-safe equality (prevents timing attacks) |
Verification Steps
To verify an incoming webhook, your server should perform these steps in order:
- Extract the signature header. Read the
X-Webhook-Signatureheader value and parse it to extract thet(timestamp) andv1(signature) components. - Check the timestamp. Compute the absolute difference between the extracted timestamp and your server's current time. If it exceeds 5 minutes (300 seconds), reject the request to prevent replay attacks.
- Reconstruct the signed content. Build the string
${timestamp}.${JSON.stringify(body)}wherebodyis the raw JSON request body exactly as received. - Compute the expected signature. Calculate the HMAC-SHA256 of the signed content using your webhook secret, and hex-encode the result.
- Compare signatures. Use a timing-safe comparison function to compare your computed signature with the
v1value from the header. If they match, the webhook is authentic.
The signature is computed over the exact JSON string of the payload. If your framework parses the body and re-serializes it, key ordering or whitespace may differ, causing verification to fail. Always use the raw request body for signature verification, then parse it afterward.
Node.js Example
const crypto = require('crypto');
/**
* Verify a Deliverty Hub webhook signature.
*
* @param {string} rawBody - Raw request body string (not parsed JSON)
* @param {string} signatureHeader - Value of X-Webhook-Signature header
* @param {string} secret - Your webhook subscription secret (whsec_...)
* @returns {boolean} True if the signature is valid
*/
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
// Step 1: Parse the signature header
const parts = signatureHeader.split(',');
let timestamp = null;
let signature = null;
for (const part of parts) {
const [key, value] = part.split('=');
if (key === 't') {
timestamp = parseInt(value, 10);
} else if (key === 'v1') {
signature = value;
}
}
if (!timestamp || !signature) {
console.error('Invalid signature header format');
return false;
}
// Step 2: Check timestamp tolerance (5 minutes)
const currentTime = Math.floor(Date.now() / 1000);
const timeDiff = Math.abs(currentTime - timestamp);
if (timeDiff > 300) {
console.error(`Timestamp too old: ${timeDiff} seconds`);
return false;
}
// Step 3: Reconstruct the signed content
const signedContent = `${timestamp}.${rawBody}`;
// Step 4: Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
// Step 5: Timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
} catch (err) {
// Lengths differ — signatures don't match
return false;
}
}
// Usage in an Express.js handler:
const express = require('express');
const app = express();
// IMPORTANT: Use raw body for signature verification
app.post('/webhooks/deliverty',
express.raw({ type: 'application/json' }),
(req, res) => {
const signatureHeader = req.headers['x-webhook-signature'];
const secret = process.env.DELIVERTY_WEBHOOK_SECRET;
if (!signatureHeader) {
return res.status(401).json({ error: 'Missing signature' });
}
const rawBody = req.body.toString('utf-8');
const isValid = verifyWebhookSignature(rawBody, signatureHeader, secret);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Signature is valid — process the event
const event = JSON.parse(rawBody);
console.log(`Received event: ${event.event}`, event.data);
// Respond quickly with 200
res.status(200).json({ received: true });
// Process asynchronously if needed...
}
);
Python Example
import hashlib
import hmac
import json
import time
def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""
Verify a Deliverty Hub webhook signature.
Args:
raw_body: Raw request body bytes
signature_header: Value of X-Webhook-Signature header
secret: Your webhook subscription secret (whsec_...)
Returns:
True if the signature is valid
"""
# Step 1: Parse the signature header
timestamp = None
signature = None
for part in signature_header.split(","):
key, _, value = part.partition("=")
if key == "t":
timestamp = int(value)
elif key == "v1":
signature = value
if timestamp is None or signature is None:
print("Invalid signature header format")
return False
# Step 2: Check timestamp tolerance (5 minutes)
current_time = int(time.time())
time_diff = abs(current_time - timestamp)
if time_diff > 300:
print(f"Timestamp too old: {time_diff} seconds")
return False
# Step 3: Reconstruct the signed content
body_str = raw_body.decode("utf-8")
signed_content = f"{timestamp}.{body_str}"
# Step 4: Compute expected signature
expected_signature = hmac.new(
secret.encode("utf-8"),
signed_content.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# Step 5: Timing-safe comparison
return hmac.compare_digest(signature, expected_signature)
# Usage with Flask:
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret_here"
@app.route("/webhooks/deliverty", methods=["POST"])
def handle_webhook():
signature_header = request.headers.get("X-Webhook-Signature")
if not signature_header:
return jsonify({"error": "Missing signature"}), 401
raw_body = request.get_data()
is_valid = verify_webhook_signature(raw_body, signature_header, WEBHOOK_SECRET)
if not is_valid:
return jsonify({"error": "Invalid signature"}), 401
# Signature is valid — process the event
event = json.loads(raw_body)
print(f"Received event: {event['event']}", event["data"])
# Respond quickly with 200
return jsonify({"received": True}), 200
Replay Attack Protection
The timestamp included in the signature serves as protection against replay attacks. Even if an attacker captures a valid webhook delivery, they cannot replay it after the 5-minute tolerance window has passed. Your verification logic should always check the timestamp before comparing signatures.
The tolerance window is set to 5 minutes (300,000 ms) to account for reasonable network latency and clock drift between servers. If you need stricter protection, you can also store and check the X-Webhook-Id to reject duplicate deliveries.
Common Issues
| Problem | Cause | Solution |
|---|---|---|
| Signature always fails | The request body was parsed and re-serialized, changing key order or whitespace. | Use the raw request body for verification. In Express, use express.raw(). In Flask, use request.get_data(). |
| Timestamp rejected | Your server's clock is significantly off from UTC. | Synchronize your server clock with NTP. The tolerance is 5 minutes, so minor drift is acceptable. |
| Wrong secret | Using the wrong secret or a stale secret after regeneration. | Confirm you are using the correct secret for the specific webhook subscription. After regenerating a secret, update your verification code immediately. |
| Encoding mismatch | Comparing base64-encoded signature with hex-encoded one, or vice versa. | Deliverty Hub signatures are hex-encoded. Make sure your HMAC digest output is also hex. |
Use the Test Webhook API endpoint to send a test payload to your URL with a valid signature. This lets you verify your signature checking code is working correctly before subscribing to real events.