# Segments


A segment is a saved set of rules that describes an audience — "people who last opened the app more
than 30 days ago", "gold-tier users in India", "devices within 5 km of the stadium". Segments are
evaluated **live at send time**, never frozen into a list, so a segment you built last month
targets today's audience.

All examples use `https://app.openpush.ai`, the OpenPush API base URL.

## When to use this page

Use segments whenever the audience is a rule rather than a person. For sending to one known user or
device, skip the segment and use the message's `target` object instead — see
[sending messages](sending-messages.md).

## Prerequisites

- The app's REST API key.
- Tags, languages and countries on your users. A segment can only filter on data that has actually
  been reported: a `tier` tag rule matches nothing until your app calls `addTag("tier", …)`.

## What is always true

Two things are hard-wired into every segment query and are not filterable:

- **Only sendable subscriptions are ever matched.** A subscription must have a positive status and
  must not have been retired. There is deliberately no "subscription status" filter field — an
  unsubscribed device cannot be targeted by any segment.
- **Rules are evaluated over subscriptions**, joined to their user. A segment's headline count is a
  count of *devices*, and a user with three devices contributes three.

## The filter vocabulary

Ten fields, seven operators, and no others. `GET /v1/apps/{app_id}/segments` returns this same
table as a machine-readable `fields` array, so you can build a rule editor from it.

| Field | Reads from | Operators | Value |
|---|---|---|---|
| `first_session` | user | `greater`, `less` | Hours ago (number) |
| `last_session` | user | `greater`, `less` | Hours ago (number) |
| `session_count` | user | `greater`, `less`, `is` | Number |
| `tag` | user | `is`, `is_not`, `exists`, `not_exists`, `greater`, `less` | Needs `key` and, except for existence checks, `value` |
| `country` | user | `is`, `is_not` | Country code, e.g. `IN` (case-insensitive) |
| `language` | user | `is`, `is_not` | Language code, e.g. `en` |
| `location` | subscription | `within` | Distance in metres, plus `lat` and `lng` |
| `device_type` | subscription | `is`, `is_not` | `Android` or `iOS` (case-insensitive) |
| `app_version` | subscription | `is`, `is_not` | e.g. `1.97` |
| `test_users` | test devices | `is` | `"true"` or `"false"` |

The **user vs subscription** column matters more than it looks. Country, language, tags and session
history live on the person, so a rule on them selects all of that person's devices together.
Platform, app version and location live on the individual device, so a rule on them can select the
phone and not the tablet.

### Operator semantics

- **`first_session` / `last_session` take hours *ago***, not a date. `greater` means further in the
  past ("last seen more than N hours ago"); `less` means more recent. Both require the user to have
  a session on record at all — a user who has never had one matches neither.
- **`is_not` treats a missing value as "is not".** A user with no `country` matches
  `country is_not FR`. This is usually what you want for exclusions and occasionally surprising.
- **`device_type`** compares case-insensitively against the subscription's platform.
- **`country`** compares upper-cased on both sides, so `in`, `In` and `IN` are the same rule.
- **`tag` with `greater` / `less`** casts the tag value to a number. A tag whose value is a word
  simply fails to match rather than erroring — tag values are strings on every SDK, so this is a
  routine situation, not an edge case.
- **`tag` keys** must be `[A-Za-z0-9_.-]` with no empty dot segment.
- **`location`** is a great-circle distance in metres from the given latitude and longitude.
  Devices with no stored coordinates never match. Location capture is gated by the app's
  data-collection switch — if it is off, no device has coordinates.
- **`test_users`** matches devices registered as test subscriptions. A value of `false`, `0` or `no`
  inverts it, which is how you exclude your own handsets from a campaign's statistics.

## Rule shape: AND within a group, OR between groups

`rules` is a flat array of row objects. Each row may carry a `group` label. **Rows in the same group
are AND'd; groups are OR'd together.** That is exactly two levels — there is no deeper nesting and
no way to express it.

```json
[
  {"field": "tag",     "op": "is", "key": "tier", "value": "gold",     "group": "0"},
  {"field": "country", "op": "is",               "value": "IN",       "group": "0"},
  {"field": "tag",     "op": "is", "key": "tier", "value": "platinum", "group": "1"}
]
```

That reads: *(tier is gold AND country is IN) OR (tier is platinum)*.

Rows with no `group` all collapse into one group, which gives you plain all-AND behaviour — the
common case, and what you get if you never think about grouping.

**Empty rules match everyone** who is sendable, and the segment's human-readable description reads
"Default — every subscribed device".

## Creating a segment

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/segments \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Lapsed 30 days",
        "rules": [{"field": "last_session", "op": "greater", "value": 720}]
      }'
