What's new, 21 October 2026: A customer who sends several messages before the bot's first reply no longer gets the opening sequence of a Chat Flow twice
WASync · Developers

Webhooks

Un endpoint firmado por cuenta. Verificación de la firma, catálogo de eventos, política de reintentos y rotación del secreto.

Apunte WASync a su endpoint y recibirá un secreto de firma. Usa whatsapp.events. No requiere registrar ninguna aplicación ni un paso de consentimiento.

Un solo espacio de webhook por cuenta

Una cuenta tiene exactamente una URL de webhook. PUT /webhook la establece: si la llama de nuevo con otra URL, mueve el endpoint en lugar de añadir un segundo, y no existe eliminación. Esto importa en cuanto más de un componente quiere eventos: un disparador de n8n y su propio backend no pueden ser ambos dueños del espacio. Distribuya los eventos desde su lado.

Establecer el endpoint

PUT /webhook
curl -X PUT https://developers.wasync.app/api/v1/webhook \
  -H "Authorization: Bearer $WASYNC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/wasync/webhook"}'
200 response
{
  "url": "https://example.com/wasync/webhook",
  "events": [
    "message.received",
    "message.status",
    "connection.disconnected",
    "connection.connected"
  ],
  "secret": "whsec_3f9a…"
}

Guarde el secreto. Se entrega aquí porque este es el momento en que lo necesita para escribir su código de verificación, y GET /webhook no lo devolverá de nuevo: ese endpoint informa { url, events, secretSet }, deliberadamente sin el secreto, porque una ruta de lectura termina en los registros y pegada en tickets. ¿Lo perdió? Llame a POST /webhook/rotate para obtener uno nuevo. La URL debe ser HTTPS en un host accesible públicamente; http, localhost y los rangos privados se rechazan con 400 invalid_webhook.

La rotación no tiene ventana de solapamiento: el secreto anterior deja de valer en el instante en que se emite el nuevo. Despliegue el nuevo secreto sin demora: la política de reintentos descrita más abajo cubre un despliegue que dure segundos, no horas.

Catálogo de eventos

EventoSe dispara cuandoDirección
message.receivedLlega un mensaje de WhatsApp a una conexión autorizada (texto o multimedia).Solo entrante: los mensajes que usted envía no lo disparan.
message.statusCambia el estado de entrega de uno de sus mensajes salientes.Solo saliente.
connection.disconnectedUna conexión dejó de funcionar y su titular debe vincularla de nuevo.Una vez por caída, no repetidamente mientras siga averiada.
connection.connectedUna conexión que se había averiado vuelve a funcionar.Una vez, y solo para una conexión de la que se le informó que se había averiado.

Ignore los valores de event desconocidos en lugar de tratarlos como errores: los tipos nuevos se anuncian en el registro de cambios antes de lanzarse.

Los eventos se disparan solo para conexiones qr. Las conexiones cloud_api no emiten webhooks por ahora; consulte GET /messages y GET /connections/{id} para ellas.

Carga útil

POST your webhook URL
{
  "event": "message.received",
  "connection_id": "conn_8f3a21",
  "message": {
    "id": "cmqj3k2ab0001xyz",
    "wa_id": "[email protected]_3EB0A1B2C3",
    "from": "40700000000",
    "text": "Hello!",
    "media_url": null,
    "media_type": null,
    "timestamp": 1766138640000
  }
}
  • message.id: el id estable de WASync (el mismo valor que GET /messages devuelve como id). Deduplique y relacione por él: la entrega es al menos una vez.
  • message.wa_id: el id propio de WhatsApp, null cuando se desconoce. Solo para soporte y depuración, nunca como clave de deduplicación ni de unión.
  • from: solo dígitos internacionales, sin +.
  • text es null en los mensajes que solo contienen multimedia; media_url / media_type son null en los mensajes de texto o cuando el archivo no se puede obtener públicamente.
  • timestamp: epoch en milisegundos como número, no una cadena ISO.

Una entrega message.status actualiza uno de sus mensajes salientes:

POST your webhook URL
{
  "event": "message.status",
  "connection_id": "conn_8f3a21",
  "message": {
    "id": "cmqj3k2ab0001xyz",
    "wa_id": "[email protected]_3EB0A1B2C3",
    "status": "read",
    "timestamp": 1766138641000
  }
}

Verificar la firma

Cada entrega incluye X-WASync-Timestamp (epoch en ms) y X-WASync-Signature: sha256=<hex>, que es HMAC-SHA256(timestamp + "." + rawBody, secret). Calcúlela sobre el cuerpo sin procesar (antes de cualquier análisis JSON) y compare en tiempo constante. Rechace las entregas cuya marca de tiempo tenga más de ~5 minutos, lo que bloquea los ataques de repetición.

verify.js
import crypto from "node:crypto";

