Saltar al contenido

Recibe eventos por webhook

Un webhook es un mensaje que enviamos a tu servidor cuando pasa algo: sale un correo, llega una respuesta, se pausa una campaña. Empieza por el más pequeño, una prueba.

Pide al dueño que abra Configuración › Webhook y pulse Enviar un evento de prueba. Tu servidor recibe un POST con este cuerpo:

Cuerpo de ejemplo. El ID y la hora son inventados.
{
  "id": "9d1c4b52-3f6e-4a7b-8c21-5e0f7a6b3d19",
  "type": "webhook.test",
  "seq": 0,
  "generation": "70000000-7000-4700-8700-700000000000",
  "timestamp": "2026-10-02T14:30:00.000Z",
  "data": {
    "test": true,
    "message": "This is a test from colding."
  }
}

id es el ID de esta entrega. type es webhook.test y seq es 0, porque una prueba no es un evento: los eventos reales empiezan en 1. generation es el ID de la serie de números de eventos en que está la lista; la sección sobre restauración, más abajo, lo explica. data trae una frase fija en inglés.

Antes de confiar en un POST, comprueba que vino de nosotros. verifyWebhook recibe tres cosas: payload, el cuerpo tal como llegó; signature, el valor de la cabecera x-colding-signature; y secret, el secreto de firma whsec_ que el dueño copió al guardar el webhook. Te devuelve el evento ya leído o lanza un error.

import { verifyWebhook } from "colding";

async function receive(request: Request): Promise<Response> {
  const event = await verifyWebhook({
    payload: await request.text(),
    signature: request.headers.get("x-colding-signature"),
    secret: "whsec_the_secret_you_were_shown",
  });
  console.log(event.id, event.type);
  return new Response("ok");
}

Lee el cuerpo como texto y compruébalo antes de que nada lo interprete. Un framework que lee el JSON y lo vuelve a escribir cambia los bytes, y la comprobación falla con no_match.

Mira qué puede salir mal

Configúralo

El dueño configura el webhook en Configuración › Webhook: una dirección https que tu servidor atiende, en el puerto 443 (el predeterminado) o 8443, y los eventos que se envían. El secreto de firma se muestra una sola vez, al guardar el webhook o al cambiar el secreto. Guárdalo donde tu servidor pueda leerlo.

Una clave no puede guardar, cambiar ni probar un webhook, y no hay una solicitud para ello. Si eres un agente, pide al dueño que lo configure y que ponga el secreto donde tu servidor lo lea.

Tu servidor responde a cada POST con un estado 2xx, en cuanto tiene el cuerpo. Haz el trabajo lento después de responder.

Cómo es una entrega

Una entrega es un POST con un cuerpo JSON y tres cabeceras.

El cuerpo es { id, type, seq, generation, timestamp, data }. id es el ID de la entrega. type es el tipo del evento. seq es el número del evento en la lista de eventos, el mismo que muestra events.list. generation es la serie de números a la que pertenece el seq, el mismo valor que responde events.list. timestamp es la hora en que se registró el evento, no la de este intento. data es pequeño: IDs y algunos datos, como la dirección de un destinatario o un enlace con clic. Nunca trae el asunto ni las palabras de un mensaje.

Las cabeceras:

x-colding-delivery
El ID de la entrega, igual al id del cuerpo e igual en cada intento.
x-colding-event
El tipo del evento, igual a type. Léelo para dirigir un POST antes de abrir el cuerpo.
x-colding-signature
La prueba, con la forma t=…,v1=…. Pásala a verifyWebhook.

Una entrega puede llegar dos veces. Guarda el id de cada entrega que proceses y ignora la repetición.

Comprueba una entrega

Cuando verifyWebhook no puede confiar en una entrega, lanza un ColdingWebhookError. Responde 400 y no hagas nada con el cuerpo, salvo que el code sea bad_secret. Su code dice el motivo:

