Webhooks y eventos
Cuando algo ocurre en tu espacio de trabajo (se entrega un correo, un destinatario rebota, se lee un mensaje WhatsApp), Bird envía mediante POST un evento JSON firmado a cada endpoint de webhook suscrito a ese tipo de evento. Bird sigue la especificación Standard Webhooks para cabeceras, firma y estructura del payload, así que si ya verificas webhooks de otra plataforma Standard Webhooks, el mismo código de verificación funciona aquí sin cambios.
Para una visión general de los endpoints de webhook y la entrega, consulta ¿Qué es un webhook?.
Crear un endpoint
Ejemplo de código
bird webhooks create https://example.com/webhooks/bird \
--events email.delivered,email.bounced,email.complained \
--description "Production delivery + bounce notifications"La gestión de endpoints requiere el scope webhooks. Las sesiones del dashboard y el login del CLI lo incluyen a través de tu rol de usuario, y las claves API también pueden tenerlo: otorga webhooks:read para inspeccionar endpoints e intentos de entrega, o webhooks:write para gestionarlos. Las operaciones subyacentes comienzan en POST /v1/webhooks.

Las URLs de los endpoints deben ser HTTPS, de 2048 caracteres como máximo, y accesibles públicamente. Las URLs en direcciones privadas, de loopback, link-local o internas se rechazan con un 422 cuando creas o actualizas el endpoint. Las entregas se originan desde la infraestructura de entrega de Bird, fuera de tu red.
El array events enumera hasta 100 tipos del catálogo de eventos. Un endpoint recibe solo los tipos que incluye. Usa PATCH /v1/webhooks/{webhook_id} para reemplazar la lista completa en entregas futuras. Para recibir todos los eventos, suscríbete a cada tipo: un tipo fuera del catálogo se rechaza con un 422, incluido un comodín como sms.*. Las suscripciones existentes no se amplían cuando hay nuevos tipos disponibles.
La respuesta de creación incluye el secret de firma del endpoint (con prefijo whsec_) una sola vez. Guárdalo en tu gestor de secretos de inmediato; no se puede recuperar después, y si lo pierdes, rótalo.
Ejemplo de código
{
"id": "whk_01ky7q639cfh99xb7ysy2x2gzj",
"url": "https://example.com/webhooks/bird",
"events": ["email.delivered", "email.bounced", "email.complained"],
"description": "Production delivery + bounce notifications",
"status": "active",
"secret": "whsec_c+dgRBsFELJ9mR4tu2cyhK0gTMMkqntv",
"created_at": "2026-07-23T14:48:29.740Z",
"updated_at": "2026-07-23T14:48:29.740Z"
}Los endpoints admiten CRUD completo: listar, obtener, actualizar y eliminar. Eliminar un endpoint detiene todas las entregas hacia él, incluidos los reintentos de entregas fallidas anteriores, y no se puede deshacer; para detener las entregas temporalmente, establece status en paused. Un espacio de trabajo puede registrar múltiples endpoints, cada uno con su propia URL, filtro de eventos y secreto.
Verificar firmas
Cada entrega incluye tres cabeceras:
| Cabecera | Valor |
|---|---|
| webhook-id | Identifica la entrega del evento. Los reintentos y replays reutilizan el mismo valor. |
| webhook-timestamp | Marca de tiempo Unix (segundos) de este intento de entrega |
| webhook-signature | v1,<base64 HMAC-SHA256>, posiblemente varias firmas delimitadas por espacios |
La firma es un HMAC-SHA256 sobre la cadena {webhook-id}.{webhook-timestamp}.{raw request body}, con la clave del secreto de tu endpoint (elimina el prefijo whsec_ y decodifica en base64 el resto para obtener los bytes de la clave). Tu handler debe verificar la firma, rechazar entregas cuyo webhook-timestamp tenga más de 5 minutos de antigüedad y deduplicar por webhook-id: Bird entrega at-least-once, por lo que la misma entrega puede llegar más de una vez.
Con la Bird SDK, las comprobaciones de firma y marca de tiempo se resuelven en una sola llamada; la deduplicación queda en tu handler:
// Pass the RAW request body; set the secret via new BirdClient({ webhooks: { secret } }).
const event = bird.webhooks.unwrap(rawBody, headers);
console.log(event.type); // discriminated union: narrow on event.type# Pass the RAW request body (bytes) and the request headers.
event = client.webhooks.unwrap(request.body, request.headers)
if event.root.type == "email.delivered":
print(event.root.data.email_id)package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(
option.WithAPIKey(os.Getenv("BIRD_API_KEY")),
option.WithWebhookSecret(os.Getenv("BIRD_WEBHOOK_SECRET")),
)
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/webhooks/bird", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
event, err := client.Webhooks.Unwrap(body, r.Header)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent) // ack fast, then process
payload, _ := event.AsAny()
switch p := payload.(type) {
case bird.EmailDeliveredEvent:
fmt.Println("delivered:", p.Data.EmailId, p.Data.Recipient)
case bird.EmailBouncedEvent:
fmt.Println("bounced:", p.Type)
}
})
}// Pass the raw request body because parsing changes the bytes used to compute
// the signature.
$rawBody = file_get_contents('php://input') ?: '';
try {
$event = $bird->webhooks->unwrap($rawBody, getallheaders());
// $event is the decoded payload as an array; branch on $event['type'].
echo $event['type'];
} catch (WebhookVerificationError) {
http_response_code(400); // bad signature, stale timestamp, or missing/malformed headers
}Rechazar una entrega con 400, como hacen los ejemplos anteriores, no descarta el evento: lo reintentamos según el calendario de abajo. Eso es deliberado y es lo que quieres. La causa habitual de una verificación fallida es un secreto que tu handler aún no tiene, durante una rotación o un despliegue incorrecto, así que la ventana de reintentos es tu oportunidad de corregir el secreto y aun así recibir el evento. Devuelve 2xx solo cuando quieras descartar la entrega definitivamente.
Cualquier librería de referencia de Standard Webhooks también funciona. Si verificas manualmente, la receta es:
Ejemplo de código
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5-minute tolerance
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
// During secret rotation, the header can contain several signatures. Accept any match.
return headers["webhook-signature"].split(" ").some((part) => {
const sig = Buffer.from(part.replace(/^v1,/, ""), "base64");
return (
sig.length === Buffer.byteLength(expected, "base64") &&
timingSafeEqual(sig, Buffer.from(expected, "base64"))
);
});
}Calcula siempre el HMAC sobre los bytes crudos del cuerpo de la petición. Parsear y re-serializar el JSON cambia espacios en blanco u orden de claves y rompe la firma.
Semántica de entrega
Cada entrega es un evento por POST HTTP con Content-Type: application/json, sin agrupación. Tu endpoint tiene 15 segundos para responder; cualquier estado 2xx cuenta como éxito, y todo lo demás (incluidas redirecciones 3xx y timeouts) cuenta como fallo. Cada fallo sigue el mismo calendario de reintentos. El código de estado que devuelves cambia lo que ves en el registro de intentos de entrega, no si reintentamos: no hay código de estado que detenga la entrega anticipadamente. Responde rápido y procesa de forma asíncrona: encola el evento y devuelve 200 antes de hacer el trabajo real.
Tras el primer intento, las entregas fallidas se reintentan según este calendario, con ±20 % de jitter para que los reintentos no se sincronicen:
| Reintento | Retraso respecto al intento anterior |
|---|---|
| 1 | 5 segundos |
| 2 | 5 minutos |
| 3 | 30 minutos |
| 4 | 2 horas |
| 5 | 5 horas |
| 6 | 10 horas |
| 7 | 10 horas |
Son ocho intentos en aproximadamente 27,5 horas. Un 429 o un timeout eleva cualquier retraso programado inferior a 60 segundos a 60 segundos, lo que en la práctica solo afecta al primer reintento: tras el jitter, llega entre 48 y 72 segundos después. Una cabecera Retry-After en una respuesta fallida puede alargar la siguiente espera. Aceptamos la cabecera como delay-seconds o como una fecha HTTP. Un retraso solicitado mayor que el programado lo reemplaza, con un tope del doble del retraso programado (después de cualquier elevación a 60 segundos); uno menor se ignora, de modo que la cabecera nunca adelanta un reintento. El jitter se aplica encima. Cada reintento lleva el mismo webhook-id, que es lo que hace funcionar la deduplicación. Tras el último reintento, la entrega queda permanentemente fallida; replay la recupera.
Las entregas no están ordenadas. Un email.delivered puede llegar antes que el email.accepted del mismo mensaje, especialmente cuando hay reintentos de por medio. Ordena por el campo timestamp dentro del payload del evento, nunca por orden de llegada.
Operar tus endpoints
Envíos de prueba
POST /v1/webhooks/{webhook_id}/test envía un evento sintético firmado a tu endpoint y devuelve el resultado de forma síncrona: si tu endpoint lo aceptó, el estado HTTP que devolvió y la latencia de ida y vuelta. El cuerpo de prueba es un stub JSON mínimo que solo contiene el type del evento, firmado exactamente como una entrega real; no refleja un payload de evento real. Pasa {"event_type": "email.delivered"} para elegir cualquier tipo del catálogo, suscrito o no, u omite el cuerpo para usar el primer tipo de evento suscrito del endpoint.
Tu endpoint tiene 10 segundos para responder. Un endpoint inalcanzable produce status: failed en el cuerpo de la respuesta, mientras que la petición en sí tiene éxito. Usa este resultado para depurar la conectividad. Los envíos de prueba van directamente a tu endpoint: funcionan en un endpoint pausado y no se registran en el registro de intentos de entrega. Un 412 significa que el endpoint aún no puede probarse porque carece de un secreto de firma válido o de un tipo de evento suscrito.
Para pruebas de extremo a extremo con flujos de eventos reales, envía a las direcciones de sandbox: los envíos de sandbox emiten eventos de webhook reales a través de la ruta de entrega normal, la mejor forma de ejercitar tu handler antes de pasar a producción.
Reproducir entregas fallidas
POST /v1/webhooks/{webhook_id}/replay encola la reentrega de las entregas que fallaron. Los eventos que el endpoint ya recibió con éxito se omiten, así que un replay nunca entrega dos veces; un evento reenviado conserva su webhook-id original, por lo que tu comprobación de deduplicación cubre también los replays. Solo se reproducen los intentos fallidos: un evento que nunca se envió a tu endpoint no tiene intento fallido, así que un replay no lo recupera.
Pasa marcas de tiempo since/until para delimitar la ventana (por defecto: las últimas 24 horas hasta el momento de la solicitud). Ambos límites son inclusivos, y ambos seleccionan según cuándo se intentó la entrega, no según cuándo ocurrió el evento, así que un reintento que se retrasó un día respecto a su evento cae en la ventana por la hora en que se intentó. La reproducción lee el registro de intentos de entrega, que conserva tres días, de modo que esa es la historia más antigua a la que llega: un since anterior amplía la ventana sin recuperar nada más antiguo. Una reproducción cubre como máximo los 10.000 eventos más antiguos dentro de la ventana.
La petición devuelve 202 y los eventos se reenvían de forma asíncrona. Una reentrega tiene un solo intento, no el calendario de reintentos anterior. El intento se registra y el trabajo se da por terminado tanto si tu endpoint lo aceptó como si no, así que un replay hacia un endpoint que sigue roto cuesta una petición por evento en lugar de ocho; corrige el endpoint y haz replay de nuevo. Esos fallos no afectan la salud del endpoint: un replay no puede llevar un endpoint a degraded ni pausarlo automáticamente. Una reentrega que tu endpoint acepta limpia ambos estados.
Haz replay de un endpoint paused y la petición sigue devolviendo 202, pero nada se reenvía. Reactívalo primero, como describe Pausa automática y reactivación.
Los replays están limitados a 20 por organización por día UTC; más allá la petición devuelve un 429 (WebhookReplayQuotaExceeded). La respuesta no incluye un contador ni un ID de tarea. Rastrea los resultados con GET /v1/webhooks/{webhook_id}/attempts, que lista los intentos de entrega recientes de más nuevo a más antiguo con códigos de estado y latencia. Cada petición HTTP tiene su propia entrada, así que un evento reintentado aparece una vez por intento, y una reentrega aparece como una entrada más.
Rotar el secreto de firma
POST /v1/webhooks/{webhook_id}/rotate-secret genera un nuevo secreto y lo devuelve una sola vez. Durante las siguientes 24 horas, Bird firma cada entrega con ambos secretos. La cabecera webhook-signature contiene las firmas delimitadas por espacios (v1,<old> v1,<new>), lo que te permite desplegar el nuevo secreto durante la superposición. Las librerías de Standard Webhooks prueban todas las firmas automáticamente. Pasadas 24 horas, el secreto antiguo deja de firmar. Un endpoint puede tener como máximo 5 secretos válidos simultáneamente, así que rotar repetidamente dentro de la ventana de superposición falla con WebhookTooManySecrets hasta que un secreto anterior expire.
Pausa automática y reactivación
El status de un endpoint es active, degraded o paused. Los fallos de entrega recientes marcan un endpoint como degraded a modo de advertencia de salud; seguimos entregando y reintentando. Un endpoint que falla continuamente durante unos cinco días se paused automáticamente y toda entrega se detiene; una entrega exitosa durante ese periodo reinicia el reloj. Un endpoint pausado nunca se reanuda por sí solo. Reactívalo con PATCH /v1/webhooks/{webhook_id} y {"status": "active"} (o desde la página Webhooks en el dashboard), luego haz replay para reenviar los intentos que fallaron antes de la pausa. Reactiva primero: un replay solicitado mientras el endpoint sigue pausado no reenvía nada. Los eventos que llegaron mientras estaba pausado nunca se enviaron, así que un replay no los recupera.
Cualquiera de estos devuelve un endpoint degraded a active:
| Qué lo restablece | Por qué |
|---|---|
| Una entrega tiene éxito | El endpoint aceptó un evento de nuevo. |
| Cambiar la url del endpoint | Los fallos registrados describen un destino que ya no usas. |
| Reactivar un endpoint paused | Vuelve a estar en servicio, así que sus fallos anteriores ya no aplican. |
| Un envío de prueba que devuelve 2xx | Has demostrado que el endpoint es accesible. |
Editar la descripción de un endpoint o sus tipos de evento suscritos no dice nada sobre la accesibilidad, así que deja degraded en su lugar, al igual que un envío de prueba que falla.
Enviamos un correo a los propietarios de la organización cuando un endpoint pasa a degraded por primera vez, una vez por episodio en lugar de una vez por entrega fallida. Una degradación posterior tras una recuperación les envía otro correo, sujeto a un periodo de espera de 24 horas: enviamos como máximo un correo de degradación por endpoint cada 24 horas, de modo que un endpoint que alterna entre active y degraded no inunda su bandeja de entrada. Cambiar la url del endpoint reinicia el periodo de espera, por lo que la primera degradación en una URL nueva puede enviar un correo incluso dentro de las 24 horas posteriores al último.
Catálogo de eventos
Los payloads de eventos contienen datos compactos, delimitados al destinatario, para correlacionar con tu sistema. No contienen el recurso completo. Si necesitas más contexto, obtén el recurso por su ID. Los tipos de evento siguen la nomenclatura resource.action y se agrupan por producto; la página de eventos de cada producto incluye los campos del payload por evento:
- Eventos de email: el ciclo de vida de entrega (email.accepted hasta email.delivered o email.bounced), interacción (email.opened, email.clicked), desuscripciones y correo entrante
- Eventos de SMS: el ciclo de vida del mensaje desde sms.accepted hasta un estado terminal
- Webhooks de WhatsApp: whatsapp.accepted a whatsapp.delivered, whatsapp.read, whatsapp.failed, whatsapp.rejected, whatsapp.received para un mensaje entrante, whatsapp.reacted cuando un usuario reacciona a uno de los tuyos, y whatsapp.group.join_request_created y whatsapp.group.join_request_revoked cuando alguien solicita unirse a un grupo que requiere aprobación o retira la solicitud
- Eventos de Verify: el ciclo de vida de verificación (verify.verification.created, verify.verification.verified) y la entrega de cada intento de código de verificación (verify.attempt.sent, verify.attempt.delivered, verify.attempt.undelivered)
- Eventos de preferencia: el registro de consentimiento multicanal: preference.granted, preference.revoked y preference.deleted
Cada cuerpo de entrega es el sobre anidado de Standard Webhooks con type, timestamp y un objeto data específico del tipo. La cabecera webhook-id lleva la identidad del evento. El timestamp del sobre registra cuándo ocurrió el evento. La cabecera webhook-timestamp registra el intento de entrega actual y cambia en cada reintento.
Ejemplo de código
{
"type": "email.delivered",
"timestamp": "2026-06-10T14:30:00Z",
"data": {
"email_id": "em_01krdgeqcxet5s7t44vh8rt9mg",
"recipient_id": "er_01krdgeqcxet5s7t44vh8rt9mg",
"workspace_id": "ws_01krdgeqcxet5s7t44vh8rt9mg",
"recipient": "user@example.com",
"recipient_role": "to",
"tags": [{ "name": "category", "value": "welcome" }],
"metadata": { "order_id": "ord_123" },
"broadcast_id": null
}
}El data de cada evento de email incluye email_id, recipient_id, workspace_id, la dirección recipient y su recipient_role del sobre. También incluye tags y metadata de la petición de envío, o null cuando no se proporcionaron. Además lleva broadcast_id, que nombra el broadcast del que formó parte el envío, o null cuando no hubo broadcast detrás. En email.unsubscribed y email.list_unsubscribed, null no descarta un broadcast; eventos de email explica por qué. Los tipos de evento añaden sus propios campos a esta base. Cada variante tiene un conjunto de campos estable: los campos son obligatorios por defecto, y su presencia depende solo del tipo de evento.
Los nombres de eventos nunca se renombran, y se añaden nuevos tipos a medida que se lanzan productos, así que escribe tu handler para ignorar los tipos que no reconozca.
Eventos de preferencia
Las preferencias declaradas (los consentimientos y las exclusiones descritos en la guía de cada canal: email, SMS, WhatsApp) abarcan varios canales, por lo que sus eventos indican el canal en el payload en lugar de en el tipo. preference.granted se dispara cuando un consentimiento entra en vigor, preference.revoked cuando lo hace una exclusión, y preference.deleted cuando se elimina una declaración registrada y su clave vuelve a no tener registro. Un evento significa que el registro actual de la clave cambió: una declaración que repite el registro actual no dispara nada, y una rechazada por estar fuera de orden tampoco. El timestamp del sobre es el momento en que la declaración entró en vigor, que para una declaración con fecha anterior es cuando se realizó, no cuando llegó a Bird.
Cada payload lleva la clave de preferencia completa: channel, handle, sender_scope y topic_id, con los campos de alcance presentes con null cuando no la restringen. Junto a la clave van el coverage del registro, el preference_id, el transition_id de la entrada de historial que la escritura añadió, y el contact_id cuyo handle coincidió cuando se registró, o null:
Ejemplo de código
{
"type": "preference.revoked",
"timestamp": "2026-08-25T14:52:03.192524705Z",
"data": {
"preference_id": "prf_01krdgeqcxet5s7t44vh8rt9mg",
"transition_id": "prt_01krdgeqcxet5s7t44vh8rt9mg",
"channel": "sms",
"handle": "+15550001234",
"sender_scope": null,
"topic_id": null,
"coverage": "non_transactional",
"contact_id": null
}
}Próximos pasos
- Referencia API de Webhooks: documentación completa de endpoints y esquemas
- Eventos de email: campos del payload por evento
- Testing y sandbox: los envíos de sandbox generan entregas de webhook reales, ideal para probar handlers
Recursos relacionados
Continúa con la documentación, guías y ejemplos sobre este tema. Los recursos están en inglés.
Ver la guíaWebhooks done right: reliable delivery eventsComprender el conceptoHow do I verify a webhook signature?Seguir la ruta de aprendizajeOperate messaging reliably
Prueba el ejercicio y obtén un resumen de implementación