Webhook Verification

How to verify webhooks are authentic and haven't been tampered with.

Why verify

Anyone who knows your webhook URL could try to send fake events. To prevent this, each webhook includes a cryptographic signature you must verify.

Always verify signatures

Never process a webhook without verifying its signature, especially if it performs important actions like creating orders or modifying data.

How it works

  1. When creating a webhook, a secret unique
  2. When we send a webhook, we calculate an HMAC-SHA256 hash of the payload using your secret
  3. We include this hash in the header X-SalonBookIt-Signature
  4. Your server calculates the same hash and compares it

Signature format

X-SalonBookIt-Signature: sha256=abc123def456...

The signature consists of:

  • sha256= - Prefix indicating the algorithm
  • Followed by the HMAC-SHA256 hash in hexadecimal

Get your secret

  1. Go to Configuration → Integrations
  2. In the Webhooks section, click on the webhook you want to verify
  3. Copy the field value Secret
Store the secret securely

Use environment variables or a secrets manager. Never include it in source code.

Verify the signature

Node.js / JavaScript

JavaScript
const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
    // Extract the hash from the signature
    const sigHash = signature.replace('sha256=', '');

    // Calculate the expected hash
    const expectedHash = crypto
        .createHmac('sha256', secret)
        .update(payload, 'utf8')
        .digest('hex');

    // Compare securely (prevents timing attacks)
    return crypto.timingSafeEqual(
        Buffer.from(sigHash, 'hex'),
        Buffer.from(expectedHash, 'hex')
    );
}

// In your endpoint
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['x-salonbookit-signature'];
    const payload = req.body.toString();

    if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
        console.error('Invalid signature');
        return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(payload);
    console.log('Valid event:', event.type);

    // Process the event...

    res.json({ received: true });
});

Python

Python
import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')

def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    """Verifies the webhook's HMAC signature."""
    if not signature.startswith('sha256='):
        return False

    sig_hash = signature[7:]  # Remove 'sha256='

    expected_hash = hmac.new(
        secret.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    # Secure comparison against timing attacks
    return hmac.compare_digest(sig_hash, expected_hash)


@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-SalonBookIt-Signature', '')
    payload = request.get_data()

    if not verify_signature(payload, signature, WEBHOOK_SECRET):
        abort(401, 'Invalid signature')

    event = request.get_json()
    print(f"Valid event: {event['type']}")

    # Process the event...

    return {'received': True}

PHP

PHP
<?php

$webhookSecret = getenv('WEBHOOK_SECRET');
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SALONBOOKIT_SIGNATURE'] ?? '';

function verifySignature(string $payload, string $signature, string $secret): bool
{
    if (strpos($signature, 'sha256=') !== 0) {
        return false;
    }

    $sigHash = substr($signature, 7);
    $expectedHash = hash_hmac('sha256', $payload, $secret);

    return hash_equals($expectedHash, $sigHash);
}

if (!verifySignature($payload, $signature, $webhookSecret)) {
    http_response_code(401);
    die('Invalid signature');
}

$event = json_decode($payload, true);
error_log('Valid event: ' . $event['type']);

// Process the event...

http_response_code(200);
echo json_encode(['received' => true]);

Ruby

Ruby
require 'openssl'
require 'sinatra'

WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']

def verify_signature(payload, signature, secret)
  return false unless signature.start_with?('sha256=')

  sig_hash = signature[7..]
  expected_hash = OpenSSL::HMAC.hexdigest('sha256', secret, payload)

  Rack::Utils.secure_compare(expected_hash, sig_hash)
end

post '/webhook' do
  payload = request.body.read
  signature = request.env['HTTP_X_SALONBOOKIT_SIGNATURE'] || ''

  unless verify_signature(payload, signature, WEBHOOK_SECRET)
    halt 401, 'Invalid signature'
  end

  event = JSON.parse(payload)
  puts "Valid event: #{event['type']}"

  # Process the event...

  content_type :json
  { received: true }.to_json
end

Verify timestamp (optional)

For extra security, you can verify the webhook isn't too old using the header X-SalonBookIt-Timestamp:

JavaScript
const timestamp = parseInt(req.headers['x-salonbookit-timestamp']);
const now = Math.floor(Date.now() / 1000);
const tolerance = 300; // 5 minutes

if (Math.abs(now - timestamp) > tolerance) {
    console.error('Webhook too old');
    return res.status(401).send('Timestamp expired');
}

This prevents replay attacks where someone tries to resend a previously captured webhook.

Common errors

Signature doesn't match

  • Verify you're using the correct secret
  • Make sure to use the payload raw (without parsing)
  • Don't modify the payload before verifying

Payload is modified

If your framework automatically parses JSON, get the raw body first. In Express:

app.use(express.raw({ type: 'application/json' }))

Encoding issues

Make sure the payload is treated as UTF-8 when calculating the hash.