Webhook Security



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

  1. When you create a webhook endpoint, you receive a secret (format: whsec_...)
  2. For each webhook delivery, Breezy computes an HMAC-SHA256 signature of the JSON body using your secret
  3. The signature is sent in the X-Hook-Signature header
  4. You verify the signature by computing it yourself and comparing

Headers

HeaderDescription
X-Hook-SignatureHMAC-SHA256 signature of the request body
X-Breezy-Webhook-VersionWebhook format version
Content-TypeAlways 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
end

PHP

<?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:

RequirementDescription
ProtocolHTTPS only (HTTP not allowed)
Valid SSLMust have a valid SSL certificate
Public IPNo localhost or private IP addresses
ReachableMust be publicly accessible

Blocked URLs

The following URL patterns are blocked in production:

  • localhost
  • 127.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:

AttemptDelay
11 second
22 seconds
34 seconds
48 seconds
516 seconds
632 seconds
7-1060 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

  1. Check the secret - Ensure you're using the exact secret returned when creating the endpoint
  2. Check JSON serialization - The signature is computed on the raw JSON body, not a re-serialized version
  3. Check for middleware - Some frameworks modify the request body before your handler sees it
  4. Check encoding - Both secret and body should be treated as UTF-8

Not Receiving Webhooks

  1. Check endpoint is active - Use the GET endpoint to verify status is "active" and enabled is true
  2. Check events - Verify the endpoint is subscribed to the event type you're expecting
  3. Check URL - Ensure the URL is reachable from the public internet
  4. Check SSL - Ensure your SSL certificate is valid and not expired
  5. Check stats - Look at failed_deliveries to see if deliveries are failing