Webhook Security
Verify that webhook requests genuinely come from Leezy by validating the HMAC-SHA256 signature.
Signature Header
Every webhook includes an X-Leezy-Signature header:
X-Leezy-Signature: sha256=abc123def456...
The signature is created by:
- Converting the JSON payload to a string
- Computing HMAC-SHA256 using your webhook secret
- Encoding the result as hexadecimal
Webhook Secret
When you create a webhook subscription, you receive a secret:
{
"data": {
"id": "webhook_abc123",
"secret": "lzy_ws_abc123xyz789..."
}
}
Store Securely
The secret is only shown once. Store it securely in environment variables.
Verification Examples
Node.js
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
// Extract the signature from the header
const expectedSignature = signature.replace('sha256=', '');
// Compute HMAC-SHA256
const computedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Compare signatures (timing-safe)
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(computedSignature, 'hex')
);
}
// Express middleware
app.post('/webhooks/leezy', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-leezy-signature'];
const secret = process.env.LEEZY_WEBHOOK_SECRET;
if (!verifyWebhook(req.body.toString(), signature, secret)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body);
// Process webhook...
res.status(200).send('OK');
});
Python
import hmac
import hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
"""Verify Leezy webhook signature."""
expected_signature = signature.replace('sha256=', '')
computed_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, computed_signature)
# Flask example
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/leezy', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Leezy-Signature', '')
secret = os.environ['LEEZY_WEBHOOK_SECRET']
if not verify_webhook(request.data, signature, secret):
return 'Invalid signature', 401
payload = request.json
# Process webhook...
return 'OK', 200
PHP
<?php
function verifyWebhook(string $payload, string $signature, string $secret): bool
{
$expectedSignature = str_replace('sha256=', '', $signature);
$computedSignature = hash_hmac('sha256', $payload, $secret);
return hash_equals($expectedSignature, $computedSignature);
}
// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_LEEZY_SIGNATURE'] ?? '';
$secret = getenv('LEEZY_WEBHOOK_SECRET');
if (!verifyWebhook($payload, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($payload, true);
// Process webhook...
http_response_code(200);
echo 'OK';
Ruby
require 'openssl'
def verify_webhook(payload, signature, secret)
expected_signature = signature.sub('sha256=', '')
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
Rack::Utils.secure_compare(expected_signature, computed_signature)
end
# Sinatra example
post '/webhooks/leezy' do
request.body.rewind
payload = request.body.read
signature = request.env['HTTP_X_LEEZY_SIGNATURE'] || ''
secret = ENV['LEEZY_WEBHOOK_SECRET']
unless verify_webhook(payload, signature, secret)
halt 401, 'Invalid signature'
end
event = JSON.parse(payload)
# Process webhook...
status 200
'OK'
end
Timestamp Validation
Prevent replay attacks by validating the timestamp:
const MAX_AGE_SECONDS = 300; // 5 minutes
function isTimestampValid(timestamp) {
const eventTime = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);
const age = currentTime - eventTime;
return age >= 0 && age <= MAX_AGE_SECONDS;
}
// In your webhook handler
const timestamp = req.headers['x-leezy-timestamp'];
if (!isTimestampValid(timestamp)) {
return res.status(401).send('Timestamp too old');
}
Security Checklist
- Always verify signatures before processing webhooks
- Use timing-safe comparison functions
- Store secrets in environment variables (never in code)
- Use HTTPS for webhook endpoints
- Validate timestamps to prevent replay attacks
- Log failed signature verifications for monitoring
Rotating Secrets
If you suspect a secret has been compromised:
- Delete the existing webhook subscription
- Create a new subscription (you'll receive a new secret)
- Update your application with the new secret
- Verify the new webhook is working
Testing Webhooks
During development, you can skip signature verification:
const SKIP_VERIFICATION = process.env.NODE_ENV === 'development';
if (!SKIP_VERIFICATION && !verifyWebhook(payload, signature, secret)) {
return res.status(401).send('Invalid signature');
}
Production Only
Never skip verification in production environments.
Next Steps
- Webhooks Overview - Delivery behavior
- Event Types - Available events
- Webhooks API - Manage subscriptions