```

```json
{
  "id": "seg_7b1c9e4a2f60",
  "app": "acme-app",
  "name": "Lapsed 30 days",
  "status": "Active",
  "is_default": 0,
  "is_target_default": 0,
  "rules": [{"field": "last_session", "op": "greater", "value": 720}],
  "rule_text": "Last Session greater than 720 hours ago",
  "subscriptions": 18422,
  "created_at": 1788044412.87,
  "updated_at": 1788044412.87
}
```

| Body field | Type | Required | Default |
|---|---|---|---|
| `name` | string | **yes** | — |
| `rules` | array | no | `[]` (everyone) |
| `status` | `Active` \| `Paused` | no | `Active` |
| `is_target_default` | boolean | no | `false` |

The response always carries three computed extras: `rules` parsed back out, `rule_text` — a plain
sentence describing the rules, useful for confirmation screens — and `subscriptions`, the live
count.

## Statuses

| Status | Meaning |
|---|---|
| `Active` | Normal. Available for targeting |
| `Paused` | Kept, with its rules intact, but parked |

Pausing preserves the configuration; it is a way to retire a segment without losing the rule you
spent an afternoon getting right. Only `Active` and `Paused` are accepted — anything else is a
`400`.

## Default segments

Every new app is created with two:

| Name | Rules |
|---|---|
| **Total Subscriptions** | None — every sendable device |
| **Active Subscriptions** | Last session less than 720 hours ago (the past 30 days) |

Both are **managed**: `DELETE` on them returns `409 managed segments cannot be deleted`. You can
still pause or rename them.

`Total Subscriptions` also starts as the **target default** — the segment pre-selected when
composing. Exactly one segment holds that flag at a time. Setting `is_target_default: true` on
another segment moves it; you cannot clear it, only hand it over, and trying returns
`400 set another segment as default instead of clearing the current default`. If you delete the
segment that holds it, it falls back to `Total Subscriptions`, or to the oldest segment.

## Recipes

### 1. Lapsed — no session in 30 days

The classic win-back audience. Hours, not days: 30 × 24 = 720.

```json
[{"field": "last_session", "op": "greater", "value": 720}]
```

Pair it with an exclusion for people you contacted last week, using a tag your backend sets.

### 2. High-value customers by tag

```json
[{"field": "tag", "op": "is", "key": "tier", "value": "gold"}]
```

Tag comparisons are exact string matches, so `Gold` and `gold` are different values. Normalise on
the way in.

### 3. Gold *or* platinum

Two groups, OR'd:

```json
[
  {"field": "tag", "op": "is", "key": "tier", "value": "gold",     "group": "0"},
  {"field": "tag", "op": "is", "key": "tier", "value": "platinum", "group": "1"}
]
```

There is no `in` or "one of" operator, so a set membership test is one group per value.

### 4. Country and language together

```json
[
  {"field": "country",  "op": "is", "value": "IN"},
  {"field": "language", "op": "is", "value": "hi"}
]
```

No `group` on either row, so both are AND'd. Use this to gate a translated campaign to the country
it makes sense in, rather than to everyone who has their phone set to Hindi.

### 5. Near a venue

Everyone within 5 km of a stadium:

```json
[{"field": "location", "op": "within", "value": 5000, "lat": 19.0760, "lng": 72.8777}]
```

`value` is metres and must be greater than zero; latitude must be between −90 and 90 and longitude
between −180 and 180. Only devices that reported coordinates can match, and only if the app's
location collection switch is on.

### 6. Engaged power users

```json
[
  {"field": "session_count", "op": "greater", "value": 50},
  {"field": "last_session",  "op": "less",    "value": 168}
]
```

Fifty-plus sessions *and* seen in the past week. `session_count` treats a missing value as zero, so
a brand-new user is never accidentally included.

### 7. Numeric tag threshold

```json
[{"field": "tag", "op": "greater", "key": "lifetime_spend", "value": 100}]
```

Even though tag values are strings, `greater` and `less` compare them numerically. A user whose
`lifetime_spend` is `"free trial"` fails the comparison rather than breaking the query.

### 8. New users who have not been onboarded

```json
[
  {"field": "first_session", "op": "less",       "value": 48},
  {"field": "tag",           "op": "not_exists", "key": "onboarded"}
]
```

First seen in the last two days, and the `onboarded` tag has never been set. `exists` and
`not_exists` need only a `key` — no `value`.

### 9. One platform, one build

```json
[
  {"field": "device_type",  "op": "is", "value": "Android"},
  {"field": "app_version",  "op": "is", "value": "1.97"}
]
```

Both fields are per-device, so this selects the specific handsets on that build, not everyone who
owns one. Useful for telling a stuck cohort to update.

### 10. Exclude your own test handsets

```json
[{"field": "test_users", "op": "is", "value": "false"}]
```

Worth adding to any segment you use for reporting, because test devices bypass the frequency cap
and quiet hours on every send and will otherwise skew small campaigns.

## Using segments in a send

```json
{
  "title": "…",
  "body": "…",
  "include_segments": ["seg_7b1c9e4a2f60", "seg_0a4c1d92be77"],
  "exclude_segments": ["seg_3f81ce02a4d5"]
}
```

- **Includes are OR'd.** A device in either segment is in the audience.
- **Excludes are subtracted** — the OR of the exclude segments is removed from the result.
- **An empty `include_segments` means every sendable device** in the app.
- An unknown segment id is `400 unknown segment '<id>'`.

`Paused` segments still resolve if you name them explicitly; pausing is an organisational state,
not an enforcement mechanism.

## Counts and estimates

**Counts are exact.** `count(*)` over the matching subscriptions, computed when you ask, with no
sampling, no estimator, no cached approximation and no "estimated" label anywhere in the product.
The trade-off is honesty over speed: a complex rule over a very large audience is a real query.

Two numbers exist. The segment row's `subscriptions` field counts **devices**. Elsewhere the
product can also report distinct **users** for the same rule — a household with a phone and a
tablet is two subscriptions and one user. When you are budgeting a campaign, subscriptions is the
number that predicts sends; users is the number that predicts how many people you annoy.

To size an audience the way a send will actually see it — including exclusions, quiet-hours holds
and frequency caps — use `POST /v1/apps/{app_id}/audience-preview` instead of adding up segment
counts. It runs the same audience resolution and the same planner the send runs.

## Managing segments

```bash
# List, with the machine-readable filter vocabulary
curl https://app.openpush.ai/v1/apps/acme-app/segments \
  -H "X-OP-API-Key: $OP_REST_KEY"

