Dev

Webhook de WhatsApp en producción: idempotencia y retry

En producción, un webhook de WhatsApp tiene que hacer cuatro cosas: validar la firma, responder 2xx en pocos segundos, tratar la misma entrega dos veces sin duplicar el efecto y dejar rastro para depurar. El servidor de ZapZap intenta entregar cada evento y, si tu endpoint falla, lo reenvía siguiendo una escalera de tiempos. Eso vuelve la duplicidad un caso normal, no un error raro. Esta guía muestra el formato del evento, la verificación de la firma, la deduplicación y cómo depurar con los endpoints de entregas y errores.

Publicado el · 7 min de lectura

Este artículo también está disponible en: Português · English

¿Qué llega a tu servidor con cada evento?

Cada evento llega como un POST con cuerpo JSON dentro de un sobre. Los campos de primer nivel son delivery_id (identifica el intento de entrega), event_id (el ID del mensaje cuando el evento es de mensaje, o nulo), version, instance_id, instance_name, event (messages, messages_update, connection y así sucesivamente) y data con el contenido. Si asociaste metadata a la instancia, también viene en el cuerpo. En un mensaje de texto recibido, el contenido está en data.message.text, el contacto en data.message.chatid y la marca de mensaje propio en data.message.fromMe.

Los encabezados traen X-ZapZap-Id (el mismo delivery_id), X-ZapZap-Event (el nombre del evento) y, cuando la instancia tiene secreto, X-ZapZap-Signature con el formato t=<unix>,v1=<hmac>. El secreto (empieza con whsec_) se genera la primera vez que configuras el webhook y vuelve en el campo webhook_secret de la respuesta de configurar webhook y de la de ver webhook. Para el paso básico de recibir mensajes, mira antes la guía de recibir mensajes por webhook y la página de webhooks en la documentación.

Los eventos vienen desactivados por defecto. Activa solo los que vayas a consumir: messages, messages_update y connection cubren la mayoría de las integraciones. Los eventos de volumen alto, como presence e history, solo valen la pena si de verdad vas a procesarlos.

Configurar a URL, os eventos e pegar o segredo

curl -X PUT "https://api.zapzapapi.com/api/v1/instances/$INSTANCE_ID/webhook" \
  -H "x-api-key: $ZAPZAP_API_KEY" \
  -H "x-api-secret: $ZAPZAP_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://api.seudominio.com/webhooks/zapzap",
    "enabled": true,
    "events": ["messages", "messages_update", "connection"]
  }'
# A resposta traz "webhook_secret": "whsec_..." (guarde em variável de ambiente)

¿Cómo validar la firma HMAC del webhook?

La firma prueba que el evento vino de ZapZap y que el cuerpo no fue alterado. El cálculo es un HMAC SHA-256 sobre la cadena timestamp, punto y cuerpo crudo (t + '.' + cuerpo), usando el secreto de la instancia. El resultado en hexadecimal debe ser igual al v1 del encabezado. El timestamp t también sirve contra replay: rechaza entregas con más de 5 minutos de diferencia respecto a tu reloj.

El punto que más rompe esta validación es calcular el HMAC sobre el JSON ya parseado. El cuerpo tiene que ser el texto exacto recibido, byte por byte. En Express eso significa usar express.raw en la ruta del webhook y recién después hacer el JSON.parse. Compara con timingSafeEqual (o hmac.compare_digest en Python) para no filtrar información por el tiempo de respuesta.

Un cuidado importante: si devuelves 4xx para una entrega legítima, ZapZap lo trata como falla y reenvía. Usa 401 solo para quien realmente no pasó la validación, y no para errores tuyos de procesamiento.

Node.js (Express): assinatura, deduplicação e resposta rápida

const crypto = require("crypto");
const express = require("express");
const app = express();

const SECRET = process.env.ZAPZAP_WEBHOOK_SECRET; // whsec_...
const seen = new Map(); // em produção: Redis SET NX EX ou índice único no banco

