Segments
A segment is a JSON tree that selects subscriptions. The dashboard's builder produces it, POST /v1/campaigns, POST /v1/notifications (target.segment) and POST /v1/segments/preview accept it, and the worker's fan-out compiles it to a MongoDB filter. A segment can also be saved under a name and reused (see below); either way the campaign carries its own copy of the definition.
{
"and": [
{ "field": "tags.plan", "op": "in", "value": ["premium", "platinum"] },
{ "field": "lastActiveAt", "op": "gt", "value": "-7d" },
{ "not": { "field": "country", "op": "eq", "value": "CN" } },
{ "or": [
{ "field": "platform", "op": "eq", "value": "ios" },
{ "field": "tags.streak", "op": "gte", "value": 10 }
]}
]
}
Nodes
| Node | Meaning |
|---|---|
{ "and": [ … ] } | all children match (1–50 children) |
{ "or": [ … ] } | any child matches (1–50) |
{ "not": … } | the child does not match |
{ "field", "op", "value" } | a condition on a subscription field |
{ "event", "campaignId", "since"? } | a behavioural condition — see Behavioural conditions |
Nesting is unlimited in depth.
Fields
Any dotted path on the subscription document that cannot start with $. The useful ones:
| Field | Type | Example values |
|---|---|---|
platform | string | ios, android, web |
country | string | TR, DE |
language | string | tr, en-GB |
timezone | string | Europe/Istanbul |
tags.<key> | string / number / boolean | whatever you set |
externalId | string | |
lastActiveAt, lastNotifiedAt, createdAt | date | ISO date or relative |
sessionCount | number | sessions seen (30-minute idle rule, counted server-side) |
lastSessionAt | date | start of the last session |
lastOpenedAt | date | last opened/clicked event from this device |
appVersion, osVersion, deviceModel, browser | string | 3.4.1, 17.5, iPhone 15 Pro, chrome |
standalone | boolean | web: installed as a PWA |
optedIn | boolean | usually left to the base filter |
Tags are indexed with a wildcard index (tags.$**), so filtering on any tag key is fast.
Operators
op | value | Compiles to |
|---|---|---|
eq / ne | string, number, boolean, null | field: v / { $ne: v } |
gt / gte / lt / lte | number, or a date string | { $gt: v } … |
in / nin | array of scalars (1–1000) | { $in: [...] } |
exists | boolean | { $exists: b } |
regex | string ≤ 256 | { $regex: v, $options: "i" } — case-insensitive |
Values are scalars only. An object value is rejected at validation — that is what stops a caller smuggling $where or $ne operators through the API.
Relative dates
For the comparison operators, a string matching ^-(\d+)([smhd])$ is turned into a Date at compile time:
| Shorthand | Means |
|---|---|
-30s | 30 seconds ago |
-15m | 15 minutes ago |
-12h | 12 hours ago |
-7d | 7 days ago |
{ "field": "lastActiveAt", "op": "gt", "value": "-7d" } = active in the last week; "op": "lt" = inactive for over a week. Absolute ISO strings (2026-08-01) work too.
Behavioural conditions
"Opened the Friday sale", "never clicked a promotion": a condition on what a device did with an earlier campaign.
{ "and": [
{ "field": "country", "op": "eq", "value": "TR" },
{ "not": { "event": "opened", "campaignId": "64b7f3a1c2d4e5f601234568", "since": "-30d" } }
]}
| Field | Values |
|---|---|
event | sent, delivered, opened, clicked |
campaignId | the campaign whose events count (a sent one; transactional sends have ids too) |
since | optional; only events after this instant — relative (-30d) or ISO |
Wrap in not for "did not". Events live in their own collection, so the API and the worker resolve each condition to a set of subscription ids first (one distinct per condition) and only then compile the tree; the wizard's Interaction button builds these. The set is the size of the earlier campaign's audience — fine up to a few hundred thousand devices.
For "has not opened anything lately" use the lastOpenedAt field instead; it needs no lookup.
Saved segments
A saved segment is a named definition (segments collection): create it in Segments in the dashboard, with Save as segment in the wizard, or through POST /v1/segments. It shows its last known size and can be recounted.
Campaigns reference it with segmentId (API) or by picking it in the wizard. The definition is copied into the campaign at send time: editing the saved segment later changes future campaigns, never one already scheduled or active. In the wizard, editing a picked segment detaches it — the campaign keeps its own copy and the saved one is untouched.
The base filter
Whatever you write is and-ed with the tenant filter — appId, optedIn: true, invalidatedAt: null. You cannot reach another app's rows, and you cannot target opted-out or dead devices, even with optedIn: false in the segment.
Preview
curl -X POST https://push.example.com/v1/segments/preview \
-H "Authorization: Bearer sk_live_…" -H "content-type: application/json" \
-d '{ "segment": { "field": "tags.plan", "op": "eq", "value": "premium" } }'
# { "count": 512, "byPlatform": { "ios": 260, "android": 200, "web": 52 } }
The wizard's Matching subscribers panel calls the same endpoint as you edit. An invalid segment answers 422 invalid_segment with the path of the bad node.
Recipes
| Goal | Segment |
|---|---|
| Everyone | omit segment |
| iOS only | { "field": "platform", "op": "eq", "value": "ios" } |
| Lapsed users | { "field": "lastActiveAt", "op": "lt", "value": "-14d" } |
| Never notified | { "field": "lastNotifiedAt", "op": "eq", "value": null } |
| Premium in Turkey, not on web | { "and": [ {"field":"tags.plan","op":"eq","value":"premium"}, {"field":"country","op":"eq","value":"TR"}, {"not": {"field":"platform","op":"eq","value":"web"}} ] } |
| Has a tag at all | { "field": "tags.firstName", "op": "exists", "value": true } |
| App version older than 3.4 | { "field": "appVersion", "op": "regex", "value": "^3\\.[0-3]\\." } (versions are strings; lt compares lexically) |
| Installed PWAs on iOS Safari | { "and": [ {"field":"platform","op":"eq","value":"web"}, {"field":"browser","op":"eq","value":"safari"}, {"field":"standalone","op":"eq","value":true} ] } |