Cómo verificar la firma de los webhooks de Waspy
La firma es HMAC-SHA256 de t, un punto y el body crudo, en X-Waspy-Signature. Si no cierra, respondé 401. Un 3xx igual cuenta como entrega.
Respuesta corta: cada POST de webhook trae X-Waspy-Signature: t=. v1 es el HMAC-SHA256, en hexadecimal, de t + . + el body crudo, usando el secret que te devolvió POST /api/v2/webhooks (el string completo whsec_..., sin decodificar). Comparalo en tiempo constante. Si t tiene más de 5 minutos, rechazalo. Si la firma no cierra, respondé 401.
El alta del webhook, los cuatro eventos y un servidor mínimo están en webhooks en Node y Python. Esta nota es solo la firma. El algoritmo publicado está en verificación de firma.
Qué llega en el header
X-Waspy-Signature: t=1745067296,v1=ab12cd34...
X-Waspy-Webhook-Id: <uuid de la suscripción>
X-Waspy-Webhook-Event: message.status_changed
X-Waspy-Webhook-Version: v1
X-Waspy-Webhook-Attempt: 1
X-Waspy-First-Attempt-At: 1745067296
| Pieza | Qué es |
|---|---|
t | Unix en segundos, de este intento. Waspy lo vuelve a generar en cada reintento |
v1 | HMAC-SHA256 hex de |
secret | El que devolvió el alta, una sola vez. Empieza con whsec_. Es la clave del HMAC tal cual, no un base64 para decodificar |
X-Waspy-First-Attempt-At | Unix del primer intento de esta entrega. No entra en la firma |
META_APP_SECRET ni un sha256= de Meta. Es el secret de tu suscripción. Si lo rotás con POST /webhooks/:id/rotate-secret (scope webhooks:write), el anterior deja de firmar en el momento. El nuevo secret también se muestra una sola vez.
El algoritmo
- Tomá el body como bytes, antes de parsear JSON. Si tu framework reordena claves o cambia espacios, la firma no va a cerrar.
- Del header, separá por comas y después por el primer
=. Te quedantyv1. - Calculá
HMAC_SHA256(secret, t + "." + rawBody)y pasalo a hex. - Si el hex calculado y
v1no tienen el mismo largo, rechazá.timingSafeEqualycompare_digesttiran en ese caso. - Compará en tiempo constante.
- Rechazá si
abs(ahora - t) > 300segundos.
Eso es lo que firma el worker: createHmac('sha256', secret).update(t + "." + rawBody).digest('hex'), y el header queda t=.
Node
import crypto from 'node:crypto';
function verifyWaspySignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=')),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(t, 10));
if (age > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// express.raw conserva los bytes. express.json() no sirve para firmar.
app.post('/webhook/waspy',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
const ok = verifyWaspySignature(
raw,
req.header('X-Waspy-Signature') || '',
process.env.WASPY_WEBHOOK_SECRET,
);
if (!ok) return res.status(401).end();
const event = JSON.parse(raw);
res.status(200).end();
// procesá event después de responder
},
);
El secret de process.env es el string whsec_... completo.
Python
import hmac, hashlib, time
def verify_waspy_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(int(time.time()) - int(t)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
if len(expected) != len(v1):
return False
return hmac.compare_digest(expected, v1)
En Flask o FastAPI leé el body con request.get_data() o await request.body(), no el dict ya parseado.
t no es la edad del evento
t se recalcula en cada reintento. Su único trabajo es la ventana de 5 minutos de este intento: si alguien te reenvía un POST viejo con la misma firma, t ya no entra en la ventana.
El worker hace hasta 5 intentos. Si falla, el siguiente sale a 1 minuto, después 5 minutos, 30 minutos y 2 horas. En cada uno cambia t y X-Waspy-Webhook-Attempt. X-Waspy-First-Attempt-At queda en la hora del primero.
Si tu regla es “no proceso eventos de más de N horas”, medí contra X-Waspy-First-Attempt-At, no contra t. Un reintento a las 2 horas sigue teniendo un t fresco: la firma es válida y el payload es el de hace 2 horas.
Qué respuesta cuenta como entrega
| Tu HTTP | Qué hace Waspy |
|---|---|
| 200–299 | Entrega ok. El contador de fallos vuelve a 0 |
| 300–399 | También ok. El POST ya corrió. No se reintenta ni se reenvía el POST a Location |
| 400–599 | Fallo. Se reintenta |
| Sin respuesta en 10 segundos | Fallo |
doPost corre y el runtime responde 302. Si eso se contara como error, Waspy repetiría el evento y, a los 10 fallos seguidos, pondría enabled: false. Para volver a activar, PATCH /webhooks/:id con enabled: true.
Si la respuesta trae Location, Waspy puede hacer un GET solo para anotar qué devolvió el redirect. Ese GET no cambia el resultado: la entrega ya estaba ok.
Respondé enseguida, aunque el trabajo tarde. Un 200 vacío alcanza. Si la firma falla, el 401 queda en lastErrorMessage y cuenta como fallo. La tabla pública está en códigos que cuentan como entrega.
Probar la firma antes de producción
curl -sS -X POST "https://api.waspytech.com/api/v2/webhooks/$WEBHOOK_ID/test" \
-H "Authorization: Bearer $WASPY_API_KEY"
Hace falta webhooks:write. Encola un message.received de prueba: el texto es This is a test event from Waspy., los ids son ceros y el waMessageId es wamid.TEST. La firma del POST que llega a tu URL se calcula sobre ese body, en el momento de entregarlo. El samplePayload de la respuesta del curl es un ejemplo: el deliveredAt del POST real lo pone el worker al enviar, así que no firmes contra el JSON del curl.
Ese test no pone en cero el contador de fallos. Una prueba en verde no borra nueve fallos reales.
Para listar suscripciones y el log de intentos, el scope es webhooks:read. Crear, editar, borrar, test, replay y rotar el secret piden webhooks:write. Una clave sin scopes tiene acceso completo. El detalle está en scopes.
Errores que parecen “la firma está mal”
| Síntoma | Causa habitual |
|---|---|
| Nunca cierra, ni en el test | Firmás el JSON re-serializado, o decodificás el whsec_ |
| Cierra en local y falla en el server | Un proxy agregó un byte, o leíste req.body ya parseado |
| Falla solo en el reintento | Comparaste t con X-Waspy-First-Attempt-At |
| 401 y a los 10 la suscripción se apaga | Tu 401 es un fallo. Arreglá la firma y volvé a activar con PATCH |
El header no trae t= | No es un webhook de Waspy. Meta usa otro formato (sha256=) |
api.waspytech.com. El límite está en rate limits.
Guardá data.id junto con event para no procesar dos veces el mismo mensaje si llega un replay. El estado de un envío, cruzado con tu número de pedido, está en clientRef.
La referencia de la suscripción está en webhooks. La API pública está en los planes Scale y Enterprise: la página de la API y Planes.