missing_header
No hay cabecera de firma, o está vacía. Solo nuestras entregas la traen.
bad_format
La cabecera no tiene la forma t=…,v1=….
too_old
La entrega se firmó hace más tiempo del que permite la ventana. Alguien puede estar repitiendo una capturada.
too_new
Se firmó más adelante de lo que permite la ventana. Revisa el reloj de tu servidor.
no_match
Ninguna firma coincide. O el secreto está mal, o el cuerpo cambió por el camino, como cuando un framework lee el JSON y lo vuelve a escribir.
bad_body
La firma es correcta, pero el cuerpo no es JSON.
bad_secret
El secreto que pasaste está vacío. El problema es tu configuración, no la entrega, así que responde 500.

Captura el error, responde y sigue:

import { ColdingWebhookError, verifyWebhook } from "colding";

async function receive(request: Request, secret: string): Promise<Response> {
  try {
    const event = await verifyWebhook({
      payload: await request.text(),
      signature: request.headers.get("x-colding-signature"),
      secret,
    });
    console.log(event.id, event.type);
    return new Response("ok");
  } catch (error) {
    if (!(error instanceof ColdingWebhookError)) throw error;
    // bad_secret is your setup, not the delivery, so it answers 500.
    return new Response(error.code, { status: error.code === "bad_secret" ? 500 : 400 });
  }
}

La ventana es de 5 minutos hacia cada lado, salvo que la cambies. Cada intento se firma de nuevo, así que un reintento que llega más tarde no se rechaza por antiguo. Si el reloj de tu servidor va mal, arréglalo primero. Si no puedes, toleranceSeconds amplía la ventana:

const event = await verifyWebhook({ payload, signature, secret, toleranceSeconds: 600 });

Envía un evento de prueba

El dueño pulsa Enviar un evento de prueba en Configuración › Webhook. Así ves que tu servidor recibe un POST y pasa la comprobación, antes de que lo haga un evento real.

  • Va firmado como una entrega real, así que el código que comprueba una real comprueba también esta.
  • Su cuerpo es el del inicio de esta página: type es webhook.test, seq es 0 y data trae test: true y un mensaje.
  • No es un evento. events.list nunca lo muestra, y un webhook no puede suscribirse a él.
  • Se envía una sola vez. Si tu servidor falla, no lo reintentamos, y no cuenta para desactivar el webhook. Pulsa el botón otra vez.
  • Sale aunque el webhook esté desactivado, y no lo vuelve a activar.
  • Un workspace puede enviar 10 eventos de prueba por hora. Pasado ese límite, Enviar un evento de prueba se rechaza hasta que termine la hora.
  • El resultado queda en la lista de entregas, marcado como webhook.test, con lo que respondió tu servidor. Si tu servidor no vio nada, a los 5 minutos la fila dice que la prueba no llegó a salir. Envía una prueba nueva.

Reintentos y desactivación

Una respuesta 2xx significa que lo recibiste. No seguimos redirecciones, así que un 3xx termina la entrega al instante. Un 4xx también, salvo 408, 425 y 429, que piden esperar y volver a intentar.

Lo demás se intenta de nuevo, hasta 8 intentos en total, contando el primero. Las esperas entre ellos son de 5 segundos, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas y 10 horas: unas 28 horas del primer intento al último. Si tu servidor manda una cabecera Retry-After que pide más, esperamos eso, hasta 10 horas. Tras el último intento, la entrega queda como fallida.

Tu servidor recibe como máximo 2 entregas a la vez. Cuando hay más por enviar, las demás esperan su turno y salen en cuanto termina una. Esperar no es intentar: no gasta ninguno de los 8 intentos y no cuenta para apagar el webhook.

Desactivamos el webhook cuando sus entregas fallan durante 5 días, sin ninguna exitosa entre medio. Los días cuentan desde la primera entrega que agotó sus intentos, y una entrega exitosa reinicia la cuenta. Cuando arregles tu servidor, el dueño pulsa Activar otra vez en Configuración › Webhook.

