Breezy webhooks include a cryptographic signature that allows you to verify that the webhook was sent by Breezy and has not been tampered with.
Signature Verification
Every webhook request includes an X-Hook-Signature header containing an HMAC-SHA256 signature of the request body.
How It Works
- When you create a webhook endpoint, you receive a
secret(format:whsec_...) - For each webhook delivery, Breezy computes an HMAC-SHA256 signature of the JSON body using your secret
- The signature is sent in the
X-Hook-Signatureheader - You verify the signature by computing it yourself and comparing
Headers
| Header | Description |
|---|---|
X-Hook-Signature | HMAC-SHA256 signature of the request body |
X-Breezy-Webhook-Version | Webhook format version |
Content-Type | Always application/json |
Verification Examples
Node.js
const crypto = require('crypto')
function verifyWebhookSignature(body, signature, secret) {
const hmac = crypto.createHmac('sha256', secret)
hmac.update(JSON.stringify(body))
const calculated = hmac.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(calculated),
Buffer.from(signature)
)
}
// Express middleware example
app.post('/webhook', express.json(), (req, res) => {
const signature = req.headers['x-hook-signature']
const secret = process.env.WEBHOOK_SECRET
if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' })
}
// Process the webhook
console.log('Received webhook:', req.body.type)
res.status(200).json({ success: true })
})Python
import hmac
import hashlib
import json
def verify_webhook_signature(body, signature, secret):
computed = hmac.new(
secret.encode('utf-8'),
json.dumps(body, separators=(',', ':')).encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed, signature)
# Flask example
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Hook-Signature')
secret = os.environ.get('WEBHOOK_SECRET')
if not verify_webhook_signature(request.json, signature, secret):
return jsonify({'error': 'Invalid signature'}), 401
# Process the webhook
print(f"Received webhook: {request.json['type']}")
return jsonify({'success': True})Ruby
require 'openssl'
require 'json'
def verify_webhook_signature(body, signature, secret)
computed = OpenSSL::HMAC.hexdigest(
'sha256',
secret,
body.to_json
)
Rack::Utils.secure_compare(computed, signature)
end
# Sinatra example
post '/webhook' do
request.body.rewind
body = JSON.parse(request.body.read)
signature = request.env['HTTP_X_HOOK_SIGNATURE']
secret = ENV['WEBHOOK_SECRET']
unless verify_webhook_signature(body, signature, secret)
halt 401, { error: 'Invalid signature' }.to_json
end
# Process the webhook
puts "Received webhook: #{body['type']}"
{ success: true }.to_json
endPHP
<?php
function verifyWebhookSignature($body, $signature, $secret) {
$computed = hash_hmac('sha256', json_encode($body), $secret);
return hash_equals($computed, $signature);
}
// Example usage
$body = json_decode(file_get_contents('php://input'), true);
$signature = $_SERVER['HTTP_X_HOOK_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
if (!verifyWebhookSignature($body, $signature, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Process the webhook
error_log("Received webhook: " . $body['type']);
echo json_encode(['success' => true]);URL Requirements
Webhook endpoints must meet these requirements:
| Requirement | Description |
|---|---|
| Protocol | HTTPS only (HTTP not allowed) |
| Valid SSL | Must have a valid SSL certificate |
| Public IP | No localhost or private IP addresses |
| Reachable | Must be publicly accessible |
Blocked URLs
The following URL patterns are blocked in production:
localhost127.0.0.1::1- Private IP ranges:
10.x.x.x,192.168.x.x,172.16-31.x.x - Link-local addresses:
169.254.x.x
Delivery Behavior
Timeouts
Each webhook delivery has a 30 second timeout. If your endpoint does not respond within 30 seconds, the delivery is marked as failed and will be retried.
Retries
Failed deliveries are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | 1 second |
| 2 | 2 seconds |
| 3 | 4 seconds |
| 4 | 8 seconds |
| 5 | 16 seconds |
| 6 | 32 seconds |
| 7-10 | 60 seconds (max) |
After 10 failed attempts, the delivery is abandoned.
Success Response
Your endpoint should return a 2xx status code to indicate successful receipt. Any other status code is treated as a failure.
Best Practices
1. Always Verify Signatures
Never process a webhook without verifying the signature. This protects against spoofed requests.
2. Use Timing-Safe Comparison
Use constant-time comparison functions (like crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python) to prevent timing attacks.
3. Respond Quickly
Process webhooks asynchronously if needed. Return a 200 response immediately, then process the data in a background job.
4. Handle Duplicates
In rare cases, the same webhook may be delivered more than once. Use the _id field to deduplicate events.
5. Store the Secret Securely
- Never commit the secret to source control
- Use environment variables or a secrets manager
- Rotate secrets if they may have been compromised
6. Log Failed Verifications
Log signature verification failures for debugging, but don't expose details to the requester.
Troubleshooting
Signature Verification Failing
- Check the secret - Ensure you're using the exact secret returned when creating the endpoint
- Check JSON serialization - The signature is computed on the raw JSON body, not a re-serialized version
- Check for middleware - Some frameworks modify the request body before your handler sees it
- Check encoding - Both secret and body should be treated as UTF-8
Not Receiving Webhooks
- Check endpoint is active - Use the GET endpoint to verify status is "active" and enabled is true
- Check events - Verify the endpoint is subscribed to the event type you're expecting
- Check URL - Ensure the URL is reachable from the public internet
- Check SSL - Ensure your SSL certificate is valid and not expired
- Check stats - Look at
failed_deliveriesto see if deliveries are failing
