Guías
Webhooks
heysteff te avisa por HTTP cuando pasa algo en tu workspace: un mensaje nuevo, una escalación, un pago. Cada entrega va firmada para que sepas que viene de heysteff.
Configurar un webhook#
En Configuración → Desarrolladores → Webhooks salientes crea un webhook con la URL HTTPS de tu servidor y elige los eventos. Un webhook sin eventos elegidos recibe todos. Al crearlo te muestra un secreto: guárdalo, es la clave para verificar las firmas. Con Enviar evento de prueba te llega un webhook.test firmado igual que los demás.
También por API, con una key con el scope webhooks:write: POST /v1/webhooks lo crea y devuelve el secreto en secret una sola vez; PATCH /v1/webhooks/{id} cambia los eventos o lo pausa (is_active: false); DELETE /v1/webhooks/{id} lo borra. Una key de prueba responde 403 test_key_not_allowed. Con el CLI: heysteff webhooks create --url … --event ….
curl https://app.heysteff.com/api/v1/webhooks \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.tutienda.com/heysteff/webhooks",
"events": ["message:received", "message.delivered", "message.read", "message.failed"]
}'Eventos#
| Evento | Cuándo llega |
|---|---|
message:received | Un cliente escribió por cualquier canal. |
message:sent | Salió un mensaje hacia un cliente. |
conversation.escalated | Una conversación pasó a una persona del equipo. |
message.delivered | Un mensaje que mandaste llegó al teléfono del cliente (WhatsApp, Instagram, Messenger, SMS). Uno por mensaje. |
message.read | El cliente leyó tu mensaje (WhatsApp, Instagram, Messenger). Uno por mensaje. |
channel.connected | Un canal quedó conectado o reconectado en tu espacio, por cualquier camino (la consola, el panel, el MCP o un link de conexión). data: channel_connection_id, channel_type, display_name, developer_customer_id (o null) y reconnected. |
customer.channel_connected | Uno de tus clientes terminó de conectar un canal con su link de conexión. Ver Clientes y links de conexión. |
message.failed | Un mensaje no se pudo entregar. |
agent.replied, agent.handoff, agent.failed | El agente de IA respondió, pasó la conversación a una persona o no pudo responder. Ver Agente de IA. |
insight.detected | Un análisis de tus conversaciones encontró algo que revisar. Ver Agente de IA. |
call.started | Empezó una llamada de WhatsApp: está sonando o ya la contestaron. Ver Llamadas de WhatsApp. |
call.ended | Terminó una llamada que alguien contestó (con duration_seconds) o falló. |
call.missed | Una llamada terminó sin que nadie contestara, o la rechazaron. |
conversation.urgent | Una escalación quedó con urgencia alta. |
flow.completed | Terminó una captura estructurada (por ejemplo, una reseña). |
cart.abandoned, cart.recovered | Un carrito quedó abandonado o se recuperó. |
purchase.completed | Se registró una compra. |
payment.paid, payment.failed, payment.refunded | Cambió el estado de un cobro. |
campaign.completed | Terminó la corrida de una campaña. |
channel.error | Un canal tiene un problema que requiere atención. |
conversation.created | Se creó una conversación, por cualquier camino: un cliente escribió por primera vez, la abriste por la API, una campaña o un flujo. |
conversation.inactive | Una conversación abierta lleva inactivity_hours sin mensajes (por webhook, de 1 a 720, por defecto 24). Llega una vez por cada silencio. |
conversation.ended | La conversación se cerró. close_kind dice cómo: resolved, archived o auto_archived. |
conversation.assigned | Cambió la persona a cargo (assigned_user_id y previous_assigned_user_id). |
conversation.bot_toggled | El bot se pausó o se reanudó (bot_enabled, paused_by). |
contact.updated | Cambió el nombre, el teléfono, el correo o los ids de canal de un contacto. changes trae el antes y el después. |
contact.opted_out, contact.opted_in | El contacto se dio de baja en un canal (por ejemplo, contestó "BAJA") o volvió a aceptar mensajes. |
channel.disconnected, channel.reconnected | Un canal dejó de funcionar (se desconectó o el proveedor avisó que se fue), o el proveedor avisó que volvió. Si lo vuelves a conectar tú, llega channel.connected. |
channel.restricted | Meta restringió, sancionó o deshabilitó la cuenta de WhatsApp. restriction trae lo que informó Meta. |
webhook.paused | Otro webhook del workspace quedó en pausa por fallar demasiado (ver Pausa automática). |
webhook.test | Lo mandaste tú con Enviar evento de prueba, para probar tu servidor. Llega a ese webhook aunque no esté suscrito a ningún evento. |
message.delivered, message.read y message.failed traen message_id, conversation_id, channel_type, external_message_id, status y timestamp; message.failed agrega error: { code, message }. Llega uno por cambio de estado: un recibo repetido del proveedor no se vuelve a avisar. message:received trae interactive_reply: { id, title } cuando el cliente toca un botón o una opción de lista, y flow_response: { flow_token, data } cuando envía un WhatsApp Flow (en los demás mensajes, los dos son null).
call.started, call.ended y call.missed traen call_id (el id de GET /v1/calls), wa_call_id, channel_connection_id, conversation_id, contact_id, direction (inbound o outbound), status, customer (el teléfono del cliente), started_at, ended_at, duration_seconds y error. Para que lleguen, la app de Meta del número tiene que tener suscrito el campo de webhook calls.
La lista completa de eventos aparece al crear el webhook en el panel.
Cuerpo de cada entrega#
Un POST con Content-Type: application/json y este sobre. Lo propio de cada evento viaja en data:
{
"id": "5d1f0c7e-8a2b-4c3d-9e4f-0a1b2c3d4e5f",
"tenant_id": "2c7e9b1a-3d4f-4a5b-8c6d-7e8f9a0b1c2d",
"event": "message:received",
"ts": "2026-10-31T14:03:52.104Z",
"data": {
"conversation_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"message_id": "4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b",
"channel_type": "whatsapp_cloud",
"channel": "whatsapp_cloud",
"channel_connection_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"external_user_id": "56912345678",
"bot_paused": false,
"bot_paused_by": null,
"sandbox": false,
"content": "Gracias, ¿a qué hora llega?",
"content_type": "text",
"contact_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"media_url": null,
"filename": null
}
}En message:received y message:sent, data trae además lo necesario para contestar y saber si hablar (ver Conecta tu propia IA):
| Campo | Qué es |
|---|---|
channel | Canal de la conversación. Va tal cual como channel de POST /v1/messages (igual a channel_type). |
channel_connection_id | Número o cuenta por donde va la conversación, o null. |
external_user_id | A quién responder: va como to de POST /v1/messages. |
contact_id | Contacto de la conversación, o null. |
bot_paused | true si una persona o un flujo tiene la conversación: tu IA no debería contestar. |
bot_paused_by | Quién la pausó (api_escalation, api, human_agent, flow:<id>…), o null sin pausa. |
sandbox | true si la conversación es del sandbox. |
| Header | Contenido |
|---|---|
X-Heysteff-Event | Nombre del evento. |
X-Heysteff-Timestamp | Segundos unix del momento en que se firmó la entrega. |
X-Heysteff-Signature | v1= seguido del HMAC-SHA256 en hexadecimal. |
X-Heysteff-Delivery-Id | Id de la entrega a este webhook. Se repite en sus reintentos. |
X-Heysteff-Idempotency-Key | Id estable del evento (igual al id del sobre): el mismo en cada reintento y en todos tus webhooks que reciben el mismo evento. Úsalo para deduplicar. |
X-Heysteff-Batch, X-Heysteff-Batch-Size | Solo en un POST agrupado: true y cuántos eventos trae events (ver Agrupar mensajes rápidos). |
Formato Meta. Si ya tienes un receptor hecho para los webhooks de la Cloud API de WhatsApp, crea el webhook con format: "meta": recibe el payload de Meta (object: "whatsapp_business_account") firmado con X-Hub-Signature-256, solo para los eventos de mensajes de WhatsApp Cloud. Ver Compatible con Meta.
Verificar la firma#
La firma es un HMAC-SHA256 con el secreto del webhook sobre el string ${timestamp}.${body}, donde timestamp es el header X-Heysteff-Timestamp y body es el cuerpo crudo tal como llegó. Para aceptar una entrega:
- Lee el cuerpo crudo, sin parsearlo ni volver a serializarlo.
- Rechaza la entrega si el timestamp difiere en más de 300 segundos de tu reloj.
- Calcula
v1=+ HMAC y compáralo con el header en tiempo constante.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.HEYSTEFF_WEBHOOK_SECRET;
// La firma se calcula sobre el cuerpo CRUDO: no lo parsees antes de verificar.
app.post('/webhooks/heysteff', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Heysteff-Timestamp') ?? '';
const signature = req.get('X-Heysteff-Signature') ?? '';
const body = req.body.toString('utf8');
// Rechaza entregas de hace más de 5 minutos (o del futuro): evita reenvíos.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return res.status(400).send('timestamp fuera de rango');
const expected = 'v1=' + crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${body}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('firma inválida');
}
const event = JSON.parse(body);
// Deduplica por X-Heysteff-Delivery-Id: un reintento puede llegar dos veces.
console.log(event.event, event.data);
res.sendStatus(200);
});
app.listen(3000);import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["HEYSTEFF_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/heysteff")
def heysteff_webhook():
timestamp = request.headers.get("X-Heysteff-Timestamp", "")
signature = request.headers.get("X-Heysteff-Signature", "")
body = request.get_data(as_text=True) # cuerpo crudo, sin re-serializar
# Rechaza entregas de hace más de 5 minutos (o del futuro).
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
abort(400)
expected = "v1=" + hmac.new(
SECRET, f"{timestamp}.{body}".encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.get_json()
# Deduplica por X-Heysteff-Delivery-Id antes de procesar.
print(event["event"], event["data"])
return "", 200Vector de prueba#
Prueba tu implementación con estos valores fijos. Si tu código produce la misma firma, verifica igual que heysteff:
| Dato | Valor |
|---|---|
| Secreto | whsec_test_123 |
| X-Heysteff-Timestamp | 1730000000 |
| Cuerpo | {"event":"message:received","data":{"id":"msg_1"}} |
| X-Heysteff-Signature | v1=7fc6ea897401575af79e1b73b304bd864c05f1fef25dca2a0e8ffbf9b0d18ab4 |
Con el vector de prueba, desactiva el chequeo de los 300 segundos: ese timestamp es de 2024.
Reintentos#
Responde con un status 2xx en menos de 10 segundos y procesa el evento después. Si la entrega falla (error de red, timeout o un status distinto de 2xx), heysteff la reintenta con espera creciente: 1, 2, 4, 8 y 16 minutos. Después del sexto intento fallido la entrega queda marcada como fallida.
- Una entrega puede llegar más de una vez: deduplica por
X-Heysteff-Idempotency-Key. - El orden de llegada no está garantizado: usa
tso el estado que consultes por la API. - Si tu servidor estuvo caído, puedes recuperar lo que pasó leyendo
GET /v1/events. - Cada intento queda en el registro de entregas, desde donde puedes reintentar una entrega fallida.
Agrupar mensajes rápidos#
Un cliente suele mandar varios mensajes seguidos. Con batch en POST o PATCH /v1/webhooks (o en la consola), los message:received de una misma conversación que llegan dentro de wait_seconds salen en un solo POST, o antes si se juntan max_size:
{ "batch": { "wait_seconds": 5, "max_size": 50 } }wait_seconds va de 1 a 60 (por defecto 5) y max_size de 1 a 100 (por defecto 50). "batch": null lo apaga. El POST agrupado trae X-Heysteff-Batch: true, X-Heysteff-Batch-Size y este cuerpo, con los eventos en orden:
{
"id": "b3c1…", "tenant_id": "2c7e…", "event": "message:received",
"ts": "2026-11-14T14:03:57.000Z", "batch": true,
"events": [ { "id": "…", "event": "message:received", "data": { … } }, … ]
}Pausa automática#
Si en 15 minutos un webhook tuvo 40 entregas o más y fallaron el 85 % o más, queda en pausa: is_active pasa a false y paused_at y paused_reason (failure_rate) dicen cuándo y por qué. Avisamos por correo a los owners y admins, y los demás webhooks del workspace reciben webhook.paused.
- Mientras está en pausa, los eventos nuevos quedan en cola. No se pierden.
- Cuando arregles tu servidor, reactívalo desde la consola o con
PATCH /v1/webhooks/{id}y{ "is_active": true }: lo que quedó en cola sale en menos de un minuto. - Si lo desactivas tú a mano (
is_active: false), no guarda eventos nuevos.
Registro de entregas y reintentos#
Cada entrega queda registrada: qué evento era, cuántos intentos lleva, el status HTTP y la duración del último, por qué falló y un extracto de lo que respondió tu servidor. Lo ves en Configuración → Desarrolladores → Webhooks salientes, por la API con una key con el scope webhooks:read y desde tu IA por el MCP (list_webhook_deliveries, retry_webhook_delivery). El secreto de firma nunca aparece.
| Endpoint | Scope | Para qué |
|---|---|---|
GET /v1/webhooks | webhooks:read | Tus webhooks, con el último 2xx, el último fallo y los fallos seguidos. |
GET /v1/webhooks/{id}/deliveries | webhooks:read | Las entregas de un webhook, de la más nueva a la más vieja, paginadas por cursor. Filtros: status, event, since, until. |
POST /v1/webhook-deliveries/{id}/retry | webhooks:write | Vuelve a mandar ahora una entrega que falló. |
El campo state de cada entrega dice dónde va:
| state | Qué significa |
|---|---|
pending | Todavía no se intentó. |
retrying | El último intento falló y se va a reintentar sola (next_retry_at). |
succeeded | Tu servidor respondió 2xx. |
failed | Agotó los 6 intentos automáticos. |
skipped | El evento no aplica a ese webhook (un webhook con format: "meta" y un mensaje borrado o de otro canal). No se mandó nada, no cuenta como fallo y error es not_applicable. |
El filtro status=failed trae las dos que fallaron (retrying y failed); status=succeeded y status=pending, las otras.
curl "https://app.heysteff.com/api/v1/webhooks/$WEBHOOK_ID/deliveries?status=failed&limit=20" \
-H "Authorization: Bearer $HEYSTEFF_API_KEY"{
"data": [
{
"id": "8b0f6a1e-2c3d-4e5f-9a6b-7c8d9e0f1a2b",
"webhook_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"event": "message.received",
"state": "retrying",
"attempts": 2,
"max_attempts": 6,
"http_status": 401,
"duration_ms": 184,
"error": "http_status",
"response_body_excerpt": "firma inválida",
"retryable": true,
"created_at": "2026-11-04T14:03:52.104Z",
"last_attempt_at": "2026-11-04T14:05:01.880Z",
"next_retry_at": "2026-11-04T14:09:01.880Z"
}
],
"cursor": { "next": null, "has_more": false }
}Depurar una entrega fallida#
- Busca la entrega con
status=failed. Suides elX-Heysteff-Delivery-Idque recibió tu servidor: úsalo para encontrarla en tus logs. - Si
erroreshttp_status, tu servidor respondió algo distinto de 2xx: mirahttp_statusyresponse_body_excerpt. Un 401 o 403 casi siempre es la firma: revisa que calcules el HMAC sobre el cuerpo crudo y con el secreto de ESE webhook. - Si
errorestimeout, tu servidor tardó más de 10 segundos: responde 2xx primero y procesa después.duration_mste dice cuánto tardó. - Si es
connection_refused,dns_failed,tls_erroroconnection_reset, no se pudo conectar a tu URL: revisa que sea pública, con HTTPS y un certificado válido.destination_blockedsignifica que la URL apunta a una red privada, y heysteff nunca la llama. - Cuando lo arregles, reintenta. Sale el mismo cuerpo con el mismo
X-Heysteff-Delivery-Idy una firma nueva. El reintento no reinicia el contador: si vuelve a fallar una entrega que ya había agotado sus intentos, queda otra vez comofailed.
curl -X POST "https://app.heysteff.com/api/v1/webhook-deliveries/$DELIVERY_ID/retry" \
-H "Authorization: Bearer $HEYSTEFF_API_KEY"Con el SDK (desde la versión 0.2.0):
import { Heysteff } from '@heysteff/sdk';
const heysteff = new Heysteff();
const [webhook] = await heysteff.webhooks.list();
// Recorre todas las entregas fallidas de las últimas 24 horas.
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
for await (const d of heysteff.webhooks.listAllDeliveries(webhook.id, { status: 'failed', since })) {
console.log(d.id, d.event, d.error, d.http_status, d.response_body_excerpt);
if (d.retryable) await heysteff.webhooks.retryDelivery(d.id);
}Puedes reintentar hasta 120 entregas por hora por espacio. Repetir el pedido mientras la entrega espera su turno no la duplica.
Probar en local sin túnel#
Para probar tu receptor en tu máquina con eventos reales no necesitas un túnel ni una URL pública. heysteff listen abre una conexión de salida a heysteff y reenvía cada evento de tu espacio a tu servidor local, con el mismo sobre y las mismas cabeceras firmadas que una entrega real:
heysteff login
heysteff listen --forward-to http://localhost:3000/webhooks/heysteffEn local, listen firma con un secreto propio que muestra al iniciar (whsec_local_…), no con el secreto de tus webhooks. Pon ese valor en el HEYSTEFF_WEBHOOK_SECRET de tu entorno local, o pásale tu secreto con --secret para que la CLI firme con él:
heysteff listen --forward-to http://localhost:3000/webhooks/heysteff --secret "$HEYSTEFF_WEBHOOK_SECRET"Con el SDK (desde la 0.7.0), el receptor es una línea: verifica la firma y la antigüedad y responde 200, 400 si la firma no es válida o 500 si tu onEvent falla, y entonces heysteff reintenta. También hay ejemplos para Hono y Express.
// app/webhooks/heysteff/route.ts (Next.js App Router)
import { createWebhookHandler } from '@heysteff/sdk';
export const POST = createWebhookHandler({
secret: process.env.HEYSTEFF_WEBHOOK_SECRET!,
onEvent: async (event) => {
if (event.event === 'message:received') {
console.log(event.data.external_user_id, event.data.content);
}
},
});Junto a cada evento, listen muestra el status que respondió tu servidor y cuánto tardó (→ 200 OK (12 ms)). Para mandar un evento de ejemplo sin esperar uno real, usa heysteff webhooks trigger.
Alternativa: leer el stream de eventos#
Si no puedes exponer una URL pública, tienes dos caminos con el scope events:read: escuchar GET /v1/events/stream, un stream de Server-Sent Events en vivo que se reanuda con Last-Event-ID (formato en CLI; en el SDK, heysteff.events.stream()), o consultar GET /v1/events?after_seq=<último>. Cada respuesta de esta última trae el next_after_seq para la siguiente llamada.