Sending
There are two ways to get a push out. They share content, delivery options, the worker, and the report; they differ in who they address and when.
| Transactional | Campaign | |
|---|---|---|
| Endpoint | POST /v1/notifications | Wizard, or POST /v1/campaigns then POST /v1/campaigns/:id/send |
| Addressed by | specific users / devices, a segment, or everyone | a segment or everyone |
| Timing | immediately | now, at a time, at the user's local time, or recurring |
| Status | 202 with a campaign id, transactional: true | draft → scheduled → sending → sent |
| A/B | no | yes (not for silent sends) |
| Typical use | order shipped, new message, password reset | promotions, announcements, re-engagement |
Both write a campaign row: the tracking id in every push embeds a campaignId, so a transactional send needs somewhere for its delivered/opened counters to land. It shows up in the dashboard list and has a full report.
Transactional — POST /v1/notifications
curl -X POST https://push.example.com/v1/notifications \
-H "Authorization: Bearer sk_live_…" \
-H "content-type: application/json" \
-H "Idempotency-Key: order-48213-shipped" \
-d '{
"target": { "externalIds": ["user_123"] },
"content": {
"en": { "title": "Your order is on its way", "body": "Order #48213 ships today.", "url": "acme://orders/48213" },
"tr": { "title": "Siparişin yola çıktı", "body": "#48213 bugün kargoda.", "url": "acme://orders/48213" },
"_default": "en"
},
"data": { "screen": "order", "id": "48213" },
"collapseId": "order-48213",
"ttl": 86400,
"bypass": { "quietHours": true }
}'
202 { "id": "…", "status": "sending" }. The API validates, writes the campaign, enqueues a fan-out job, and returns — delivery happens in the worker.
Targets — exactly one of
| Selector | Reaches | Limit |
|---|---|---|
externalIds: ["user_123", …] | every opted-in device of those users | 1 000 ids |
userIds: ["<ObjectId>", …] | same, by our ids | 1 000 |
subscriptionIds: ["<ObjectId>", …] | those exact devices | 1 000 |
segment: { … } | devices matching the segment DSL | — |
segmentId: "<ObjectId>" | a saved segment; its definition is copied at send time | — |
all: true | every opted-in device | — |
Unknown external ids are silently skipped (there is nothing to send to). The response is always 202 — inspect /v1/campaigns/:id/stats for what actually happened.
Delivery fields (both kinds)
| Field | Default | Meaning |
|---|---|---|
content | one of content / templateId / silent | Per-locale copy. See Content. |
templateId | — | Take copy and delivery defaults from a template. Fields given in the request override the template's. |
silent | false | Background push: nothing is shown, the app is woken with data. No content needed. See Silent pushes. |
data | — | Flat string → string map delivered with the push. |
ttl | 259200 (3 days) | Seconds the push service keeps trying while the device is offline. Max 4 weeks. |
collapseId | — | Newer push with the same id replaces an undelivered older one. |
priority | "high" | high → APNs priority 10, FCM high. normal → 5 / normal. Delivery urgency only; see presentation.interruptionLevel for how loudly iOS shows it. |
presentation | — | sound, badge, threadId, icon, interruptionLevel, relevanceScore, channelId. See Content. |
bypass | — | { frequencyCap?: true, quietHours?: true } — skip a delivery rule. |
Precedence when a template is used: request field → template field → default.
Campaigns — API
# 1. create (draft)
curl -X POST https://push.example.com/v1/campaigns \
-H "Authorization: Bearer sk_live_…" -H "content-type: application/json" \
-d '{
"name": "Weekend flash sale",
"content": { "en": { "title": "Weekend flash sale", "body": "30% off until Sunday." }, "_default": "en" },
"segment": { "and": [
{ "field": "tags.marketingOptIn", "op": "eq", "value": true },
{ "field": "lastActiveAt", "op": "gt", "value": "-30d" }
]},
"schedule": { "type": "user_timezone", "localTime": "19:30" }
}'
# 201 { "id": "66f1…", "status": "draft" }
# 2. send (or schedule)
curl -X POST https://push.example.com/v1/campaigns/66f1…/send \
-H "Authorization: Bearer sk_live_…"
# 202 { "id": "66f1…", "status": "scheduled" }
# 3. cancel while still scheduled
curl -X POST https://push.example.com/v1/campaigns/66f1…/cancel -H "Authorization: Bearer sk_live_…"
# 4. stats
curl https://push.example.com/v1/campaigns/66f1…/stats -H "Authorization: Bearer sk_live_…"
schedule is { "type": "now" } (default), { "type": "at", "at": "2026-10-01T09:00:00Z" }, { "type": "user_timezone", "localTime": "HH:MM" } or { "type": "recurring", … }. send on a now campaign queues immediately; at and user_timezone become scheduled; recurring becomes active and spawns a run per tick. See Scheduling.
A campaign without a segment reaches everyone. segmentId targets a saved segment (not together with segment); templateId starts from a template.
Campaigns — dashboard
The four-step wizard is documented in Dashboard › Campaigns. It produces exactly the same document the API does; the Generated DSL panel in step 1 shows the segment JSON you would put in a POST /v1/campaigns body.
What happens after 202
fan-out job ──▶ count audience ──▶ cursor over opted-in, non-invalidated subscriptions
──▶ for each device: pick locale, pick A/B variant, compute notBefore (user tz)
──▶ one job per device on push-ios / push-android / push-web
worker ──▶ delivery rules (cap, quiet hours) ──▶ personalise ──▶ transport
──▶ event (sent | failed | skipped) + campaign counters + webhook
stats.queued is set when fan-out completes; the other counters move as pushes go through. campaign.completed fires at the end of fan-out — every push is queued, not necessarily sent yet.
Rate limits
/v1/notifications is limited per API key (SEND_RATE_LIMIT, 100/min by default). One call to a 1 000-user externalIds target counts once. For high-volume transactional traffic batch ids per call, or raise the limit.