Webhooks firmados
En lugar de consultar la API en bucle, deja que WhatSetter te empuje los eventos a tu servidor en cuanto suceden. Cada entrega va firmada, así que puedes estar seguro de que viene de nosotros.
Los eventos
Sección titulada «Los eventos»| Evento | Se dispara cuando | Estado |
|---|---|---|
contact.qualified |
El agente califica un lead (según los criterios de tu prompt) | Activo |
contact.not_qualified |
El agente descarta un lead | Activo |
agent.disconnected |
Un número de WhatsApp se desconecta | Activo |
booking.created |
Se agenda una reunión en la conversación | Aún no se emite |
message.received |
Un lead responde | Aún no se emite |
Crear una suscripción
Sección titulada «Crear una suscripción»POST /webhooks necesita el scope webhooks:manage. La URL debe ser HTTPS (HTTP plano solo se acepta para localhost).
curl -X POST https://app.whatsetter.com/api/v1/webhooks \ -H "Authorization: Bearer $WHATSETTER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://tu-servidor.com/webhooks/whatsetter","events":["contact.qualified","contact.not_qualified"],"description":"Sync CRM"}'const res = await fetch('https://app.whatsetter.com/api/v1/webhooks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.WHATSETTER_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://tu-servidor.com/webhooks/whatsetter', events: ['contact.qualified', 'contact.not_qualified'], description: 'Sync CRM', }),});const { data } = await res.json();console.log(data.secret); // whsec_… se muestra una sola vez, guárdalo ahoraimport os, requests
r = requests.post( "https://app.whatsetter.com/api/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['WHATSETTER_API_KEY']}"}, json={ "url": "https://tu-servidor.com/webhooks/whatsetter", "events": ["contact.qualified", "contact.not_qualified"], "description": "Sync CRM", },)print(r.json()["data"]["secret"]) # whsec_… se muestra una sola vez, guárdalo ahora{ "data": { "id": "66f1c0ffee00000000000abc", "url": "https://tu-servidor.com/webhooks/whatsetter", "events": ["contact.qualified", "contact.not_qualified"], "active": true, "description": "Sync CRM", "secret_prefix": "whsec_3f9a", "secret": "whsec_3f9a…", "last_delivery_at": null, "last_delivery_status": null, "created_at": "2026-09-19T10:00:00.000Z" }}La respuesta contiene el secreto de firma (whsec_…). Solo se muestra aquí: guárdalo de inmediato. Las llamadas posteriores a GET /webhooks solo devuelven secret_prefix.
Errores que importan:
| HTTP | code |
Por qué |
|---|---|---|
| 422 | validation_error |
url ausente o sin HTTPS, events vacío o desconocido, description de más de 255 caracteres. |
| 429 | quota_exceeded |
El espacio ya tiene 10 suscripciones. Borra una primero. |
Probar la entrega
Sección titulada «Probar la entrega»POST /webhooks/{id}/test envía de inmediato un evento de ejemplo firmado a la URL de la suscripción e informa de cómo respondió tu endpoint. El ejemplo usa el primer evento de la suscripción y lleva "is_test": true.
curl -X POST https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test \ -H "Authorization: Bearer $WHATSETTER_API_KEY"const res = await fetch('https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test', { method: 'POST', headers: { Authorization: `Bearer ${process.env.WHATSETTER_API_KEY}` },});console.log(await res.json());import os, requests
r = requests.post( "https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test", headers={"Authorization": f"Bearer {os.environ['WHATSETTER_API_KEY']}"},)print(r.json()){ "data": { "delivered": true, "endpoint_status": 200, "error": null, "event_id": "evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013" }}delivered es false cuando tu endpoint respondió algo distinto de un estado 2xx (mira endpoint_status) o no se pudo alcanzar en 10 segundos (mira error). Un 404 not_found significa que el id de suscripción es desconocido o pertenece a otro espacio.
Lo que recibes
Sección titulada «Lo que recibes»Cada entrega comparte el mismo sobre. Los campos propios del evento van al lado de los del sobre, no anidados bajo una clave payload.
{ "event_type": "contact.qualified", "event_id": "evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013", "api_version": "2026-06-01", "occurred_at": "2026-09-15T09:10:12.517Z", "team_id": "6b0c1d2e3f405162738a", "is_test": false,
"campaign": { "id": "6b0c1d2e3f4051627abc", "slug": "acme-morocco-1-ab12", "name": "Alex" }, "contact": { "id": "6b0c1d2e3f4051627def", "lead_id": "212600000000@c.us", "name": "Sam", "phone": "212600000000", "classification": "qualified", "ai_reasoning": "Busca un SUV, presupuesto confirmado, quiere que le llamen.", "conversation_url": "https://app.whatsetter.com/dashboard/conversations?leadId=212600000000%40c.us", "external_reference": "your-crm-id-12345" }}Campos del sobre
Sección titulada «Campos del sobre»| Campo | Detalle |
|---|---|
event_type |
Uno de los eventos de arriba. |
event_id |
Único por entrega. Úsalo para deduplicar. |
api_version |
Actualmente 2026-06-01. |
occurred_at |
ISO 8601, en UTC. |
team_id |
El espacio de WhatSetter al que pertenece el evento. |
is_test |
true si se envió desde POST /webhooks/{id}/test. |
contact.qualified y contact.not_qualified
Sección titulada «contact.qualified y contact.not_qualified»| Campo | Detalle |
|---|---|
campaign.id |
Identificador interno de la campaña. |
campaign.slug |
Identificador estable y legible de la campaña. |
campaign.name |
El nombre visible del agente. |
contact.id |
Identificador interno del lead, el mismo id que en GET /leads. |
contact.lead_id |
Identificador de WhatsApp en bruto, por ejemplo 212600000000@c.us. |
contact.name |
Nombre detectado por el agente, puede ser null. |
contact.phone |
Solo dígitos, mira el aviso de abajo. |
contact.classification |
El veredicto del agente. |
contact.ai_reasoning |
Por qué el agente lo decidió así, en texto claro. |
contact.conversation_url |
Enlace directo a la conversación en WhatSetter. |
contact.external_reference |
Tu propio identificador, mira el consejo de abajo. |
agent.disconnected
Sección titulada «agent.disconnected»{ "event_type": "agent.disconnected", "event_id": "evt_…", "api_version": "2026-06-01", "occurred_at": "2026-09-14T13:00:09.250Z", "team_id": "6b0c1d2e3f405162738a", "is_test": false,
"agent": { "name": "Alex", "phone": "212611111111@c.us", "campaign_id": "acme-morocco-1-ab12", "campaign_name": "Acme Morocco 1", "session_status": "disconnected", "disconnected_at": "2026-09-14T12:54:00.000Z" }}Se envía una sola vez, al detectar el incidente. Los recordatorios de reconexión no lo reenvían.
Verificar la firma
Sección titulada «Verificar la firma»Cada entrega va firmada con HMAC-SHA256 del cuerpo en bruto de la petición, usando el secreto de tu suscripción. Las cabeceras que recibes:
Content-Type: application/jsonUser-Agent: Whatsetter-Webhooks/1.0X-Whatsetter-Event: contact.qualifiedX-Whatsetter-Delivery: evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013X-Whatsetter-Timestamp: 2026-09-15T09:10:12.517ZX-Whatsetter-Api-Version: 2026-06-01X-Whatsetter-Signature: sha256=<hex>X-Whatsetter-Delivery repite el event_id del sobre, y X-Whatsetter-Timestamp su occurred_at. Calcula sha256= + el HMAC hexadecimal del cuerpo en bruto y compáralo con X-Whatsetter-Signature en tiempo constante.
import crypto from 'node:crypto';
function verify(rawBody, header, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(rawBody) // el cuerpo EN BRUTO, antes de cualquier JSON.parse .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(header || ''); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express: conserva el cuerpo en bruto en esta ruta// app.post('/webhooks/whatsetter', express.raw({ type: 'application/json' }), (req, res) => {// if (!verify(req.body, req.get('X-Whatsetter-Signature'), process.env.WHATSETTER_WEBHOOK_SECRET)) {// return res.status(401).end();// }// const event = JSON.parse(req.body);// res.status(200).end(); // responde rápido, procesa en asíncrono// });import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header or "")
# Flask: usa request.get_data(), no request.json, para conservar el cuerpo en bruto# @app.post("/webhooks/whatsetter")# def whatsetter_webhook():# if not verify(request.get_data(), request.headers.get("X-Whatsetter-Signature", ""), os.environ["WHATSETTER_WEBHOOK_SECRET"]):# return "", 401# event = request.get_json()# return "", 200<?phpfunction verify(string $rawBody, string $header, string $secret): bool{ $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret); return hash_equals($expected, $header);}
$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_X_WHATSETTER_SIGNATURE'] ?? '';
if (!verify($rawBody, $header, getenv('WHATSETTER_WEBHOOK_SECRET'))) { http_response_code(401); exit;}$event = json_decode($rawBody, true);http_response_code(200);Reglas de entrega
Sección titulada «Reglas de entrega»- Un solo intento por evento. Si tu endpoint está caído o responde un error, el evento no se reintenta y se pierde. Mantén el endpoint disponible, y reconcilia con
GET /leadssi sospechas un hueco. - La petición caduca a los 10 segundos. Responde
2xxde inmediato y haz tu procesamiento de forma asíncrona; un timeout cuenta como fallo. - Deduplica igualmente por
event_id. No cuesta nada y te protege si más adelante se añaden reintentos. - Un evento se reparte a cada suscripción activa que lo incluya, hasta 10 suscripciones por espacio. Dos suscripciones a la misma URL reciben cada una su copia.
last_delivery_atylast_delivery_statusen la suscripción te dicen cómo fue la última entrega.
Gestionar las suscripciones
Sección titulada «Gestionar las suscripciones»- GET /webhooks lista tus suscripciones, sin el secreto.
DELETE /webhooks/{id}detiene las entregas de inmediato. Para cambiar la URL o los eventos, borra y vuelve a crear: obtienes un secreto nuevo.

