Skip to main content

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:

  1. Converting the JSON payload to a string
  2. Computing HMAC-SHA256 using your webhook secret
  3. 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:

  1. Delete the existing webhook subscription
  2. Create a new subscription (you'll receive a new secret)
  3. Update your application with the new secret
  4. 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