Verificación de Webhooks

Cómo verificar que los webhooks son auténticos y no han sido manipulados.

Por qué verificar

Cualquiera que conozca la URL de tu webhook podría intentar enviar eventos falsos. Para evitarlo, cada webhook incluye una firma criptográfica que debes verificar.

Siempre verifica las firmas

Nunca proceses un webhook sin verificar su firma, especialmente si realiza acciones importantes como crear pedidos o modificar datos.

Cómo funciona

  1. Al crear un webhook, se genera un secret único
  2. Cuando enviamos un webhook, calculamos un hash HMAC-SHA256 del payload usando tu secret
  3. Incluimos este hash en el header X-SalonBookIt-Signature
  4. Tu servidor calcula el mismo hash y lo compara

Formato de la firma

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

La firma se compone de:

  • sha256= - Prefijo que indica el algoritmo
  • Seguido del hash HMAC-SHA256 en hexadecimal

Obtener tu secret

  1. Ve a Configuración → Integraciones
  2. En la sección Webhooks, haz clic en el webhook que quieres verificar
  3. Copia el valor del campo Secret
Guarda el secret de forma segura

Usa variables de entorno o un gestor de secretos. Nunca lo incluyas en el código fuente.

Verificar la firma

Node.js / JavaScript

JavaScript
const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
    // Extraer el hash de la firma
    const sigHash = signature.replace('sha256=', '');

    // Calcular el hash esperado
    const expectedHash = crypto
        .createHmac('sha256', secret)
        .update(payload, 'utf8')
        .digest('hex');

    // Comparar de forma segura (evita timing attacks)
    return crypto.timingSafeEqual(
        Buffer.from(sigHash, 'hex'),
        Buffer.from(expectedHash, 'hex')
    );
}

// En tu 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('Firma inválida');
        return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(payload);
    console.log('Evento válido:', event.type);

    // Procesar el evento...

    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:
    """Verifica la firma HMAC del webhook."""
    if not signature.startswith('sha256='):
        return False

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

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

    # Comparación segura contra 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"Evento válido: {event['type']}")

    # Procesar el evento...

    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('Evento válido: ' . $event['type']);

// Procesar el evento...

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 "Evento válido: #{event['type']}"

  # Procesar el evento...

  content_type :json
  { received: true }.to_json
end

Verificar timestamp (opcional)

Para mayor seguridad, puedes verificar que el webhook no sea muy antiguo usando el header X-SalonBookIt-Timestamp:

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

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

Esto previene ataques de replay donde alguien intenta reenviar un webhook capturado anteriormente.

Errores comunes

La firma no coincide

  • Verifica que estás usando el secret correcto
  • Asegúrate de usar el payload raw (sin parsear)
  • No modifiques el payload antes de verificar

El payload está modificado

Si tu framework parsea automáticamente el JSON, obtén el body raw antes. En Express:

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

Problemas de encoding

Asegúrate de que el payload se trata como UTF-8 al calcular el hash.