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.
Nunca proceses un webhook sin verificar su firma, especialmente si realiza acciones importantes como crear pedidos o modificar datos.
Cómo funciona
- Al crear un webhook, se genera un secret único
- Cuando enviamos un webhook, calculamos un hash HMAC-SHA256 del payload usando tu secret
- Incluimos este hash en el header
X-SalonBookIt-Signature - 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
- Ve a Configuración → Integraciones
- En la sección Webhooks, haz clic en el webhook que quieres verificar
- Copia el valor del campo Secret
Usa variables de entorno o un gestor de secretos. Nunca lo incluyas en el código fuente.
Verificar la firma
Node.js / 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
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
$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
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:
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.