Cuando desactivamos el webhook, el dueño recibe una alerta, y un correo salvo que haya apagado los correos de alerta. En Configuración › Webhook aparece desde cuándo está desactivado. Al volver a activarlo solo se envían los eventos nuevos. Lo que se perdió no sale otra vez salvo que el dueño lo pida: en Configuración › Webhook puede enviar los eventos escritos mientras estaba desactivado, o volver a enviar todas las entregas que fallaron. Solo puedes reenviar eventos de los tipos a los que el webhook está suscrito en ese momento y que siguen en la lista de eventos (24 horas). Cada pulsación cuenta en los 60 reenvíos por hora.

Esa página también lista cada entrega, con su estado, lo que respondió tu servidor y, en las fallidas, el motivo. Guarda 30 días y se filtra por estado y por tipo de evento. Enviar otra vez manda ahora una entrega fallida. Un workspace puede usarlo 60 veces por hora. Solo puedes reenviar una entrega mientras su evento está en la lista de eventos. Guardamos los eventos al menos 24 horas. Después, el botón puede dejar de aparecer.

Cuando tu servidor se perdió algo

Un webhook avisa a tu servidor cuando pasa algo. Si el servidor está caído, se pierde el POST o fallan todos los intentos de entrega, puedes leer el evento en la lista de eventos.

En la primera llamada, deja cursor fuera: empiezas en el evento más antiguo que aún guardamos. Cuando hayas procesado la página, guarda el resumeCursor que recibas. Tras una pausa, llama a events.list con él como cursor. Recibes todo lo que pasó después de ese punto, del más antiguo al más nuevo. Una clave con read puede hacerlo.

const page = await colding.events.list({ cursor: lastCursor });
for (const event of page.items) console.log(event.seq, event.type);
// Save this after you handle the page, and pass it as `cursor` next time.
console.log(page.resumeCursor);

Consulta la lista de eventos al menos cada 24 horas. Es el tiempo mínimo que guardamos cada evento. Después, puede salir de la lista. Si eso pasa, ya no puedes reenviar una entrega fallida de ese evento.

Usa seq para encontrar el evento de un POST en la lista de eventos. El id del POST identifica la entrega, no el evento.

Un cursor solo funciona con los types y el order de los que salió. order: "desc" lee hacia atrás desde el evento más nuevo. Un cursor también pertenece a una generación, y una restauración lo anula.

Guarda el resumeCursor, no el nextCursor. El nextCursor sirve para pasar de página dentro de una lectura y viene null en una página que no está llena, así que no te dice desde dónde seguir. Toda página leída del más antiguo al más nuevo tiene un resumeCursor, incluso la página vacía. Una lectura con order: "desc" sirve para consultar el historial y no tiene ninguno.

Después de restaurar una copia de seguridad

Después de restaurar una copia de seguridad

A veces volvemos a poner la base de datos desde una copia de seguridad. Los números de los eventos (seq) vuelven entonces a un punto más antiguo, y el mismo número puede nombrar un evento distinto del que viste antes.

Para que esto no pase inadvertido, cada página de events.list y cada entrega trae una generation. Es el ID de una serie de números de eventos. Un cursor o un seq guardado solo tiene sentido dentro de la generación de la que salió.

Tras una restauración, events.list responde a un cursor de la generación anterior con el estado 409, messageKey serverErrors.feedResetRequired, details.resetRequired en true y la generación nueva en details.generation. Un cursor de antes de que existieran las generaciones recibe la misma respuesta. Nunca leemos un cursor viejo como si todavía sirviera.

Cuando recibas esa respuesta, o veas una generation nueva en una entrega:

  1. Guarda la generation de la última página o entrega que procesaste. Si es distinta, la numeración empezó de nuevo.
  2. Deja de actuar según los eventos y descarta el cursor guardado.
  3. Revisa tus propios registros para ver qué pudo perder la restauración: las carpetas Enviados de tus buzones, la lista de envíos y tus solicitudes. Un envío o una aprobación que no encuentras no es permiso para hacerlo otra vez. Si no puedes saber qué pasó, pregunta al dueño.
  4. Solo entonces llama a events.list sin cursor. Cuando hayas procesado la página, guarda su resumeCursor y su generation.
  5. Sigue ignorando repeticiones por el id. Una entrega que esperó durante una restauración conserva su id y el ID de su evento, y lleva la generation nueva.

