Implementar correctamente la verificación HMAC para autenticar que cada notificación proviene de Whalemate.
Manejar reintentos, duplicados y timeouts sin errores en producción.
Tener snippets listos para copiar en los lenguajes más comunes.
Servidor accesible públicamente por HTTPS con certificado TLS válido (no autofirmado).
Capacidad de responder en menos de 10 segundos — si el procesamiento tarda más, encolar el trabajo y responder 200 OK de inmediato.
URL con esquema https://: Whalemate rechaza http://. La URL tiene que ser la definitiva y resolver desde internet — Whalemate no sigue redirecciones, así que un 301 o un 302 cuenta como entrega fallida.
Configura el webhook desde
Configuración → Integraciones → tarjeta Webhooks → Configurar(ver doc funcional). El secreto HMAC se muestra una sola vez al guardar — cópialo antes de continuar con la verificación de firma.
Todos los eventos comparten la misma estructura base. El body se envía como JSON en UTF-8.
{
"event": "campaign.clicked",
"campaign_id": 142,
"campaign_type": "phishing",
"employee_email": "[email protected]",
"employee_id": 1204,
"occurred_at": "2026-04-24T08:14:55Z"
}
Campo
Tipo
Nullable
Descripción
event
string
No
Nombre del evento
campaign_id
integer
Sí
ID interno de la campaña. Ver nota abajo.
campaign_type
string
No
Tipo de campaña. Valor actual: "phishing"
employee_email
string
Sí
Email del empleado. Ver nota abajo.
employee_id
integer
Sí
ID interno del empleado. Ver nota abajo.
occurred_at
string
No
Timestamp ISO 8601 UTC
employee_emailyemployee_idllegan ennullen dos situaciones: en el eventocampaign.opened, cuando el píxel de tracking es activado por un escáner de seguridad o proxy, y en el envío de prueba.campaign_idsolo llega ennullen el envío de prueba.Los eventos se entregan en el orden en que ocurren, pero Whalemate no garantiza que lleguen a tu servidor en ese mismo orden. Usa el campo
occurred_atpara ordenarlos cronológicamente en tu sistema.
El botón Probar de la pantalla de webhooks no manda un evento de campaña: manda un payload sintético, pensado para que tu receptor lo pueda reconocer e ignorar.
{
"event": "webhook.test",
"test": true,
"campaign_id": null,
"campaign_type": "phishing",
"employee_email": null,
"employee_id": null,
"occurred_at": "2026-04-24T08:14:55Z"
}
Dos diferencias que importan al implementar la validación: el valor de
eventeswebhook.test, que no está entre los cinco eventos de campaña, y el payload trae el campo extratest: true. Si tu receptor rechaza los eventos que no reconoce, va a rechazar la prueba aunque la integración esté bien.El envío de prueba tampoco lleva el header
X-Whalemate-Delivery-Id. Si usas ese header como clave de idempotencia, considera el caso en el que no viene.
Header
Ejemplo
Descripción
Content-Type
application/json
Siempre application/json
User-Agent
Whalemate-Webhooks/1.0
Identificador del cliente HTTP
X-Whalemate-Delivery-Id
"4521"
ID único de esta entrega. No viaja en el envío de prueba.
X-Whalemate-Timestamp
"1714986000"
Unix timestamp UTC del momento del envío
X-Whalemate-Signature
sha256=abc123...
Firma HMAC-SHA256
Puedes usar
User-Agent: Whalemate-Webhooks/1.0como filtro rápido, pero no lo uses como mecanismo de seguridad. Siempre verifica la firma HMAC.
Cada webhook incluye una firma HMAC-SHA256 en el header X-Whalemate-Signature. Debes verificarla antes de procesar cualquier payload.
Algoritmo:
firma = "sha256=" + HMAC_SHA256(key=secret, message=timestamp + "." + raw_body)
secret: el secreto generado al activar el webhook (disponible una sola vez).
timestamp: valor del header X-Whalemate-Timestamp.
raw_body: el cuerpo JSON exactamente como llega, sin parsear, como string o bytes en UTF-8.
Pasos:
Leer el body como string crudo antes de cualquier parseo JSON.
Construir el mensaje: timestamp + "." + raw_body.
Calcular HMAC-SHA256 del mensaje usando el secreto como clave.
Comparar el resultado (con prefijo sha256=) con X-Whalemate-Signature usando comparación de tiempo constante.
Verificar que |now() - timestamp| < 300 segundos (5 minutos) para prevenir replay attacks.
Si parseas el JSON y lo re-serializas, el orden de las claves o el espaciado puede cambiar y la firma no va a coincidir. Siempre trabaja con los bytes originales del body.
Escenario
Respuesta recomendada
Evento recibido y válido
200 OK inmediato, procesa de forma asíncrona
Firma inválida
401 Unauthorized
Timestamp expirado
400 Bad Request
Error interno
500 — Whalemate reintentará
Timeout (> 10s)
Whalemate marca como fallo y reintenta
Redirección (301 / 302)
Whalemate no la sigue: cuenta como fallo
El cuerpo de la respuesta no importa. Whalemate lo registra pero no lo procesa.
Whalemate puede entregar el mismo evento más de una vez en circunstancias excepcionales de red. Tu sistema debe ser idempotente.
Estrategia recomendada:
Usa X-Whalemate-Delivery-Id como clave de idempotencia.
Antes de procesar, verifica si ya procesaste ese Delivery ID.
Si ya existe, responde 200 OK sin volver a procesar.
delivery_id = request.headers.get("X-Whalemate-Delivery-Id")
if already_processed(delivery_id):
return Response(status=200) # duplicado ignorado
mark_as_processed(delivery_id)
process_event(request.json)
function verifyWebhookSignature(string $secret, string $timestamp, string $body, string $signature): bool {
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
return hash_equals($expected, $signature);
}
$timestamp = $_SERVER['HTTP_X_WHALEMATE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WHALEMATE_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400); exit('Timestamp too old');
}
if (!verifyWebhookSignature(getenv('WHALEMATE_WEBHOOK_SECRET'), $timestamp, $body, $signature)) {
http_response_code(401); exit('Invalid signature');
}
http_response_code(200);
$event = json_decode($body, true);
// procesar $event...
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
def verify_signature(secret, timestamp, raw_body, signature):
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhook")
def webhook():
secret = os.environ["WHALEMATE_WEBHOOK_SECRET"]
timestamp = request.headers.get("X-Whalemate-Timestamp", "")
signature = request.headers.get("X-Whalemate-Signature", "")
raw_body = request.get_data()
if abs(time.time() - int(timestamp)) > 300:
abort(400, "Timestamp too old")
if not verify_signature(secret, timestamp, raw_body, signature):
abort(401, "Invalid signature")
event = request.get_json()
# procesar event...
return "", 200
import hashlib, hmac, json, os, time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhook")
async def webhook(request: Request):
secret = os.environ["WHALEMATE_WEBHOOK_SECRET"]
timestamp = request.headers.get("x-whalemate-timestamp", "")
signature = request.headers.get("x-whalemate-signature", "")
raw_body = await request.body()
if abs(time.time() - int(timestamp)) > 300:
raise HTTPException(status_code=400, detail="Timestamp too old")
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(raw_body)
# procesar event...
return {"status": "ok"}
const crypto = require('crypto');
function verifySignature(secret, timestamp, rawBody, signature) {
const message = `${timestamp}.${rawBody}`;
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(message).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-whalemate-timestamp'] ?? '';
const signature = req.headers['x-whalemate-signature'] ?? '';
const rawBody = req.body.toString('utf8');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
return res.status(400).send('Timestamp too old');
if (!verifySignature(process.env.WHALEMATE_WEBHOOK_SECRET, timestamp, rawBody, signature))
return res.status(401).send('Invalid signature');
const event = JSON.parse(rawBody);
// procesar event...
res.status(200).end();
});
SECRET="tu_secreto_aqui"
TIMESTAMP=$(date +%s)
BODY='{"event":"campaign.sent","campaign_id":1,"campaign_type":"phishing","employee_email":"[email protected]","employee_id":1,"occurred_at":"2026-01-01T00:00:00Z"}'
SIGNATURE="sha256=$(printf '%s' "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST https://tu-servidor.com/webhook \
-H "Content-Type: application/json" \
-H "X-Whalemate-Delivery-Id: 9999" \
-H "X-Whalemate-Timestamp: $TIMESTAMP" \
-H "X-Whalemate-Signature: $SIGNATURE" \
-d "$BODY"
Verifica siempre la firma HMAC antes de procesar. No confíes únicamente en la IP de origen o el User-Agent.
Almacena el secreto en variables de entorno o un secrets manager. Nunca lo hardcodees.
Usa HTTPS con TLS 1.2 o superior.
Valida el contenido del payload aunque la firma sea válida.
Aplica rate limiting en tu endpoint receptor.
Causa
Solución
Parseas el body antes de calcular la firma
Lee siempre el body como string/bytes crudo antes de parsear
Middleware re-serializa el JSON
Desactiva el parsing automático del body o reconstruye desde los bytes originales
El secreto tiene espacios o saltos de línea extra
Verifica que el secreto esté trimmed al almacenarlo
Comparas con == en lugar de comparación constante
Usa hash_equals / compare_digest / timingSafeEqual
El reloj del servidor receptor no está sincronizado con NTP. Verifica con date o timedatectl status.
Encola el procesamiento y responde 200 OK de inmediato.
@app.post("/webhook")
def webhook():
# ... verificar firma ...
enqueue_job(process_webhook, payload=request.get_json())
return "", 200
El payload de prueba llega como webhook.test, con test: true, con los campos de campaña y empleado en null y sin el header X-Whalemate-Delivery-Id. Revisa que tu validación de esquema y de nombre de evento acepte ese caso, o que lo ignore devolviendo 200.
¿Por qué debo leer el body como string crudo y no como JSON directo?
La firma HMAC se calcula sobre los bytes exactos del body tal como llegaron. Si parseas el JSON y lo re-serializas, el orden de las claves puede cambiar y la firma no va a coincidir.
¿Por qué usar comparación de tiempo constante para la firma?
Las comparaciones normales (==) son vulnerables a timing attacks. hmac.compare_digest, hash_equals y timingSafeEqual eliminan esa vulnerabilidad.
¿Qué hago si necesito procesar más de 10 segundos por evento?
Responde 200 OK inmediatamente después de verificar la firma y encola el procesamiento en un job/worker asíncrono.
¿Cómo prevengo procesar el mismo evento dos veces?
Usa X-Whalemate-Delivery-Id como clave de idempotencia. Guarda los IDs procesados y verifica antes de ejecutar. Ten en cuenta que el envío de prueba no trae ese header.
¿Tienes feedback o quieres solicitar mejoras? Háznoslo saber en roadmap.whalemate.com/roadmap