developers
EN Crear cuenta

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
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#

EventoCuándo llega
message:receivedUn cliente escribió por cualquier canal.
message:sentSalió un mensaje hacia un cliente.
conversation.escalatedUna conversación pasó a una persona del equipo.
message.deliveredUn mensaje que mandaste llegó al teléfono del cliente (WhatsApp, Instagram, Messenger, SMS). Uno por mensaje.
message.readEl cliente leyó tu mensaje (WhatsApp, Instagram, Messenger). Uno por mensaje.
channel.connectedUn 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_connectedUno de tus clientes terminó de conectar un canal con su link de conexión. Ver Clientes y links de conexión.
message.failedUn mensaje no se pudo entregar.
agent.replied, agent.handoff, agent.failedEl agente de IA respondió, pasó la conversación a una persona o no pudo responder. Ver Agente de IA.
insight.detectedUn análisis de tus conversaciones encontró algo que revisar. Ver Agente de IA.
call.startedEmpezó una llamada de WhatsApp: está sonando o ya la contestaron. Ver Llamadas de WhatsApp.
call.endedTerminó una llamada que alguien contestó (con duration_seconds) o falló.
call.missedUna llamada terminó sin que nadie contestara, o la rechazaron.
conversation.urgentUna escalación quedó con urgencia alta.
flow.completedTerminó una captura estructurada (por ejemplo, una reseña).
cart.abandoned, cart.recoveredUn carrito quedó abandonado o se recuperó.
purchase.completedSe registró una compra.
payment.paid, payment.failed, payment.refundedCambió el estado de un cobro.
campaign.completedTerminó la corrida de una campaña.
channel.errorUn canal tiene un problema que requiere atención.
conversation.createdSe 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.inactiveUna conversación abierta lleva inactivity_hours sin mensajes (por webhook, de 1 a 720, por defecto 24). Llega una vez por cada silencio.
conversation.endedLa conversación se cerró. close_kind dice cómo: resolved, archived o auto_archived.
conversation.assignedCambió la persona a cargo (assigned_user_id y previous_assigned_user_id).
conversation.bot_toggledEl bot se pausó o se reanudó (bot_enabled, paused_by).
contact.updatedCambió 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_inEl contacto se dio de baja en un canal (por ejemplo, contestó "BAJA") o volvió a aceptar mensajes.
channel.disconnected, channel.reconnectedUn 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.restrictedMeta restringió, sancionó o deshabilitó la cuenta de WhatsApp. restriction trae lo que informó Meta.
webhook.pausedOtro webhook del workspace quedó en pausa por fallar demasiado (ver Pausa automática).
webhook.testLo 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:

JSON
{
  "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):

CampoQué es
channelCanal de la conversación. Va tal cual como channel de POST /v1/messages (igual a channel_type).
channel_connection_idNúmero o cuenta por donde va la conversación, o null.
external_user_idA quién responder: va como to de POST /v1/messages.
contact_idContacto de la conversación, o null.
bot_pausedtrue si una persona o un flujo tiene la conversación: tu IA no debería contestar.
bot_paused_byQuién la pausó (api_escalation, api, human_agent, flow:<id>…), o null sin pausa.
sandboxtrue si la conversación es del sandbox.
HeaderContenido
X-Heysteff-EventNombre del evento.
X-Heysteff-TimestampSegundos unix del momento en que se firmó la entrega.
X-Heysteff-Signaturev1= seguido del HMAC-SHA256 en hexadecimal.
X-Heysteff-Delivery-IdId de la entrega a este webhook. Se repite en sus reintentos.
X-Heysteff-Idempotency-KeyId 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-SizeSolo 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:

  1. Lee el cuerpo crudo, sin parsearlo ni volver a serializarlo.
  2. Rechaza la entrega si el timestamp difiere en más de 300 segundos de tu reloj.
  3. 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);

Vector de prueba#

Prueba tu implementación con estos valores fijos. Si tu código produce la misma firma, verifica igual que heysteff:

DatoValor
Secretowhsec_test_123
X-Heysteff-Timestamp1730000000
Cuerpo{"event":"message:received","data":{"id":"msg_1"}}
X-Heysteff-Signaturev1=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.

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:

JSON
{ "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:

JSON
{
  "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.

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.

EndpointScopePara qué
GET /v1/webhookswebhooks:readTus webhooks, con el último 2xx, el último fallo y los fallos seguidos.
GET /v1/webhooks/{id}/deliverieswebhooks:readLas 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}/retrywebhooks:writeVuelve a mandar ahora una entrega que falló.

El campo state de cada entrega dice dónde va:

stateQué significa
pendingTodavía no se intentó.
retryingEl último intento falló y se va a reintentar sola (next_retry_at).
succeededTu servidor respondió 2xx.
failedAgotó los 6 intentos automáticos.
skippedEl 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
curl "https://app.heysteff.com/api/v1/webhooks/$WEBHOOK_ID/deliveries?status=failed&limit=20" \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY"
JSON
{
  "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#

  1. Busca la entrega con status=failed. Su id es el X-Heysteff-Delivery-Id que recibió tu servidor: úsalo para encontrarla en tus logs.
  2. Si error es http_status, tu servidor respondió algo distinto de 2xx: mira http_status y response_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.
  3. Si error es timeout, tu servidor tardó más de 10 segundos: responde 2xx primero y procesa después. duration_ms te dice cuánto tardó.
  4. Si es connection_refused, dns_failed, tls_error o connection_reset, no se pudo conectar a tu URL: revisa que sea pública, con HTTPS y un certificado válido. destination_blocked significa que la URL apunta a una red privada, y heysteff nunca la llama.
  5. Cuando lo arregles, reintenta. Sale el mismo cuerpo con el mismo X-Heysteff-Delivery-Id y 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 como failed.
curl
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):

TypeScript
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:

Terminal
heysteff login
heysteff listen --forward-to http://localhost:3000/webhooks/heysteff

En 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:

Terminal
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.

TypeScript
// 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.