Enterarse de que terminó una campaña sin mirar la pantalla
El evento que avisa cuando la plataforma termina o frena una campaña
Una campaña se arma dentro de Vitrina, en Campañas. La audiencia, la plantilla y la hora de salida no se tocan por API. Lo que sí está publicado es la otra mitad, la que a tu código le importa.
Son dos avisos firmados: uno cuando la campaña termina, otro cuando la plataforma la frena sola. Al final de esta receta tu sistema recibe los dos, con sus cifras, sin consultar nada.
Trampa
campaign.paused no significa que alguien la pausó
El evento se dispara solo cuando la plataforma frena la campaña por su cuenta: el número llegó a su límite de mensajería en Meta, o el guardián de reputación de correo cruzó su umbral. Una persona que aprieta pausa en la pantalla no emite nada. Trátalo como una alarma, no como un historial de cambios.
Antes de empezar
- Una API key con
webhooks:writepara suscribir ywebhooks:readpara revisar las entregas. - Un endpoint público que responda
2xxrápido. Uno privado no sirve: la API resuelve el dominio y rechaza cualquiera que apunte a una dirección interna. - Para recibir las cifras dentro del aviso, la credencial dueña de la suscripción necesita además
campaigns:read.
1. Lee el contrato antes de escribir el receptor
GET /webhooks/events devuelve el catálogo completo con el esquema de cada evento. Esa respuesta es la fuente; esta página solo la comenta:
{
"name": "campaign.sent",
"version": 1,
"resource_type": "campaign",
"read_scope": "campaigns:read",
"sensitive": false,
"description": "A campaign finished sending — every recipient reached a terminal pre-delivery state (sent/failed/suppressed).",
"fires_when": "The campaign worker drains the last pending recipient and flips the campaign to status=sent.",
"data_schema": {
"campaign_id": "string (uuid)",
"name": "string",
"channel": "enum: whatsapp | email",
"total_recipients": "integer",
"sent": "integer (includes delivered/read)",
"failed": "integer",
"suppressed": "integer",
"finished_at": "ISO 8601 timestamp | null"
},
"sample": {
"campaign_id": "0d9b2a4e-1f6c-4e7a-9b3d-5c8e2f1a6b4d",
"name": "Reactivación de clientes abril",
"channel": "whatsapp",
"total_recipients": 1240,
"sent": 1198,
"failed": 17,
"suppressed": 25,
"finished_at": "2026-06-11T15:04:05Z"
}
}campaign.sent no dice que los mensajes llegaron. Dice que ningún destinatario quedó pendiente. sent cuenta lo que salió hacia el proveedor, y los acuses de entrega y lectura siguen llegando después. No cuentes sent como «entregados».
suppressed cuenta a quienes la campaña excluyó por preferencia del contacto. Esa cifra no es un fallo: es la política de contacto funcionando (Respetar a quien pidió que no le escribas).
2. Suscríbete pidiendo los datos
curl -X POST https://api.vitrinadev.com/api/v1/webhooks \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://avisos.tuempresa.cl/vitrina",
"events": ["campaign.sent", "campaign.paused"],
"include_data": true,
"description": "Avisos de fin de campaña"
}'{
"data": {
"id": "b78a3494-afa4-49ee-bc2f-679d3afa819f",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"url": "https://avisos.tuempresa.cl/vitrina",
"secret": "whsec_ede26a540f5deeaace460e45f70ea74d0a7100f835245589fa9c664e5b38b122",
"events": ["campaign.sent", "campaign.paused"],
"enabled": true,
"description": "Avisos de fin de campaña",
"created_at": "2026-09-23T11:58:03.334521+00:00",
"updated_at": "2026-09-23T11:58:03.334521+00:00",
"last_delivery_at": null,
"last_status": null,
"consecutive_failures": 0,
"owner_kind": "api_key",
"owner_id": "63041c70-76e1-47b6-8f9e-0ae098f97cdd",
"include_data": true,
"paused_at": null,
"paused_reason": null,
"failing_since": null,
"consecutive_failed_deliveries": 0
}
}Por defecto, una entrega trae solo el aviso y tu sistema lee el recurso con su propia credencial. La campaña no es un recurso de la API pública, así que las cifras solo llegan en data, con include_data: true.
data se entrega solo si en ese momento el dueño tiene campaigns:read sin restricciones de visibilidad. Si no, llega el aviso con "data_omitted": "missing_scope:campaigns:read", y tu receptor se queda sin las cifras. secret aparece una sola vez; guárdalo en ese momento.
3. La entrega que vas a recibir
{
"id": "f68a168d-4214-46da-8fbf-4eb4c0e9ec47",
"type": "campaign.sent",
"version": 1,
"livemode": true,
"created_at": "2026-09-23T12:08:17.304Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "campaign",
"id": "c0a00001-0000-4000-8000-000000000002",
"url": "…"
},
"author": {
"kind": "system",
"id": null,
"name": null
},
"data": {
"campaign_id": "c0a00001-0000-4000-8000-000000000002",
"name": "Aviso de horario extendido",
"channel": "whatsapp",
"total_recipients": 240,
"sent": 231,
"failed": 3,
"suppressed": 6,
"finished_at": "2026-09-23T12:08:17.281Z"
}
}Con estos headers, más el X-Webhook-Signature que hay que verificar antes de leer el cuerpo:
X-Webhook-Event: campaign.sent
X-Webhook-Event-Id: f68a168d-4214-46da-8fbf-4eb4c0e9ec47
X-Webhook-Timestamp: 1790165297El esquema de la firma, la ventana de tolerancia y un ejemplo resuelto con sus cuatro valores están en Recibir eventos firmados.
author.kind es system y los otros dos campos vienen nulos: la campaña terminó sola cuando el último destinatario dejó de estar pendiente.
resource.url apunta a una ruta que no es parte de la API pública; no la llames.
Las cifras de arriba suman 240, así que total_recipients cuadra con sent + failed + suppressed. No cuentes con que siempre cuadre: una campaña cancelada a medias deja destinatarios sin estado terminal.
4. La alarma
{
"id": "44995484-cabf-4456-aaf0-ebac2986188c",
"type": "campaign.paused",
"version": 1,
"livemode": true,
"created_at": "2026-09-23T12:12:33.064Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "campaign",
"id": "c0a00001-0000-4000-8000-000000000003",
"url": "…"
},
"author": {
"kind": "system",
"id": null,
"name": null
},
"data": {
"campaign_id": "c0a00001-0000-4000-8000-000000000003",
"name": "Aviso de horario extendido",
"reason": "messaging_limit"
}
}reason tiene dos valores y piden cosas distintas:
messaging_limit: Meta frenó el número. La plataforma vuelve a probar sola cada hora y la campaña sigue cuando el límite se libera. No hay nada que hacer, salvo dejar de programar envíos encima.reputation_guard: demasiados rebotes duros o demasiadas denuncias de spam en el correo. Esto además bloquea el canal de correo del workspace entero, así que no es solo esta campaña la que se detuvo.detaildice qué umbral se cruzó y con qué tasa.
5. Cuando tu endpoint estaba caído
Cada evento se reintenta cinco veces, con esperas de 5, 10, 20, 40 y 80 segundos, y todos los intentos reusan el mismo X-Webhook-Event-Id para que tu receptor pueda ser idempotente. Agotados los cinco, el evento queda en el registro de entregas:
curl "https://api.vitrinadev.com/api/v1/webhooks/$WEBHOOK/deliveries?limit=6" \
-H "Authorization: Bearer $VITRINA_KEY"[
{ "id": 934, "event": "campaign.paused", "attempt": 1, "status_code": 200, "error": null, "latency_ms": 261, "redelivery_of": null },
{ "id": 933, "event": "campaign.sent", "attempt": 1, "status_code": 200, "error": null, "latency_ms": 301, "redelivery_of": null },
{ "id": 932, "event": "campaign.sent", "attempt": 1, "status_code": 200, "error": null, "latency_ms": 260, "redelivery_of": 931 },
{ "id": 931, "event": "campaign.sent", "attempt": 5, "status_code": 530, "error": "http_530", "latency_ms": 466, "redelivery_of": null },
{ "id": 930, "event": "campaign.sent", "attempt": 4, "status_code": 404, "error": "http_404", "latency_ms": 64, "redelivery_of": null },
{ "id": 919, "event": "campaign.sent", "attempt": 3, "status_code": 404, "error": "http_404", "latency_ms": 73, "redelivery_of": null }
]Cinco intentos fallidos contra un receptor caído, y después la entrega 932, que llegó bien y lleva redelivery_of: 931. El reenvío se pide así:
curl -X POST https://api.vitrinadev.com/api/v1/webhooks/$WEBHOOK/deliveries/931/redeliver \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": {
"subscription_id": "b78a3494-afa4-49ee-bc2f-679d3afa819f",
"delivery_id": 931,
"event": "campaign.sent",
"event_id": "f68a168d-4214-46da-8fbf-4eb4c0e9ec47",
"status": "queued"
}
}Responde 202 y encola: el reenvío sale por el mismo camino que el original, con el mismo event_id.
Cuando falla
Una suscripción se pausa sola cuando veinte eventos seguidos agotan sus reintentos, o cuando lleva veinticuatro horas fallando sin un solo acierto. Los administradores del workspace reciben un aviso, una vez.
Mientras está pausada no se encola nada, así que POST /webhooks/{id}/resume no reenvía los eventos de ese período. Lo que se perdió se recupera desde el registro de entregas, reenviando una por una.
En la aplicación: las mismas cifras, por destinatario y en vivo, están en Campañas, junto al botón de pausa manual que no emite este evento. Guía completa en Manual de plataforma → Enviar una campaña.
Los identificadores de esta página serán otros en tu workspace; la forma es la misma.
El catálogo completo de eventos está en Eventos, y la firma, los reintentos y los modos de entrega en Recibir eventos firmados. Para armar la lista de destinatarios antes de la campaña, Armar la lista de a quién escribirle.