Volver al blog
Desarrolladores

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.

· 13 min
Cómo verificar la firma de los webhooks de Waspy

Respuesta corta: cada POST de webhook trae X-Waspy-Signature: t=,v1=. 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
PiezaQué es
tUnix en segundos, de este intento. Waspy lo vuelve a generar en cada reintento
v1HMAC-SHA256 hex de .
secretEl 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-AtUnix del primer intento de esta entrega. No entra en la firma
La clave no es el 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

  1. Tomá el body como bytes, antes de parsear JSON. Si tu framework reordena claves o cambia espacios, la firma no va a cerrar.
  2. Del header, separá por comas y después por el primer =. Te quedan t y v1.
  3. Calculá HMAC_SHA256(secret, t + "." + rawBody) y pasalo a hex.
  4. Si el hex calculado y v1 no tienen el mismo largo, rechazá. timingSafeEqual y compare_digest tiran en ese caso.
  5. Compará en tiempo constante.
  6. Rechazá si abs(ahora - t) > 300 segundos.

Eso es lo que firma el worker: createHmac('sha256', secret).update(t + "." + rawBody).digest('hex'), y el header queda t=,v1=.

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 HTTPQué hace Waspy
200–299Entrega ok. El contador de fallos vuelve a 0
300–399También ok. El POST ya corrió. No se reintenta ni se reenvía el POST a Location
400–599Fallo. Se reintenta
Sin respuesta en 10 segundosFallo
El caso de todos los días es Google Apps Script: 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íntomaCausa habitual
Nunca cierra, ni en el testFirmás el JSON re-serializado, o decodificás el whsec_
Cierra en local y falla en el serverUn proxy agregó un byte, o leíste req.body ya parseado
Falla solo en el reintentoComparaste t con X-Waspy-First-Attempt-At
401 y a los 10 la suscripción se apagaTu 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=)
La API permite 120 pedidos por minuto por cuenta, compartidos entre las claves. Verificar la firma no gasta ese cupo: el cupo es de las llamadas que vos hacés a 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.

Tu equipo atendiendo por WhatsApp en minutos

CRM facil pero potente. En 10 minutos tenes a tu equipo trabajando — no necesitas hablar con nadie.