En el SDK, un reset es un ColdingError con estado 409. Este código vuelve a leer la lista, sin cursor, cuando ya revisaste tus registros:

let page;
try {
  page = await colding.events.list({ cursor: lastCursor });
} catch (error) {
  // 409: we restored from a backup, and this cursor is from before it.
  if (!(error instanceof ColdingError) || error.status !== 409) throw error;
  // Stop acting on events and check your mailboxes, the send list and your handoffs first.
  // A send or an approval you can't find is not permission to do it again.
  page = await colding.events.list();
}
// Save both after you handle the page. A different generation next time means the numbers started over.
console.log(page.generation, page.resumeCursor);

Un reset no repara una pérdida. Restauramos hasta el último punto que tenemos. Un evento, o un correo, de poco antes de ese punto puede no existir en la base nueva, y la lista de eventos no puede mostrar lo que no tiene. Volver a leer la lista te dice qué hay ahora. No te dice qué falta.

También pausamos las campañas que estaban en curso. Un correo que quizá iba en camino queda marcado como unknown y nunca se vuelve a enviar. Antes de iniciar una campaña otra vez, compara las carpetas Enviados de tus buzones con la lista de envíos.

El reset no es una de las tres protecciones del envío. La clave de la petición HTTP, la intención de envío y el worker que toma el correo para sí funcionan como dice la guía de envío, y el reset no reemplaza a ninguna.

Los eventos

El dueño elige cuáles de estos eventos recibe el webhook. Todos aparecen en la lista de eventos, incluso los que no se envían al webhook. Si un tipo se agregó después de guardar el webhook, el dueño tiene que seleccionarlo para que el webhook lo reciba.

Con estos seis eventos, el agente puede esperar un webhook en vez de consultar la lista de eventos una y otra vez: campaign.finished, campaign.waiting_for_leads, mailbox.recovered, mailbox.address_confirmed, email_check.run_held y workspace.posture_changed.

