Campaigns API
Secret key. campaigns:write for mutations, campaigns:read for stats.
POST /v1/campaigns — create a draft
Accepts Idempotency-Key.
{
"name": "Weekend flash sale",
"content": { "en": { "title": "…", "body": "…" }, "tr": { "title": "…", "body": "…" }, "_default": "en" },
"segment": { "field": "tags.marketingOptIn", "op": "eq", "value": true },
"schedule": { "type": "at", "at": "2026-10-03T07:00:00Z" },
"data": { "screen": "sale" },
"ttl": 259200, "collapseId": "weekend-sale", "priority": "high",
"presentation": { "sound": "default", "threadId": "promotions" },
"bypass": { "frequencyCap": false },
"experiment": {
"sampleRate": 0.5,
"variants": [
{ "id": "a", "name": "Control", "weight": 1, "content": { "en": { "title": "…", "body": "…" }, "_default": "en" } },
{ "id": "b", "name": "Emoji", "weight": 1, "content": { "en": { "title": "… ⚡", "body": "…" }, "_default": "en" } }
]
}
}
| Field | Notes |
|---|---|
name | 1–200 chars, internal |
content | required unless templateId or silent: true is given; with an experiment it is the fallback |
templateId | start from a template; request fields win over the template's |
segment | omit for everyone |
segmentId | a saved segment; not together with segment. Copied in at send time. |
schedule | {type:"now"} (default) · {type:"at", at} · {type:"user_timezone", localTime:"HH:MM"} · {type:"recurring", every:"day"|"week", daysOfWeek?, localTime, timezone, until?} |
silent | background push, no content needed; web is skipped. See Silent pushes |
experiment | 2–4 variants; see A/B testing. Not with silent. |
| rest | as in Notifications |
201 { "id": "66f3…", "status": "draft" }. Nothing is sent yet.
POST /v1/campaigns/:id/send
No body. From draft or scheduled:
- schedule
now→202 { "status": "sending" }, fan-out queued; at/user_timezone→202 { "status": "scheduled" }, held as a delayed job;recurring→202 { "status": "active" }, a job scheduler is installed and each tick creates a run (a child campaign withrecurringOf). See Scheduling.
If the campaign has a segmentId, the saved segment's definition is copied into it now (404 if the segment was deleted).
409 invalid_state if the campaign is in any other status or has no content (a dashboard draft whose copy is not finished).
POST /v1/campaigns/:id/cancel
From draft, scheduled or active → 200 { "status": "cancelled" }; the delayed job or the recurring scheduler is removed. 409 invalid_state once fan-out has started. Runs already created by a recurring campaign are separate campaigns and are not affected.
POST /v1/campaigns/:id/experiment/winner
{ "variant": "b" }
202 { "id": "<follow-up campaign>", "status": "sending" }. Sends the winning content to the held-out remainder as a new campaign linked back with remainderOf. Errors: 409 (not an experiment / no remainder / already sent / still sending), 422 (unknown variant). See A/B testing.
GET /v1/campaigns/:id/stats
{
"id": "66f3…",
"status": "sent",
"stats": {
"queued": 1650, "sent": 1534, "failed": 49, "skipped": 67,
"delivered": 1396, "opened": 433, "clicked": 173,
"variants": {
"a": { "sent": 770, "failed": 24, "skipped": 30, "delivered": 700, "opened": 180, "clicked": 70 },
"b": { "sent": 764, "failed": 25, "skipped": 37, "delivered": 696, "opened": 253, "clicked": 103 }
}
},
"rates": { "delivery": 0.91, "open": 0.3102, "click": 0.1239 }
}
rates.open and rates.click are against delivered; null when the denominator is 0. variants is present only for experiments. Counters keep moving after status is sent as devices report opens.
Lifecycle
There is no GET /v1/campaigns list or DELETE on the public API; the dashboard has both through the admin surface. Keep your own record of ids you create.