Webhook de WhatsApp: firma, reintentos y eventos duplicados
Webhook de WhatsApp para un agente de IA: verifica la firma Standard Webhooks, responde en 10 segundos, soporta reintentos y deduplica eventos. Código en TypeScript.

Lo esencial
- Un webhook de whasp va firmado con Standard Webhooks, se entrega al menos una vez y sin garantía de orden, así que un receptor debe verificar, deduplicar y tolerar el desorden.
- Verifica la firma contra el cuerpo en bruto de la petición, nunca contra un JSON vuelto a serializar, y rechaza las marcas de tiempo con más de cinco minutos de diferencia.
- Escribe el evento en almacenamiento durable, responde 2xx en menos de 10 segundos y haz el trabajo lento después: whasp conserva el cuerpo de un evento solo hasta que se entrega o se da por perdido.
- Si tu endpoint sigue fallando, whasp reintenta 12 veces a lo largo de unas ocho horas y después se rinde, y el contenido de un message.received fallido no se puede volver a enviar.
- Deduplica por el id del evento y envía cada respuesta con una Idempotency-Key construida a partir del id del mensaje entrante.
Un webhook de whasp es un POST HTTPS por evento, firmado con Standard Webhooks, entregado al menos una vez y sin orden garantizado. Un receptor fiable hace cuatro cosas: verifica la firma contra el cuerpo en bruto, escribe el evento en almacenamiento durable, responde 2xx en menos de 10 segundos y hace el trabajo lento después, deduplicando por el id del evento. Esta guía construye ese receptor en TypeScript.
¿Qué contiene una entrega de webhook?
Una entrega de whasp es un POST con un sobre JSON y tres cabeceras de firma. El sobre contiene el id del evento, su type, una hora created_at y los data del evento. Las cabeceras son webhook-id (el id del evento, el mismo en cada reintento), webhook-timestamp (segundos unix) y webhook-signature (v1, seguido de un HMAC-SHA256 en base64).
Existen ocho tipos de evento:
| Tipo | Cuándo se dispara | Qué hace un receptor |
|---|---|---|
message.received |
Una persona escribió a un número. Trae text, media y más. |
Guárdalo y ejecuta el agente. Es la única copia del contenido. |
message.sent |
Un mensaje saliente salió del teléfono. | Actualiza tu propio registro. |
message.failed |
Un mensaje saliente falló; el motivo está en message.error. |
Regístralo. Reintenta solo si el motivo es temporal. |
reaction.received |
Alguien reaccionó a un mensaje; un emoji vacío significa que se quitó. |
Normalmente, ignóralo. |
number.ready |
Un número que estaba en provisioning ya está activo. |
Empieza a dirigirle usuarios. |
number.status_changed |
Cambió health u online. |
Avisa si pasa a critical. |
number.replaced |
Se reemplazó un número baneado. Los dos números vienen en el evento. | Migrar usuarios a un número nuevo de WhatsApp. |
webhook.test |
Llamaste a POST /v1/webhook/test. |
Responde 2xx. |
¿Cómo se verifica la firma?
La verificación de la firma demuestra que una petición viene de whasp y que nadie cambió su cuerpo. La librería standardwebhooks lo hace en una sola llamada, y el proyecto publica librerías para JavaScript, Python, Go, Java, Ruby, Rust y .NET. Este es el algoritmo de la especificación de Standard Webhooks escrito con node:crypto, para stacks sin librería (en Hono, pasa c.req.raw.headers y el cuerpo como texto):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, headers: Headers, rawBody: string): boolean {
const id = headers.get("webhook-id") ?? "";
const timestamp = headers.get("webhook-timestamp") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= 300)) return false; // más de 5 minutos antes o después; también atrapa NaN
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
// La cabecera puede contener varias firmas separadas por espacios, p. ej. "v1,abc= v1,def=".
return (headers.get("webhook-signature") ?? "").split(" ").some((part) => {
const [version, signature] = part.split(",");
if (version !== "v1" || !signature) return false;
const got = Buffer.from(signature, "base64");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
Cinco errores suelen romper la verificación:
- JSON ya parseado. La firma cubre exactamente los bytes que envió whasp. Lee primero el cuerpo como texto, verifica y solo entonces parsea. Un objeto serializado de nuevo puede diferir en espacios en blanco o en el orden de las claves.
- La clave equivocada. Un secreto tiene la forma
whsec_más base64. La clave son los bytes decodificados, no la cadena. Ambas librerías quitan el prefijo y decodifican por ti. - Desfase del reloj. Se rechazan las marcas de tiempo a más de cinco minutos de tu reloj. Mantén el servidor sincronizado con NTP.
- Una comparación simple con
===. UsatimingSafeEqual, que además lanza una excepción cuando las longitudes difieren, de ahí la comprobación de longitud. - Leer solo la primera firma. La cabecera puede contener varias firmas separadas por espacios. Acepta la petición si coincide cualquiera de las
v1.
Responde 401 cuando falle la verificación. whasp lo trata como cualquier otro fallo y reintenta, así que un secreto equivocado no pierde eventos de golpe, pero sí cuando se agotan los reintentos. POST /v1/webhook/rotate-secret emite un secreto nuevo. La API no dice si whasp firma con ambos secretos durante una rotación, así que actualiza tu configuración enseguida; lo que se rechace entretanto se reintenta 30 segundos después.
¿Por qué responder primero y trabajar después?
Un receptor debe guardar el evento y responder primero, porque whasp espera 10 segundos un 2xx y el trabajo de un agente, una llamada al modelo o a una herramienta, suele tardar más. Responder tarde provoca un reintento, y un reintento provoca un duplicado.
Guardar va primero porque whasp conserva el cuerpo de un evento solo hasta que se entrega o se da por perdido, y los servidores de whasp no guardan mensajes. En cuanto tu endpoint devuelve 2xx, tienes la única copia de un message.received. El patrón es un inbox durable: inserta el evento en bruto, responde 204 y deja que un worker procese las filas de la tabla. A continuación está el receptor completo, con Hono y node:sqlite. Instala hono, @hono/node-server y standardwebhooks en un proyecto con "type": "module", define WHASP_WEBHOOK_SECRET y ejecútalo con node --env-file=.env webhook.ts en Node 22.18 o superior.
import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Webhook } from "standardwebhooks";
type WhaspEvent = { id: string; type: string; created_at: string; data: any };
const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!); // whsec_...
const MAX_ATTEMPTS = 6;
const db = new DatabaseSync("events.db");
db.exec(`
CREATE TABLE IF NOT EXISTS inbox (
id TEXT PRIMARY KEY, type TEXT NOT NULL, chat TEXT, body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | done | dead
attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT,
received_at INTEGER NOT NULL DEFAULT (unixepoch()));
`);
const app = new Hono();
app.post("/whasp/webhook", async (c) => {
const body = await c.req.text(); // los bytes en bruto que se firmaron
let event: WhaspEvent;
try {
event = wh.verify(body, c.req.header()) as WhaspEvent;
} catch {
return c.text("Invalid signature", 401);
}
const chat = event.data.message?.chat ?? event.data.chat ?? null;
// Durable antes del 2xx. Un id repetido no inserta nada y aun así recibe un 2xx.
db.prepare("INSERT OR IGNORE INTO inbox (id, type, chat, body) VALUES (?, ?, ?, ?)").run(
event.id, event.type, chat, body,
);
void drain(); // arranca ya; el temporizador de abajo solo recoge los reintentos
return c.body(null, 204);
});
// El worker: un evento a la vez por chat, muchos chats en paralelo.
const busy = new Set<string>();
async function drain() {
const due = db
.prepare("SELECT id, chat, body, attempts FROM inbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid")
.all(Date.now()) as { id: string; chat: string | null; body: string; attempts: number }[];
for (const row of due) {
const lane = row.chat ?? row.id;
if (busy.has(lane)) continue;
busy.add(lane);
void work(row).finally(() => busy.delete(lane));
}
}
async function work(row: { id: string; body: string; attempts: number }) {
try {
await handle(JSON.parse(row.body));
db.prepare("UPDATE inbox SET status = 'done' WHERE id = ?").run(row.id);
} catch (err) {
const attempts = row.attempts + 1;
const status = attempts >= MAX_ATTEMPTS ? "dead" : "pending";
const next = Date.now() + Math.min(2 ** attempts * 5_000, 300_000); // 10 s, 20 s, 40 s...
db.prepare("UPDATE inbox SET status = ?, attempts = ?, next_at = ?, error = ? WHERE id = ?").run(
status, attempts, next, String(err), row.id,
);
}
}
async function handle(event: WhaspEvent) {
switch (event.type) {
case "message.received":
console.log("inbound from", event.data.message.chat, event.data.message.text);
return; // llama aquí a tu agente y después a POST /v1/messages
case "message.failed":
console.warn("send failed", event.data.message.id, event.data.message.error);
return;
case "number.status_changed":
if (event.data.number.health === "critical") console.error("number critical", event.data.number.number);
return;
case "number.replaced":
console.warn("replaced", event.data.old_number, "->", event.data.new_number.number);
return;
default:
return; // message.sent, reaction.received, number.ready, webhook.test
}
}
setInterval(drain, 1000);
serve({ fetch: app.fetch, port: 3000 });
Tres partes cargan con el peso. El INSERT OR IGNORE sobre el id del evento es la deduplicación y la durabilidad en una sola instrucción. El bucle drain ejecuta un evento a la vez por chat y muchos chats en paralelo, así que una conversación lenta nunca bloquea a las demás. Y work registra cada fallo con un contador de intentos y una espera creciente, y después aparca la fila como dead tras seis intentos, de modo que un evento venenoso deja de repetirse y se queda en la tabla para que lo leas.
Borra a diario las filas terminadas con DELETE FROM inbox WHERE status = 'done' AND received_at < unixepoch() - 86400. Conserva los ids más tiempo que la ventana de reintentos de whasp, o un reintento tardío parecerá nuevo. Tras una caída de tu proveedor de modelos, vuelve a poner en cola las filas aparcadas con UPDATE inbox SET status = 'pending', attempts = 0 WHERE status = 'dead'.
¿Qué pasa cuando tu endpoint falla?
Cuando tu endpoint falla, whasp reintenta el mismo evento con esperas cada vez mayores. Un fallo es cualquier respuesta que no sea 2xx, un error de conexión o la falta de respuesta en 10 segundos. Un 401 por una firma incorrecta también cuenta.
| Reintento | Espera tras el fallo anterior |
|---|---|
| 1 | 30 segundos |
| 2 | 2 minutos |
| 3 | 10 minutos |
| 4 | 30 minutos |
| 5 a 12 | 1 hora cada uno |
Los doce reintentos abarcan unas ocho horas. Tras el último, la entrega se marca como failed, y como whasp descarta el cuerpo de un evento una vez entregado o dado por perdido, el texto de un message.received que falló todas las veces no se puede volver a enviar. Sin una URL de webhook, los eventos solo aparecen listados como no_endpoint y nunca se envían.
De ahí salen dos hábitos. Mantén el endpoint independiente de tu proveedor de modelos: con el inbox, una caída ahí se ve como filas en tu tabla, no como entregas fallidas. Y vigila GET /v1/webhook/deliveries, que lista los últimos 7 días con el status, los attempts, el last_status_code y el last_error de cada entrega.
¿Cómo se manejan los duplicados y el orden?
Los eventos duplicados son normales, porque la entrega es al menos una vez. El mismo evento llega dos veces cuando tu 2xx se pierde en la red o llega pasados 10 segundos. Deduplica por el id del evento (el mismo valor que webhook-id), sigue respondiendo 2xx a los repetidos y tu manejador se ejecutará una vez por evento.
El orden tampoco está garantizado. Un mensaje que necesitó un reintento puede llegar después del que la persona envió a continuación. El receptor de arriba ya procesa un chat a la vez. Ordena el historial que construyes para el modelo por message.created_at y no por orden de llegada, y cuenta con ráfagas: tres mensajes en diez segundos producen tres eventos, así que considera esperar dos o tres segundos por si llegan más antes de responder una sola vez.
Los duplicados también alcanzan lo que envías de vuelta. Envía cada respuesta con una Idempotency-Key construida a partir del id del mensaje entrante, por ejemplo reply-msg_..., y no con un valor aleatorio por intento. whasp conserva una clave durante 7 días por cuenta: la misma clave devuelve el primer mensaje y nunca envía dos veces. Un envío rechazado (4xx) no gasta la clave, y un 409 idempotency_conflict significa que la primera petición con esa clave sigue en curso, así que reintenta pasado un momento. El mismo cuidado vale para las herramientas con efectos secundarios, como reservas o reembolsos: un evento repetido vuelve a ejecutar tu agente, así que protégelas con el id del mensaje entrante, como explica el tutorial del bot de WhatsApp con Claude.
¿Cómo pruebas y depuras los webhooks?
Pruebas un webhook de whasp en cuatro pasos, y el último no necesita cuenta de whasp.
- Ejecuta el receptor y exponlo.
cloudflared tunnel --url http://localhost:3000da una dirección HTTPS temporal y no necesita cuenta de Cloudflare. - Regístralo con
PUT /v1/webhooky un cuerpo de{ "url": "https://.../whasp/webhook" }. La respuesta contiene el secreto. - Llama a
POST /v1/webhook/test. Envía unwebhook.testal instante y responde{ "delivered", "status_code", "error" }, así que ves lo que devolvió tu servidor. - Envía a localhost un evento falso firmado.
Webhook.signproduce el valor de la cabecera, así que este script no necesita cuenta de whasp ni túnel:
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!);
const event = {
id: `evt_local_${Date.now()}`,
type: "message.received",
created_at: new Date().toISOString(),
data: { message: { id: "msg_local_1", chat: "+15555550111", text: "Hola, esto es una prueba", media: null } },
};
const body = JSON.stringify(event);
const res = await fetch("http://localhost:3000/whasp/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
"webhook-id": event.id,
"webhook-timestamp": String(Math.floor(Date.now() / 1000)),
"webhook-signature": wh.sign(event.id, new Date(), body),
},
body,
});
console.log(res.status); // 204
Ejecútalo con node --env-file=.env send-test-event.ts mientras el receptor esté en marcha. Cambia type y data para ensayar message.failed o number.replaced del mismo modo.
¿Qué construir después?
Conecta el receptor a un agente: los tutoriales del bot de WhatsApp con ChatGPT y del bot de WhatsApp con Claude parten de este patrón. Dale al agente historial, resúmenes y datos, o construye el mismo flujo sin código en n8n. Cuando un número no basta, planifica la capacidad para varios.
Preguntas
¿Cómo verifico la firma de un webhook de whasp?
Recalcula un HMAC-SHA256 sobre la cadena webhook-id, un punto, webhook-timestamp, un punto y el cuerpo en bruto, usando como clave la parte decodificada en base64 de tu secreto whsec_, y compáralo en tiempo constante con el valor v1 de webhook-signature. La librería standardwebhooks de tu lenguaje lo hace en una sola llamada.
¿Cuántas veces reintenta whasp un webhook fallido?
Hasta 12 veces, tras 30 segundos, 2 minutos, 10 minutos, 30 minutos y después cada hora. Tras el último fallo la entrega se marca como fallida y se descarta el cuerpo del evento.
¿Puede llegar dos veces el mismo evento de webhook?
Sí. La entrega es al menos una vez, así que un 2xx perdido o una respuesta lenta produce un duplicado con el mismo id de evento. Guarda el id con una restricción de unicidad y responde 2xx a los repetidos.
¿Qué debe devolver un endpoint de webhook?
Cualquier estado 2xx en menos de 10 segundos cuenta como entregado, y basta con un 204 sin cuerpo. Un 4xx, un 5xx, un timeout o un error de conexión es un fallo y se reintenta.
¿Cómo pruebo un webhook de whasp en local?
Expón tu puerto con un túnel, registra la URL con PUT /v1/webhook y llama a POST /v1/webhook/test. También puedes firmar tú mismo un evento falso con la librería standardwebhooks y enviarlo a localhost.