function assinaturaValida(raw, header) {
  const partes = Object.fromEntries(
    String(header || "").split(",").map((p) => p.trim().split("="))
  );
  const t = Number(partes.t);
  if (!t || !partes.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const esperado = crypto
    .createHmac("sha256", SECRET)
    .update(t + "." + raw)
    .digest("hex");
  const a = Buffer.from(esperado);
  const b = Buffer.from(partes.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/zapzap",
  express.raw({ type: "application/json", limit: "2mb" }),
  (req, res) => {
    const raw = req.body.toString("utf8");
    if (!assinaturaValida(raw, req.get("X-ZapZap-Signature"))) {
      return res.sendStatus(401);
    }
    const evento = JSON.parse(raw);
    const chave = req.get("X-ZapZap-Id") || evento.delivery_id;
    if (seen.has(chave)) return res.sendStatus(200); // reentrega: já tratada
    seen.set(chave, Date.now());

    res.sendStatus(200); // confirma primeiro
    setImmediate(() => processar(evento).catch(console.error)); // trabalha depois
  }
);

async function processar(evento) {
  const msg = evento.data && evento.data.message;
  if (evento.event !== "messages" || !msg || msg.fromMe) return;
  console.log("mensagem de", msg.chatid, ":", msg.text);
}

app.listen(3000);

Python (Flask): a mesma validação

import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["ZAPZAP_WEBHOOK_SECRET"].encode()

def assinatura_valida(raw: bytes, header: str) -> bool:
    try:
        partes = dict(p.strip().split("=", 1) for p in header.split(","))
        t = int(partes["t"])
    except (KeyError, ValueError, AttributeError):
        return False
    if abs(time.time() - t) > 300:
        return False
    esperado = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperado, partes.get("v1", ""))

@app.post("/webhooks/zapzap")
def webhook():
    raw = request.get_data()  # corpo bruto, antes de qualquer parse
    if not assinatura_valida(raw, request.headers.get("X-ZapZap-Signature", "")):
        return "", 401
    evento = request.get_json(force=True)
    # deduplique por request.headers["X-ZapZap-Id"] e enfileire o trabalho
    return "", 200

¿Cómo funciona el retry y qué cuenta como entrega?

Valores verificados en el sistema el 27/10/2026, sujetos a ajuste. El primer intento ocurre al instante, con timeout de 10 segundos. Un status menor a 400 cuenta como entregado. Cualquier 4xx, 5xx, timeout o error de red entra en una cola de reintentos: después del primer intento, la cola vuelve a intentar a los 30 segundos, 2 minutos, 10 minutos y 1 hora. Cada reintento lleva el mismo delivery_id y se firma de nuevo con un timestamp nuevo.

Hay dos frenos de seguridad. Si el endpoint falla muchas veces seguidas, el sistema deja de encolar nuevos reintentos por unos minutos para no llenar la cola con un destino muerto. Y si las fallas son permanentes y seguidas, recibes un aviso y, tras algunas más, el webhook se desactiva hasta que lo corrijas y lo reactives. Por eso monitorear la salud del endpoint no es opcional.

La URL tiene que ser HTTPS y pública. Las direcciones privadas, localhost y las IP internas se rechazan al configurar, y el servidor hace una verificación de alcance al guardar. Ese es el motivo más común de 'lo guardé y no llega nada': mira la guía de webhook que no llega.

Idempotencia: ¿cómo evitar procesar dos veces?

Idempotente significa que procesar la misma entrega dos veces produce el mismo resultado que procesarla una. Con reintentos, la duplicidad va a ocurrir: tu servidor procesa, la respuesta se pierde, ZapZap reenvía. La defensa es guardar una clave única antes de actuar y rechazar la segunda.

Usa dos capas. Para reintentos, la clave es el X-ZapZap-Id (delivery_id), que se repite en la reentrega. Para duplicidad en el origen, la clave es la combinación de event con event_id: un mismo mensaje puede generar eventos distintos (messages y luego messages_update de entregado y de leído), así que no uses solo el event_id. Guarda la clave con vencimiento (por ejemplo, algunos días) en Redis con SET NX EX, o en una tabla con índice único.

Filtra también fromMe desde el inicio. Cuando tu propio sistema envía un mensaje, el evento vuelve al webhook, y responderle crea un bucle. La regla es: si data.message.fromMe es verdadero, responde 200 y detente.

Por último, separa 'confirmar' de 'procesar'. El endpoint solo valida, guarda la clave, pone el evento en una cola y responde 200. Quien consume la cola hace el trabajo pesado (IA, base de datos, envío de respuesta). Si el procesamiento falla después del 200, el evento ya está en tu cola y lo reprocesas desde ahí, sin depender del retry de ZapZap.

¿Cómo depurar cuando algo no llega?

La API tiene tres endpoints de diagnóstico. La prueba de webhook envía un evento de prueba firmado a la URL configurada (o a una URL alternativa) y devuelve el status HTTP y la latencia. Las entregas recientes listan, por evento, si fue entregado, el status, la latencia y el número de intento. Los errores del webhook muestran las últimas fallas con su motivo, como timeout.

La secuencia práctica: ejecuta la prueba, revisa el status devuelto, abre las entregas para ver si los eventos reales llegan con 2xx y, si hay fallas, lee los errores. Un status 200 en la prueba con eventos reales fallando suele indicar un tiempo de respuesta alto bajo carga. Un timeout constante indica que el procesamiento está dentro del camino de la respuesta.

Testar, ver entregas e ver erros

BASE="https://api.zapzapapi.com/api/v1"
H1="x-api-key: $ZAPZAP_API_KEY"
H2="x-api-secret: $ZAPZAP_API_SECRET"

# 1) evento de teste assinado
curl -X POST "$BASE/instances/$INSTANCE_ID/webhook/test" -H "$H1" -H "$H2" \
  -H "Content-Type: application/json" -d '{}'

# 2) últimas entregas (máximo 100)
curl "$BASE/instances/$INSTANCE_ID/webhook/deliveries?limit=20" -H "$H1" -H "$H2"

# 3) últimos erros de entrega
curl "$BASE/$INSTANCE_ID/webhook/errors" -H "$H1" -H "$H2"

Checklist de producción

[ ] URL HTTPS pública, con certificado válido, en un dominio tuyo

[ ] Firma validada sobre el cuerpo crudo, con tolerancia de 5 minutos

[ ] Respuesta 2xx en pocos segundos, con el trabajo pesado en una cola

[ ] Clave de deduplicación (X-ZapZap-Id, y event más event_id) con vencimiento

[ ] fromMe filtrado para no responderte a ti mismo

[ ] Solo los eventos que consumes activados en el webhook

[ ] Alerta cuando las entregas recientes muestran fallas seguidas

Para el flujo sin código, el nodo y las plantillas de n8n ya reciben el webhook y la integración por webhook describe el formato. La visión general de los recursos está en la página de API de WhatsApp.

Resumen rápido

  • Valida la firma X-ZapZap-Signature sobre el cuerpo crudo (HMAC SHA-256 de t + punto + cuerpo) y rechaza timestamps con más de 5 minutos de diferencia.
  • Responde 2xx rápido y lleva el trabajo pesado a una cola. El timeout de la entrega es de 10 segundos.
  • Las entregas fallidas se reenvían a los 30 segundos, 2 minutos, 10 minutos y 1 hora, con el mismo delivery_id. Deduplica por él.
  • Filtra fromMe y combina event con event_id: un mismo mensaje genera más de un evento.
  • Depura con la prueba de webhook, las entregas recientes y los errores del webhook.

Lee también

Preguntas frecuentes

¿El webhook de ZapZap tiene retry automático?

Sí. El primer intento es inmediato, con timeout de 10 segundos. Si falla (status 400 o mayor, timeout o error de red), la cola vuelve a intentar a los 30 segundos, 2 minutos, 10 minutos y 1 hora. Valores verificados el 27/10/2026.

¿Cómo sé que el webhook realmente vino de ZapZap?

Por el encabezado X-ZapZap-Signature, con el formato t=<unix>,v1=<hmac>. Recalcula el HMAC SHA-256 de t, punto y cuerpo crudo con el webhook_secret de la instancia y compara. Rechaza timestamps antiguos para evitar replay.

¿Puedo recibir el mismo evento dos veces?

Sí, sobre todo después de una falla de red o de una respuesta lenta. Guarda el X-ZapZap-Id y rechaza lo que ya se procesó. Para duplicidad en el origen, usa la combinación de event y event_id.

¿Por qué se desactivó mi webhook?

Las fallas permanentes y seguidas del endpoint generan un aviso y, tras algunas más, la desactivación del webhook. Corrige el endpoint, ejecuta la prueba de webhook y configura la URL de nuevo.

¿El webhook funciona con HTTP o con localhost?

No. La URL tiene que ser HTTPS y pública. Las direcciones locales y privadas se rechazan al configurar. Para probar en desarrollo, usa un túnel HTTPS o un servidor de pruebas.

Configura el webhook y pruébalo en minutos

Crea la cuenta, verifica tu teléfono y recibe el crédito de bienvenida, sin depositar (uno por teléfono, sujeto a la campaña vigente). Conecta un número y usa el endpoint de prueba para ver la primera entrega firmada.