Skip to main content

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" } }
]
}
}
FieldNotes
name1–200 chars, internal
contentrequired unless templateId or silent: true is given; with an experiment it is the fallback
templateIdstart from a template; request fields win over the template's
segmentomit for everyone
segmentIda 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?}
silentbackground push, no content needed; web is skipped. See Silent pushes
experiment2–4 variants; see A/B testing. Not with silent.
restas in Notifications

201 { "id": "66f3…", "status": "draft" }. Nothing is sent yet.

POST /v1/campaigns/:id/send

No body. From draft or scheduled:

  • schedule now202 { "status": "sending" }, fan-out queued;
  • at / user_timezone202 { "status": "scheduled" }, held as a delayed job;
  • recurring202 { "status": "active" }, a job scheduler is installed and each tick creates a run (a child campaign with recurringOf). 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 active200 { "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.