Find out a campaign finished without watching the screen
The event that fires when the platform finishes or halts a campaign
You build a campaign inside Vitrina, under Campaigns. The audience, the template and the send time are not touched over the API. The other half is published, and it is the one your code cares about.
Two signed notices: one when the campaign finishes, another when the platform halts it on its own. By the end of this recipe your system receives both, with their numbers, without asking for anything.
Trap
campaign.paused does not mean somebody paused it
The event fires only when the platform halts the campaign by itself: the number hit its Meta messaging limit, or the email reputation guard crossed its threshold. A person pressing pause on the screen emits nothing. Treat it as an alarm rather than a change log.
Before you start
- An API key with
webhooks:writeto subscribe andwebhooks:readto review deliveries. - A public endpoint that answers
2xxfast. A private one will not do: the API resolves the domain and rejects anything pointing at an internal address. - To receive the numbers inside the notice, the credential owning the subscription also needs
campaigns:read.
1. Read the contract before writing the receiver
GET /webhooks/events returns the full catalogue with each event's schema. That response is the source; this page only comments on it:
{
"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 does not say the messages arrived. It says no recipient is pending any more. sent counts what left towards the provider, and delivery and read receipts keep arriving afterwards. Do not count sent as «delivered».
suppressed counts the people the campaign excluded on the contact's own preference. That number is not a failure: it is the contact policy working (Keep a contact's opt-out).
2. Subscribe asking for the data
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
}
}By default, a delivery carries only the notice and your system reads the resource with its own credential. A campaign is not a resource of the public API, so the numbers only arrive in data, with include_data: true.
data is delivered only if, at that moment, the owner holds campaigns:read with unrestricted visibility. Otherwise the notice arrives with "data_omitted": "missing_scope:campaigns:read", and your receiver is left without the numbers. secret is shown once; store it then.
3. The delivery you will receive
{
"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"
}
}With these headers, plus the X-Webhook-Signature to verify before reading the body:
X-Webhook-Event: campaign.sent
X-Webhook-Event-Id: f68a168d-4214-46da-8fbf-4eb4c0e9ec47
X-Webhook-Timestamp: 1790165297The signature scheme, the tolerance window and a worked example with its four values are in Receiving signed events.
author.kind is system and the other two fields come back null: the campaign ended by itself when the last recipient stopped being pending.
resource.url points at a path that is not part of the public API; do not call it.
The numbers above add up to 240, so total_recipients matches sent + failed + suppressed. Do not count on it always matching: a campaign cancelled halfway leaves recipients with no terminal state.
4. The alarm
{
"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 has two values and they ask for different things:
messaging_limit: Meta throttled the number. The platform probes again every hour and the campaign carries on when the limit clears. There is nothing to do beyond not scheduling more sends on top.reputation_guard: too many hard bounces or too many spam complaints on email. This also blocks the whole workspace's email channel, so it is not only this campaign that stopped.detailsays which threshold was crossed, and at what rate.
5. When your endpoint was down
Each event is retried five times, waiting 5, 10, 20, 40 and 80 seconds. Every attempt reuses the same X-Webhook-Event-Id, so your receiver can be idempotent. Once the five are spent, the event stays in the delivery log:
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 }
]Five failed attempts against a receiver that was down, and then delivery 932, which landed and carries redelivery_of: 931. You request the redelivery like this:
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"
}
}It answers 202 and queues: the resend leaves by the same path as the original, with the same event_id.
When it fails
A subscription pauses itself when twenty events in a row exhaust their retries, or when it has been failing for twenty-four hours without a single success. The workspace admins are notified, once.
While it is paused nothing is queued, so POST /webhooks/{id}/resume does not resend the events from that period. What was lost is recovered from the delivery log, one redelivery at a time.
In the app: the same numbers, per recipient and live, are in Campaigns, next to the manual pause button that emits no event.
The identifiers on this page will be different in your workspace; the shape is the same.
The full event catalogue is in Events, and the signature, the retries and the delivery modes in Receiving signed events. To build the recipient list before the campaign, see Search and filter contacts into an exportable list.