// rawBody MUST be the exact bytes you received (verify BEFORE JSON.parse).
export function verifyWASync(headers, rawBody, secret) {
  const ts  = headers["x-wasync-timestamp"];
  const sig = headers["x-wasync-signature"];          // "sha256=<hex>"
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret)
          .update(`${ts}.${rawBody}`)
          .digest("hex");
  return Boolean(sig) &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
receiver (express)
import express from "express";
import { verifyWASync } from "./verify.js";

const app = express();

// Capture the RAW body — signature verification needs the exact bytes.
app.post("/webhooks/wasync", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyWASync(req.headers, req.body.toString("utf8"), process.env.WASYNC_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  // Reject stale deliveries (replay window) — 5 minutes is a sane tolerance.
  if (Math.abs(Date.now() - Number(req.headers["x-wasync-timestamp"])) > 5 * 60_000) {
    return res.status(401).end();
  }

  res.status(200).end(); // ACK fast — under 10s — then process async
  const { message } = JSON.parse(req.body.toString("utf8"));
  // ... your logic (dedupe on message.id — retries can deliver duplicates)
});

Política de entrega y reintentos

PropiedadValor
Criterio de éxitoCualquier 2xx. Todo lo demás, incluidas las redirecciones, es un fallo.
Intentos3 por evento: inmediato, luego tras 500 ms y luego tras 1 s.
Tiempo límite por intento10 segundos. Confirme rápido y procese de forma asíncrona.
Tras el último falloEl evento se descarta. No hay una cola larga de reentrega.
DuplicadosPosibles. Deduplique por message.id.
OrdenNo garantizado. Ordene por timestamp si es importante.
Firma en los reintentosSe reenvían el mismo cuerpo y la misma firma: verifique cada intento de la misma manera.

Escalera de estados

Los valores de message.status forman una escalera de un solo sentido: sent (1) → delivered (2) → read (3), con failed como resultado terminal que no sigue a read.

Aplique las actualizaciones de forma monótona. Aplique un estado nuevo solo si su rango es mayor que el que tiene almacenado. Un mensaje puede llegar con read antes que delivered (una entrega tardía), y son posibles los eventos duplicados. Nunca retroceda el estado de un mensaje en su interfaz.

Conciliación: consulte periódicamente como red de seguridad

Las entregas se intentan tres veces y luego se descartan, por lo que el manejador de su webhook es la vía rápida, no la garantía. Ejecute un bucle en segundo plano que consulte GET /messages?connectionId=… cada 30–60 segundos e inserte o actualice todo lo que el manejador no haya visto, usando el cursor para obtener los datos de forma incremental. El mismo bucle recupera las actualizaciones de estado que llegaron mientras su endpoint estaba caído.

Webhooks = actualizaciones de la interfaz en tiempo real. Consultas periódicas = corrección. Ejecute ambos.

Ciclo de vida de la conexión

POST your webhook URL
{
  "event": "connection.disconnected",
  "connection_id": "conn_8f2a…",
  "connection": {
    "id": "conn_8f2a…",
    "phone_number": "393331234567",
    "label": "Studio Rossi",
    "status": "disconnected",
    "needs_reconnect": true,
    "license_status": "active",
    "license_expires": "2027-08-08T09:00:00.000Z",
    "reason": "AUTH_LOST"
  },
  "timestamp": 1786000000000
}

Una conexión qr puede dejar de funcionar por motivos ajenos al control de cualquiera: el teléfono se apaga durante un día, se abre WhatsApp en otro dispositivo, la sesión caduca. El número queda entonces en silencio en ambas direcciones, y sin un evento, la primera persona en notarlo es su cliente.

El código QR no está en la carga útil, a propósito. Un QR de WhatsApp es una credencial de inicio de sesión por escaneo: cualquiera que lo lea puede tomar el control de la sesión. El evento le indica que hace falta una nueva vinculación: obtenga el QR mediante su GET /connections/{id} autenticado y muéstreselo al titular del número, exactamente como en la configuración inicial. Guíe su interfaz con needs_reconnect; trate reason como una pista de registro orientativa, ya que pueden aparecer valores nuevos.

Latencia de detección: una desconexión se detecta mediante una sonda de estado de 5 minutos (~5 min). Cuando la sesión parece recuperable, primero se intenta una reparación automática, de modo que ese camino tarda ~15–20 minutos.

¿No recibe eventos?

  • ¿La URL de entrega es HTTPS en un host público? Localhost, las IP privadas y http:// se rechazan. Use un túnel durante el desarrollo y compruebe qué es lo que se almacenó realmente con GET /webhook.
  • ¿La credencial incluye whatsapp.events y cubre la conexión en la que llegó el mensaje?
  • ¿La licencia de la conexión sigue activa? Una licencia vencida significa que no hay eventos y 402 en los envíos.
  • ¿Su endpoint responde 2xx en menos de 10 segundos? Los manejadores lentos parecen fallos y consumen los tres intentos.
  • ¿Es una conexión qr? Las conexiones cloud_api no emiten webhooks.
  • Solo los mensajes entrantes disparan message.received: sus propios envíos nunca lo hacen.
¿Le resultó útil esta página?

On this page