# Update rules, name, status, or the target default
curl -X PATCH https://app.openpush.ai/v1/apps/acme-app/segments/seg_7b1c9e4a2f60 \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rules": [{"field": "last_session", "op": "greater", "value": 1440}]}'

# Delete
curl -X DELETE https://app.openpush.ai/v1/apps/acme-app/segments/seg_7b1c9e4a2f60 \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

A patch revalidates the rules, and an empty patch is `400 nothing to update`.

## Validation errors

Every rule problem comes back as a `400` with a specific message:

| Message | Cause |
|---|---|
| `unknown field '<f>'` | Not one of the ten fields |
| `<what> needs a number, got <v>` | A numeric field or distance got a non-number |
| `<field> supports greater\|less` | Wrong operator for a session-time field |
| `session_count supports greater\|less\|is` | Wrong operator |
| `<field> supports is\|is_not` | Wrong operator for country, language, device type or app version |
| `location supports is within` | Location accepts only `within` |
| `location distance must be greater than 0 meters` | Zero or negative radius |
| `location latitude must be between -90 and 90` | Out-of-range latitude |
| `location longitude must be between -180 and 180` | Out-of-range longitude |
| `tag needs a key of [A-Za-z0-9_-.] with no empty .segment` | Bad or missing tag key |
| `tag supports is\|is_not\|exists\|not_exists\|greater\|less` | Wrong operator for a tag |
| `unknown segment '<id>'` | A send named a segment that does not exist |

## Limits

- **Two levels of logic only** — AND within a group, OR between groups. Nested parentheses are not
  representable.
- **No set-membership operator.** No `in`, no `one of`; use one OR group per value.
- **No "is in segment X" relation.** Segments cannot reference other segments; combine them with
  `include_segments` on the send instead.
- **No subscription-status field.** Sendability is enforced, not filtered.
- **No event-based rules.** Custom events do not reach segments at all — they trigger
  [journeys](journeys.md). Behavioural targeting is not expressible here.
- **No message-engagement rules** — you cannot segment on who opened or clicked a previous campaign.
- **No segment duplication route.** Read one and create a copy.
- **No count estimator and no "estimated" labelling** — counts are exact by design.
- **No scheduled or snapshot segments.** Every segment is live.

## FAQ

**Does a segment freeze its members when a message is scheduled?**
No. The audience is resolved when the send fires, so a scheduled campaign targets whoever matches
at that moment.

**Why does my tag rule match nobody?**
Usually one of three things: the tag was never actually set (check a user record), the value differs
in case, or the value is an empty string — which the SDKs treat as deletion, so the tag does not
exist at all.

**How do I target "everyone except X"?**
Put X in `exclude_segments` on the send and leave `include_segments` empty. Building it as a rule
also works, but exclusion at send time is clearer and reusable.

**Why did `country is_not FR` include people with no country?**
By design — a missing value counts as "is not". If you need "has a country, and it is not France",
add a positive condition alongside it.

**Can I filter on whether someone received a specific message?**
No. That data exists on the message report, not in the segment vocabulary.

**Do paused segments still count toward anything?**
No quota exists to count against. Pausing simply marks a segment as not in current use.

## Related

- [Sending messages](sending-messages.md) — includes, excludes and direct targeting
- [Users and subscriptions](users-and-subscriptions.md) — where tags and session data come from
- [Personalization](personalization.md) — using the same tags in copy
- [Events](events.md) — why events do not appear here
- [Segments API reference](../api-handbook/04-segments.md)
