Campaigns
Bulk WhatsApp or email sends to an audience: draft, schedule, pause and recipients.
Download the full API pública projection: openapi.json.
| Method | Path | What it does |
|---|---|---|
GET | /campaigns | List campaigns |
POST | /campaigns | Create a campaign |
DELETE | /campaigns/{id} | Delete a campaign |
GET | /campaigns/{id} | Fetch one campaign with its stats |
PATCH | /campaigns/{id} | Edit a campaign |
POST | /campaigns/{id}/cancel | Cancel the campaign |
PUT | /campaigns/{id}/header-media | Change a WhatsApp campaign's media-header picture |
GET | /campaigns/{id}/meta-insights | Meta’s own analytics for the campaign’s template |
POST | /campaigns/{id}/pause | Pause the campaign |
GET | /campaigns/{id}/preflight | «¿Lista para enviar?» — the checks before a send |
GET | /campaigns/{id}/recipients | List the campaign’s recipients |
GET | /campaigns/{id}/recipients/export.csv | Export the campaign’s recipients as CSV |
GET | /campaigns/{id}/results | The campaign’s results |
POST | /campaigns/{id}/resume | Resume a paused campaign |
POST | /campaigns/{id}/schedule | Schedule the campaign |
POST | /campaigns/{id}/send | Send the campaign now |
POST | /campaigns/{id}/test-send | Send a test of the campaign to up to five people |
GET | /campaigns/messaging-budget | A WhatsApp number's messaging-limit budget, and a send plan |
GET /campaigns
List campaigns
Filter by status, channel and/or audience_id (the campaigns that used or will use one audience). Paginated with limit (default 50, max 100) and offset; the count comes back as meta.pagination.total, NOT meta.total.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
status | query | draft \ | scheduled \ | sending \ |
channel | query | whatsapp \ | email \ | voice |
audience_id | query | uuid | no | |
limit | query | integer | no | ≥ 1, ≤ 100, por defecto 50 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
curl https://api.vitrinadev.com/api/v1/campaigns \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "sending",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": "2026-10-01T15:00:00.000Z",
"finished_at": null,
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 500,
"suppressed_count": 12,
"sent_count": 120,
"delivered_count": 110,
"read_count": 64,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z"
},
{
"id": "7a7a7a7a-0000-4000-8000-0000000000c2",
"name": "Promoción de octubre",
"channel": "whatsapp",
"status": "draft",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": null,
"finished_at": null,
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 0,
"suppressed_count": 0,
"sent_count": 0,
"delivered_count": 0,
"read_count": 0,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z"
}
],
"meta": {
"pagination": {
"total": 2,
"limit": 50
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /campaigns
Create a campaign
Creates it in draft. Nothing is validated for sendability here — a campaign with no audience and no content is a perfectly legal draft, and the gates only run at schedule/send time.
Which fields apply depends on channel: email uses content (subject, html, from_local, and an opaque design blob the builder reloads its canvas from); whatsapp uses template_id plus template_params, which maps each template variable to a contact field or a static value with an optional fallback; voice uses neither. Passing scheduled_at here records the intent but does NOT schedule the campaign — call /schedule for that. Answers 201.
send_mode (WhatsApp, ADR 0129): paced (default) continues across the rolling 24 h windows of the number’s messaging limit by itself; asap sends what fits now and pauses (budget_exhausted). pilot_size (1–1000) sends that many random recipients first and pauses (pilot) until resumed. send_window (WhatsApp): when paced batches may leave — \{ timezone (IANA), days [1–7], start "HH:MM", end "HH:MM" \}, start < end, same day; null = the workspace default. An invalid one is a 400 with field_errors.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | mín. 1, máx. 200 |
channel | whatsapp \ | email \ | voice |
audience_id | string | null | — | |
messaging_account_id | string | null | — | |
template_id | string | null | — | |
template_params | object | — | |
content | object | — | |
reply_mode | account_default \ | human | — |
scheduled_at | string | null | — | date-time |
send_mode | paced \ | asap | — |
pilot_size | integer | null | — | ≥ 1, ≤ 1000 |
send_window | any | — | |
send_schedule | any | — |
curl -X POST https://api.vitrinadev.com/api/v1/campaigns \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Recordatorio de control",
"channel": "whatsapp",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"send_mode": "paced"
}'Example response (201)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "draft",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": null,
"finished_at": null,
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 0,
"suppressed_count": 0,
"sent_count": 0,
"delivered_count": 0,
"read_count": 0,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z"
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /campaigns/{id}
Delete a campaign
Only draft and cancelled campaigns can be deleted; anything else is a 409. A campaign that has sent is a record of messages that reached real people and stays. Cancel first if you need it gone.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/campaigns/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /campaigns/{id}
Fetch one campaign with its stats
The campaign plus its delivery counters — this is the read to poll while a send is in progress. A WhatsApp campaign that is not finished also carries pacing (ADR 0129): its number’s tier and limit_per_24h, sent_last_24h, pending, waiting (a paced campaign waiting for the window) with next_batch_at, estimated_completion_at and windows_remaining, plus the effective send_window (send_window_source: campaign | workspace), its window_label and next_window_opens_at (outside the window now); null when the budget cannot be read. With a send_schedule (ADR 0139) it also carries schedule_label, next_slot_at, batches (what each slot did: planned / sent / failed / skipped_reason, oldest first) and schedule — the pending part simulated slot by slot. A campaign paused with paused_reason: delivery_guard carries paused_detail (sends, undeliverable 131026, withheld 131049, rate 0-1), computed from the recipients; null otherwise.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/campaigns/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "sending",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": "2026-10-01T15:00:00.000Z",
"finished_at": null,
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 500,
"suppressed_count": 12,
"sent_count": 120,
"delivered_count": 110,
"read_count": 64,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:00:00.000Z",
"recipient_counts": {
"pending": 380,
"sent": 10,
"delivered": 46,
"read": 64,
"failed": 0,
"suppressed": 12
},
"pacing": {
"send_mode": "paced",
"tier": "TIER_1K",
"tier_unknown": false,
"limit_per_24h": 1000,
"sent_last_24h": 120,
"pending": 380,
"waiting": false,
"next_batch_at": null,
"estimated_completion_at": "2026-10-01T16:00:00.000Z",
"windows_remaining": 1,
"sendable_now": 380,
"campaign_headroom": 760
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /campaigns/{id}
Edit a campaign
Legal only while the campaign is draft or scheduled; from any other status this is a 409 — except a body carrying ONLY send_window and/or send_schedule, which is also legal while paused or sending and takes effect at the next batch (a campaign waiting for the old window or slot re-plans at once). At least one field is required. A send_schedule with send_mode: asap (set or stored) is a 400 schedule_requires_paced; a slot in the past or two slots at once a 400 schedule_invalid. channel is absent from the body on purpose — a campaign’s channel is fixed at creation, since the content shape depends on it.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | — | mín. 1, máx. 200 |
audience_id | string | null | — | |
messaging_account_id | string | null | — | |
template_id | string | null | — | |
template_params | object | — | |
content | object | — | |
reply_mode | account_default \ | human | — |
send_mode | paced \ | asap | — |
pilot_size | integer | null | — | ≥ 1, ≤ 1000 |
send_window | any | — | |
send_schedule | any | — |
curl -X PATCH https://api.vitrinadev.com/api/v1/campaigns/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Recordatorio de control (octubre)",
"send_mode": "asap",
"pilot_size": 50
}'Example response (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control (octubre)",
"channel": "whatsapp",
"status": "draft",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": null,
"finished_at": null,
"paused_reason": null,
"send_mode": "asap",
"pilot_size": 50,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 0,
"suppressed_count": 0,
"sent_count": 0,
"delivered_count": 0,
"read_count": 0,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:30:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /campaigns/{id}/cancel
Cancel the campaign
Terminal, and the widest transition here: legal from draft, scheduled, sending and paused. There is no un-cancel — a cancelled campaign can only be deleted or left as a record. Recipients already delivered to are unaffected.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/campaigns/<id>/cancel \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "cancelled",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": "2026-10-01T15:00:00.000Z",
"finished_at": "2026-10-01T15:50:00.000Z",
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 500,
"suppressed_count": 12,
"sent_count": 120,
"delivered_count": 110,
"read_count": 64,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T15:50:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /campaigns/{id}/header-media
Change a WhatsApp campaign's media-header picture
Replaces the media header (image / video / PDF) of a WhatsApp campaign — the one change still legal while it is sending or paused besides the send window/schedule; also legal in draft and scheduled. Body \{ url \}: a public https link (e.g. from POST /whatsapp-templates/header-media).
In one transaction it sets template_params.header_media = \{ source: 'static', value: url \} and rewrites the frozen params.header_media of every recipient still pending, so each one not yet sent gets the new picture. Recipients already sent, delivered, read or failed are history and keep the one they received. A batch the worker had already picked up when this commits may still leave with the old picture; the next batch carries the new one.
The 200 is the campaign detail (the same data as GET /campaigns/\{id\}) with meta.updated_recipients — how many pending recipients changed.
Refusals (error.details.reason): 409 campaign_closed — the campaign is sent or cancelled; 400 header_media_not_supported — not a WhatsApp campaign, or its template has no media header; 400 header_media_invalid — the url is not a public https link (the same rule as template_params.header_media on create/PATCH). Fires the campaign.updated realtime event.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X PUT https://api.vitrinadev.com/api/v1/campaigns/<id>/header-media \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://media.example.com/promo-noviembre.jpg"
}'Example response (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "sending",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
},
"header_media": {
"source": "static",
"value": "https://media.example.com/promo-noviembre.jpg"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": "2026-10-01T15:00:00.000Z",
"finished_at": null,
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 500,
"suppressed_count": 12,
"sent_count": 120,
"delivered_count": 110,
"read_count": 64,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T15:10:00.000Z",
"recipient_counts": {
"pending": 380,
"sent": 10,
"delivered": 46,
"read": 64,
"failed": 0,
"suppressed": 12
},
"pacing": {
"send_mode": "paced",
"tier": "TIER_1K",
"tier_unknown": false,
"limit_per_24h": 1000,
"sent_last_24h": 120,
"pending": 380,
"waiting": false,
"next_batch_at": null,
"estimated_completion_at": "2026-10-01T16:00:00.000Z",
"windows_remaining": 1,
"sendable_now": 380,
"campaign_headroom": 760
},
"paused_detail": null
},
"meta": {
"updated_recipients": 380
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /campaigns/{id}/meta-insights
Meta’s own analytics for the campaign’s template
Meta’s per-template analytics — sent, delivered, read, clicks per button (quick-reply AND URL buttons) and cost — for the campaign’s WhatsApp template over its days (started_at → finished_at or now, padded to whole UTC days, at most the last 90), read through the messaging bridge (ADR 0137) and cached 15 minutes. These are the TEMPLATE’s numbers over those days: another campaign sending the same template on the same days shares them.
202 \{ data: \{ status: "enabling", retry_after_seconds \} \} the first time, while Meta switches the account’s template insights on — ask again after retry_after_seconds. 409 when the campaign has not started; 422 for a campaign that is not a WhatsApp template campaign; 404 when the bridge does not know the template; 429 (with retry_after_seconds) when Meta throttles; 502 when Meta is unavailable; 501 meta_insights_unavailable when Meta’s numbers cannot exist for the account: reason not_on_bridge (not connected through the bridge) or coexistence (a WhatsApp Business app number — Meta refuses template insights there).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/campaigns/<id>/meta-insights \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"status": "ready",
"template": {
"name": "promo_octubre",
"language": "es",
"external_id": "1234567890"
},
"range": {
"start": "2026-10-01",
"end": "2026-10-14"
},
"totals": {
"sent": 820,
"delivered": 790,
"read": 512,
"cost": {
"amount": "41.2",
"currency": "USD"
}
},
"clicks": [
{
"type": "quick_reply_button",
"button": "Quiero más información",
"count": 61
},
{
"type": "url_button",
"button": "Ver promoción",
"count": 37
}
],
"daily": [
{
"date": "2026-10-13",
"sent": 400,
"delivered": 388,
"read": 250,
"clicks": 52
}
],
"fetched_at": "2026-10-14T13:05:00.000Z"
}
}Answers: 200 · 202 · 400 · 401 · 403 · 404 · 409 · 429 · 501
POST /campaigns/{id}/pause
Pause the campaign
Legal from sending and scheduled; a 409 otherwise. Stops further delivery — messages already sent are already sent. A person's pause reads paused_reason: manual. The platform also pauses on its own: messaging_limit (Meta pushed back; resumes by itself within the hour), number_quality (the WhatsApp number's quality turned RED; every sending or scheduled campaign on the number and its WABA siblings stops, and only a person resumes it), budget_exhausted, delivery_guard (WhatsApp: undeliverable 131026 + withheld 131049 failures passed 10% of the sends, after 50), pilot, schedule_exhausted, config_error and reputation_guard (email). Each automatic pause fires the campaign.paused webhook.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/campaigns/<id>/pause \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "paused",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": "2026-10-01T15:00:00.000Z",
"finished_at": null,
"paused_reason": "manual",
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 500,
"suppressed_count": 12,
"sent_count": 120,
"delivered_count": 110,
"read_count": 64,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T15:20:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /campaigns/{id}/preflight
«¿Lista para enviar?» — the checks before a send
Everything worth knowing before pressing send, as a check list (ADR 0139): each \{ key, status: ok | warn | block, title_es/_en, detail_es/_en?, href? \}. WhatsApp: template_approved, recipients (reachable vs not), header_media, test_sent (a test in the last 7 days), messaging_tier, number_quality, schedule (the simulation’s issues), replies (who answers the replies: the AI agent the number routes to, or the team — warn when the team answers and some sends fall outside the contact hours), opt_out (a quick-reply button to say no), recent_campaign_overlap (people who got a campaign in the last 7 days). Email: template, recipients, unsubscribe, test_sent, sender_domain. ready = no block; /send refuses with 409 preflight_blocked otherwise. summary says how it will go (recipients, schedule label, first batch, completion, batches) and plan is the schedule simulated («Ver plan»). Reads only.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/campaigns/<id>/preflight \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"ready": true,
"checks": [
{
"key": "template_approved",
"status": "ok",
"title_es": "Plantilla aprobada por Meta",
"title_en": "Template approved by Meta",
"detail_es": "promo_octubre",
"detail_en": "promo_octubre"
},
{
"key": "recipients",
"status": "warn",
"title_es": "Se enviará a 801 personas",
"title_en": "It will go to 801 people",
"detail_es": "50 no la recibirán: sin número válido, duplicadas o pidieron no recibir mensajes.",
"detail_en": "50 will not get it: no valid number, duplicates or opted out.",
"href": "/campaigns/audiences/7a7a7a7a-0000-4000-8000-0000000000a1"
},
{
"key": "messaging_tier",
"status": "ok",
"title_es": "Tu número puede iniciar 250 conversaciones cada 24 h",
"title_en": "Your number can start 250 conversations every 24 h",
"detail_es": "Las campañas dejan 50 libres para recordatorios y otros mensajes.",
"detail_en": "Campaigns leave 50 free for reminders and other messages."
}
],
"summary": {
"recipients": 801,
"channel": "whatsapp",
"template_name": "promo_octubre",
"schedule_label_es": "Todos los días a las 20:00 (America/Santiago) desde hoy",
"schedule_label_en": "Every day at 20:00 (America/Santiago) from today",
"first_batch_at": "2026-10-01T23:00:00.000Z",
"completes_at": "2026-10-05T23:00:00.000Z",
"batches_count": 5,
"limit_per_24h": 250,
"reserve": 50
},
"plan": {
"batches": [
{
"at": "2026-10-01T23:00:00.000Z",
"local": "jue 1 oct, 20:00",
"planned": 200,
"slot_index": null,
"warnings": []
}
],
"covers_all": true,
"remaining_after_last": 0,
"completes_at": "2026-10-05T23:00:00.000Z",
"issues": [],
"tier_unknown": false
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /campaigns/{id}/recipients
List the campaign’s recipients
One row per recipient with its delivery state — pending, sent, delivered, read, failed or suppressed. suppressed means the contact was in the audience but the suppression list stopped the send, which is a successful outcome rather than a failure.
The list only exists once the run materialises it, so a draft or not-yet-started campaign answers empty rather than previewing who WOULD receive it. limit defaults to 50 and caps at 500; the count is meta.pagination.total.
Filters: state, error_code (Meta's code, failed rows of one reason) and replied. Each row carries Meta's raw error, the same failure in plain language with a suggested action (error_reason), and replied_button — the template quick-reply button the person's first reply tapped (null for free text or no reply).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
state | query | pending \ | sent \ | delivered \ |
error_code | query | string | no | mín. 1, máx. 40 |
replied | query | true \ | false | no |
limit | query | integer | no | ≥ 1, ≤ 500, por defecto 50 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
curl https://api.vitrinadev.com/api/v1/campaigns/<id>/recipients \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "7a7a7a7a-0000-4000-8000-000000000101",
"campaign_id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"contact_id": "7a7a7a7a-0000-4000-8000-0000000000f1",
"identifier": "+56912345678",
"params": {
"1": "María",
"2": "15 de noviembre"
},
"rendered_text": null,
"state": "pending",
"external_message_id": null,
"error": null,
"sent_at": null,
"replied_at": null,
"replied_conversation_id": null,
"created_at": "2026-10-01T15:00:00.000Z"
},
{
"id": "7a7a7a7a-0000-4000-8000-000000000102",
"campaign_id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"contact_id": "7a7a7a7a-0000-4000-8000-0000000000f2",
"identifier": "+56987654321",
"params": {
"1": "cliente",
"2": "15 de noviembre"
},
"rendered_text": null,
"state": "failed",
"external_message_id": null,
"error": {
"code": "131026",
"title": "Message undeliverable",
"detail": null
},
"error_reason": {
"code": "131026",
"human_es": "El número no tiene WhatsApp o no puede recibir este mensaje (app desactualizada, no aceptó los términos o bloqueó a la empresa).",
"human_en": "The number is not on WhatsApp or cannot receive this message (outdated app, terms not accepted, or it blocked the business).",
"action": "remove_number"
},
"replied_button": null,
"sent_at": null,
"replied_at": null,
"replied_conversation_id": null,
"created_at": "2026-10-01T15:00:00.000Z"
},
{
"id": "7a7a7a7a-0000-4000-8000-000000000103",
"campaign_id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"contact_id": null,
"identifier": "+56955500000",
"params": {},
"rendered_text": null,
"state": "suppressed",
"external_message_id": null,
"error": null,
"sent_at": null,
"replied_at": null,
"replied_conversation_id": null,
"created_at": "2026-10-01T15:00:00.000Z"
}
],
"meta": {
"pagination": {
"total": 500,
"limit": 50
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /campaigns/{id}/recipients/export.csv
Export the campaign’s recipients as CSV
The whole recipients list (same filters as the list: state, error_code, replied) as a UTF-8 CSV with a BOM and Spanish headers: Nombre, Identificador, Estado, Enviado, Entregado, Leído, Respondió, Botón, Código de error, Motivo, Acción sugerida. Times are in the workspace time zone. Requires campaigns:read AND contacts:read (it carries names and phone numbers).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
state | query | pending \ | sent \ | delivered \ |
error_code | query | string | no | mín. 1, máx. 40 |
replied | query | true \ | false | no |
curl https://api.vitrinadev.com/api/v1/campaigns/<id>/recipients/export.csv \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /campaigns/{id}/results
The campaign’s results
What happened to the campaign, from its own recipients: the funnel (recipients → sent → delivered → read → replied, plus failed, opted_out, suppressed, pending) and its rates (delivered/sent, read/delivered, replied/delivered); replies per template quick-reply button (a reply whose first message is a button label) and the free_text rest; failures grouped by Meta error code, each with a plain-language reason (es/en) and a suggested action; opt-outs attributable to the campaign (recorded by it, or on a recipient’s number within the reply-attribution window after the send) by reason; and a timeline of sends, deliveries, reads and replies per hour (per day past a week), bucketed in the workspace time zone. Delivery/read times are recorded from 2026-10-14 on; older sends show in the funnel but not in the timeline.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/campaigns/<id>/results \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"campaign_id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"channel": "whatsapp",
"funnel": {
"recipients": 64,
"sent": 64,
"delivered": 52,
"read": 40,
"replied": 17,
"opted_out": 2,
"failed": 9,
"suppressed": 0,
"pending": 0
},
"rates": {
"delivered": 0.8125,
"read": 0.7692,
"replied": 0.3269
},
"buttons": [
{
"text": "Quiero más información",
"count": 10
},
{
"text": "No me interesa",
"count": 2
}
],
"free_text": 5,
"failures": [
{
"code": "131026",
"title": "Message undeliverable",
"human_es": "El número no tiene WhatsApp o no puede recibir este mensaje (app desactualizada, no aceptó los términos o bloqueó a la empresa).",
"human_en": "The number is not on WhatsApp or cannot receive this message (outdated app, terms not accepted, or it blocked the business).",
"action": "remove_number",
"count": 4
},
{
"code": "130472",
"title": "User's number is part of an experiment",
"human_es": "Meta retiene mensajes de marketing a una muestra de usuarios para un experimento. No depende de ti.",
"human_en": "Meta holds back marketing messages from a sample of users for an experiment. Nothing on your side.",
"action": "none",
"count": 2
}
],
"opt_outs": {
"count": 2,
"by_reason": {
"stop_keyword": 1,
"classified_marketing": 1
}
},
"timeline": {
"unit": "hour",
"timezone": "America/Santiago",
"points": [
{
"at": "2026-09-15T17:00:00.000Z",
"sent": 64,
"delivered": 52,
"read": 10,
"replied": 4
},
{
"at": "2026-09-15T18:00:00.000Z",
"sent": 0,
"delivered": 0,
"read": 4,
"replied": 2
}
]
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /campaigns/{id}/resume
Resume a paused campaign
Where it resumes to depends on whether it had started. A campaign paused mid-send returns to sending and continues from where it got to; one paused before the recipient list was ever materialised returns to scheduled with the schedule reset to now. So the same call produces two different statuses, and neither is an error.
An optional body \{ send_mode \} switches how the rest is sent — e.g. an asap campaign the messaging-limit budget paused (budget_exhausted) resumed as paced continues by itself across the following days. A pilot (paused_reason: pilot) resumes into the full send.
A WhatsApp number whose quality is not GREEN asks for confirmation. When the stored quality of the campaign's number is YELLOW or RED (or unknown after a number_quality pause), a resume without acknowledge_quality: true answers 409 with details: \{ reason: "number_quality_not_green", quality_rating: "RED" | "YELLOW" | null \} and leaves it paused; with it, the campaign resumes. Nothing resumes a number_quality pause automatically.
A 409 if the campaign is not paused. An EMAIL campaign is re-checked against the connected-mailbox rule before it resumes (a mailbox may have been connected, or the sending domain removed, while it was paused): a 422 CAMPAIGN_FROM_CONNECTED_MAILBOX leaves it paused.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/campaigns/<id>/resume \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"send_mode": "paced"
}'Example response (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "sending",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": null,
"started_at": "2026-10-01T15:00:00.000Z",
"finished_at": null,
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 500,
"suppressed_count": 12,
"sent_count": 120,
"delivered_count": 110,
"read_count": 64,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T15:45:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /campaigns/{id}/schedule
Schedule the campaign
Before a campaign may leave draft it must pass the readiness gates, and each unmet one is its own 400: an audience must be set and still exist; an EMAIL campaign additionally needs subject/body/from and a VERIFIED sending domain; a WHATSAPP campaign needs a template that is APPROVED on the same messaging account it will send from, with every template variable mapped. Voice campaigns have their own gates and no template — the agent speaks the call.
An EMAIL campaign never leaves from a person's connected mailbox (ADR 0108 §Decision 10, ADR 0032): it goes out through the campaign sending provider under the verified sending domain, and the connected mailbox may only be its Reply-To. A campaign that would resolve to a connected mailbox — the workspace has no verified sending domain but has a connected mailbox, or the From address IS a connected mailbox's address — is refused whole with a 422 CAMPAIGN_FROM_CONNECTED_MAILBOX (error.details: reason no_campaign_domain | from_is_mailbox, address, messaging_account_id), before any recipient is written or queued. This check runs FIRST, before the first-campaign platform approval gate: where mail leaves from is decided before whether the workspace may campaign.
scheduled_at must be in the future — a timestamp more than a minute in the past is a 400, though a slightly-past one is tolerated for clock skew. Legal from draft and from scheduled (rescheduling), and a 409 from anywhere else.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
scheduled_at | string | yes | date-time |
curl -X POST https://api.vitrinadev.com/api/v1/campaigns/<id>/schedule \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"scheduled_at": "2026-10-05T13:00:00.000Z"
}'Example response (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "scheduled",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": "2026-10-05T13:00:00.000Z",
"started_at": null,
"finished_at": null,
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 500,
"suppressed_count": 12,
"sent_count": 0,
"delivered_count": 0,
"read_count": 0,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T14:40:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /campaigns/{id}/send
Send the campaign now
"Now" means scheduled-for-this-instant. The campaign this returns is in status scheduled, not sending — the worker moves it to sending when it picks the run up. A client that waits for sending in this response waits forever; poll GET /campaigns/\{id\} instead.
Before a campaign may leave draft it must pass the readiness gates, and each unmet one is its own 400: an audience must be set and still exist; an EMAIL campaign additionally needs subject/body/from and a VERIFIED sending domain; a WHATSAPP campaign needs a template that is APPROVED on the same messaging account it will send from, with every template variable mapped. Voice campaigns have their own gates and no template — the agent speaks the call.
An EMAIL campaign never leaves from a person's connected mailbox (ADR 0108 §Decision 10, ADR 0032): it goes out through the campaign sending provider under the verified sending domain, and the connected mailbox may only be its Reply-To. A campaign that would resolve to a connected mailbox — the workspace has no verified sending domain but has a connected mailbox, or the From address IS a connected mailbox's address — is refused whole with a 422 CAMPAIGN_FROM_CONNECTED_MAILBOX (error.details: reason no_campaign_domain | from_is_mailbox, address, messaging_account_id), before any recipient is written or queued. This check runs FIRST, before the first-campaign platform approval gate: where mail leaves from is decided before whether the workspace may campaign.
Legal from draft and scheduled; a 409 from anywhere else. Sending is not undoable once recipients start receiving — /pause stops the remainder, it does not recall what went out.
The response is the campaign plus started: true and waiting_for_window_until: a paced campaign (or a pilot) sent outside its send window sends nothing yet and waits for the window — this is when it opens; null = the first batch leaves now. With a send_schedule it is the first slot.
Optional body \{ send_schedule \} (ADR 0139): sets the schedule and sends in one step. The «¿Lista para enviar?» checks (GET /campaigns/\{id\}/preflight) run again here: while any is block the answer is a 409 preflight_blocked with the checks in error.details.checks, and nothing changes.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/campaigns/<id>/send \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"send_schedule": {
"kind": "daily",
"timezone": "America/Santiago",
"start_date": "2026-10-01",
"time": "20:00"
}
}'Example response (200)
{
"data": {
"id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"name": "Recordatorio de control",
"channel": "whatsapp",
"status": "scheduled",
"tenant_id": "7a7a7a7a-0000-4000-8000-000000000001",
"audience_id": "7a7a7a7a-0000-4000-8000-0000000000a1",
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"template_id": "7a7a7a7a-0000-4000-8000-0000000000d1",
"template_params": {
"1": {
"source": "contact_field",
"field": "first_name",
"fallback": "cliente"
},
"2": {
"source": "static",
"value": "15 de noviembre"
}
},
"content": {},
"reply_mode": "account_default",
"scheduled_at": "2026-10-01T15:00:00.000Z",
"started_at": null,
"finished_at": null,
"paused_reason": null,
"send_mode": "paced",
"pilot_size": null,
"pilot_completed_at": null,
"pacing_next_batch_at": null,
"total_recipients": 500,
"suppressed_count": 12,
"sent_count": 0,
"delivered_count": 0,
"read_count": 0,
"failed_count": 0,
"replied_count": 0,
"created_by": "7a7a7a7a-0000-4000-8000-000000000011",
"created_at": "2026-10-01T14:00:00.000Z",
"updated_at": "2026-10-01T15:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 422 · 429
POST /campaigns/{id}/test-send
Send a test of the campaign to up to five people
Sends the campaign, exactly as it will leave, to 1–5 recipients typed by hand — phone for a WhatsApp campaign, email for an email one — and answers one result per recipient: sent (with provider_message_id) or failed (with error_code). It writes NO recipient rows and does not touch the campaign’s status or counters, so it is safe on a scheduled, paused or sent campaign and leaves no trace in the stats; a sending or cancelled campaign answers 409 campaign_not_testable.
Readiness is the real send’s, minus the audience: a WhatsApp test needs the template APPROVED on the campaign’s connected account and a resolvable media header (400 header_media_missing otherwise); an email test needs content and a verified sending domain.
Values. Per template slot: variables[slot] when typed → else the campaign mapping read with the typed recipient as the contact (a contact_field slot gets the typed name / phone / email, else the mapping’s fallback; a static slot its text) → else the template’s example. A slot still empty refuses that recipient (missing_variables). The media header is always the campaign’s own (template_params.header_media) else the template default — never a test variable. Each result echoes values with the source of each.
WhatsApp tests are conversations. The number’s contact and its active thread on the campaign’s account are found or created, and the message is stored there (metadata.campaign_test: true), so a reply reaches the AI agent with the campaign in context — and is never attributed to the campaign’s metrics. conversation_id points at it.
Refusals per recipient: invalid_phone, landline, invalid_email, duplicate, opted_out (with opt_out_reason), contact_blocked, safety_hold, missing_variables, email_not_verified (SES sandbox: only verified addresses), send_failed (with provider_code).
Rate limit: 20 test messages per campaign and 60 per workspace per hour, counted for the recipients actually sent to; past it the whole request is refused with 429 RATE_LIMITED (details.retry_after_s).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/campaigns/<id>/test-send \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{
"phone": "+56 9 1234 5678",
"name": "María González",
"variables": {
"1": "María"
}
}
]
}'Example response (200)
{
"data": {
"campaign_id": "7a7a7a7a-0000-4000-8000-0000000000c1",
"channel": "whatsapp",
"header_media_url": null,
"remaining_this_hour": 4,
"results": [
{
"phone": "+56912345678",
"name": "María González",
"status": "sent",
"conversation_id": "7a7a7a7a-0000-4000-8000-000000000201",
"contact_id": "7a7a7a7a-0000-4000-8000-0000000000f1",
"rendered_text": "Hola María, tu próximo control es el 15 de noviembre.",
"values": {
"1": {
"value": "María",
"source": "variable"
},
"2": {
"value": "15 de noviembre",
"source": "static"
}
}
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /campaigns/messaging-budget
A WhatsApp number's messaging-limit budget, and a send plan
Meta caps how many unique people a WhatsApp business may START a conversation with (a template outside the customer’s 24 h window) in a rolling 24 h — the messaging-limit tier (TIER_250, TIER_2K, …), shared by the numbers of one portfolio. budget is where the number stands: limit_per_24h (null = no cap: unlimited, or tier_unknown), the unique people messaged in the last 24 h by every template sender (used_last_24h, split campaigns / others), the reserve campaigns leave for everyone else — max(20 % of the limit, the p95 of the number’s own daily non-campaign usage over 7 days), capped at 80 % — and the campaign_headroom left right now.
With recipients, plan says how a campaign to that many people would go: sendable_now (its fair share of the headroom, shared with the other campaigns sending on the number), windows (sending days), estimated_completion_at (continuations move into the workspace’s marketing hours) and the recommended_send_mode. campaign_id makes a saved campaign’s own recent sends count as its. 404 for a number that is not this workspace’s, 400 for one that is not WhatsApp.
send_window (JSON-encoded, optional) plans against that window — null = the workspace default; absent = the saved campaign’s effective window (with campaign_id), else the workspace default. The plan then answers send_window (resolved), window_label («lun–vie 10:00–18:00 (America/Santiago)») and, when it is outside the window now, next_window_opens_at — the first batch, next_batch_at and estimated_completion_at start there, and every continuation lands inside the window. An invalid window is a 400 with field_errors (send_window.start, …).
send_schedule (JSON-encoded CampaignSendSchedule, optional; null = none; absent = the saved campaign’s) plans against explicit instants instead (ADR 0139): plan.schedule simulates it batch by batch against the rolling 24 h — planned per slot, per-slot warnings (budget_not_freed, partial_budget, outside_contact_hours — only when the team answers the replies, see reply_mode —, in_the_past), covers_all, completes_at and issues (slot_in_past, duplicate_slot, too_close with a suggested_at, uncovered, asap_not_allowed). The window fields are then null: a schedule replaces the window.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
messaging_account_id | query | uuid | yes | |
recipients | query | integer | null | no | ≥ 0, ≤ 1000000 |
campaign_id | query | uuid | no | |
send_window | query | string | no | máx. 1000 |
send_schedule | query | string | no | máx. 10000 |
reply_mode | query | account_default \ | human | no |
curl https://api.vitrinadev.com/api/v1/campaigns/messaging-budget \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"budget": {
"messaging_account_id": "7a7a7a7a-0000-4000-8000-0000000000b1",
"account_ids": [
"7a7a7a7a-0000-4000-8000-0000000000b1"
],
"waba_id": null,
"tier": "TIER_1K",
"tier_unknown": false,
"unlimited": false,
"quality_rating": "GREEN",
"tier_observed_at": "2026-10-01T08:00:00.000Z",
"limit_per_24h": 1000,
"used_last_24h": 180,
"used_by_campaigns_last_24h": 120,
"used_by_other_senders_last_24h": 60,
"remaining": 820,
"reserve": 100,
"reserve_basis": {
"pct_floor": 100,
"observed_p95": 60
},
"campaign_cap": 900,
"campaign_headroom": 780,
"window_resets_at": "2026-10-02T08:00:00.000Z",
"next_batch_at": null,
"computed_at": "2026-10-01T15:30:00.000Z"
},
"plan": {
"recipients": 1500,
"sendable_now": 780,
"fits_now": false,
"per_window": 900,
"windows": 2,
"estimated_completion_at": "2026-10-02T16:00:00.000Z",
"recommended_send_mode": "paced",
"next_batch_at": "2026-10-02T08:00:00.000Z"
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429