send.sent
El servidor de correo por el que salió el mensaje lo aceptó: el de tu buzón o, en una dirección de remitente o una bandeja de entrada de agente, el nuestro. Eso no significa que llegó a la bandeja de entrada.
send.failed
Un correo no salió. El evento dice por qué.
send.unknown
La conexión se cortó al entregar el correo. Puede que haya llegado, así que nunca lo envíes de nuevo.
send.cancelled
Un correo se detuvo justo antes de salir. El evento dice por qué.
send.bounced
El servidor del destinatario rechazó el correo para siempre.
send.delivered
El servidor del destinatario aceptó un correo que salió por nuestros servidores de correo. El correo enviado desde un buzón que conectaste nunca dispara este evento.
send.deferred
El servidor del destinatario dijo que ahora no, y nuestro servidor de correo lo vuelve a intentar solo. No envíes el correo otra vez.
send.opened
Una persona abrió el correo por primera vez.
send.clicked
Una persona hizo clic en un enlace del correo por primera vez.
handoff.completed
Una persona terminó lo que un agente le pidió.
mailbox.failed
No pudimos leer un buzón. El evento dice por qué.
mailbox.warmup_health_changed
La salud del calentamiento de un buzón cambió, o su cuarentena o bloqueo tiene una nueva fecha de fin.
message.received
Llegó un mensaje nuevo a un buzón.
suppression.added
Una dirección o un dominio entró en la lista de supresión.
suppression.removed
Una dirección o un dominio salió de la lista de supresión.
campaign.reply_received
Alguien respondió a un correo de campaña, o su servidor respondió por esa persona.
campaign.paused
Se pausó una campaña en curso. El evento dice por qué, salvo que la pausara una persona o una clave.
email_check.run_completed
Un lote de verificaciones de dirección ya no tiene nada pendiente.
email_check.run_held
Un lote de verificaciones de dirección sigue abierto, pero nada avanza y algunas verificaciones están en espera. El evento dice el motivo y cuántas esperan. Llega una vez por cada espera, y run_completed sigue llegando cuando las verificaciones terminen.
campaign.finished
Todos los leads recibieron la secuencia entera, así que la campaña terminó.
campaign.waiting_for_leads
Una campaña en curso se quedó sin nadie a quien escribir.
mailbox.recovered
Terminó un problema de un buzón.
mailbox.address_confirmed
Se confirmó la dirección de un buzón, así que ya puede enviar.
workspace.posture_changed
Pusimos en espera los envíos del workspace, cambiamos ese límite o lo quitamos. El evento dice cuál es y el motivo.
mail_domain.state_changed
Un dominio de correo que agregaste cambió de estado: pending, active, failing o removed. El evento trae el ID, el nombre y el uso del dominio (inbox o send), y el estado del que salió (from) y al que pasó (to).
mailbox.review_changed
Empezó o terminó un límite en un buzón. mode es hold (no sale nada del buzón), first_contact_brake (una bandeja de entrada de agente solo responde a quienes tu workspace ya escribió antes) o null cuando ya nada lo limita. En una espera, ground dice el motivo. Con spam_reports, phishing_malware, legal_order o other, nuestro equipo puso este buzón en espera tras una denuncia sobre él. Con complaint_rate, bounce_rate, policy_bounce_rate o policy_abuse_burst, demasiados correos del dominio de una bandeja de entrada de agente o de una dirección de remitente se marcaron como spam, rebotaron o se rechazaron como spam, así que todas las bandejas de entrada de agente y todas las direcciones de remitente de ese dominio están en espera. En los demás casos, ground es null, y los eventos enviados antes de que existiera no lo traen. Cuando receiving es true, nuestro equipo también impidió que la dirección reciba correo.

Todo evento de envío (send.sent, send.failed, send.unknown, send.cancelled y send.bounced) trae el campo viaOurServers. Es true cuando el correo salió, o habría salido, por nuestros servidores de correo: el correo de una dirección de remitente o la respuesta de una bandeja de entrada de agente. Es false para un buzón que conectaste. Los eventos guardados antes de que existiera el campo no lo tienen. Con true, send.sent significa que nuestro servidor de correo aceptó el correo. Con false, significa que lo aceptó el servidor de correo de tu buzón. Ninguno de los dos significa que el correo llegó a la bandeja de entrada.

send.delivered y send.deferred existen solo para el correo que sale por nuestros servidores de correo, como el correo de una dirección de remitente o las respuestas de una bandeja de entrada de agente. Cada uno se dispara como máximo una vez por envío, aunque el servidor del destinatario nos pida muchas veces que lo intentemos de nuevo. Los dos traen sendId, to, messageId, at (la hora en que nuestro servidor de correo anotó la respuesta) y receiver: google, microsoft, yahoo, icloud o el nombre del propio servidor, o null cuando no hay ninguno. send.deferred trae además smtpCode, enhancedCode y errorDetail, lo que dijo el servidor del destinatario. Un envío puede disparar send.deferred y después send.delivered o send.bounced. El último es cómo terminó.

Dos entregas, un correo

Una entrega repetida no es un envío repetido. Pero si tu código envía un correo cuando llega un evento, la repetición puede pedir un segundo.

El envío tiene sus propias tres protecciones: la clave de la petición HTTP, la intención de envío y el worker que toma el correo para sí. Son distintas, y ninguna reemplaza tu comprobación del id. Ata cada envío a un único correo: el ID y la revisión de un borrador ya lo hacen, y en un correo de prueba o transaccional puedes armar el intentKey con la generation y el seq del evento.

La guía de envío explica las tres protecciones