# Segments


A **segment** is a named, saved audience filter. Messages target segments by id — you include
one or more, and optionally exclude others. A segment is evaluated live at send time and at read
time; there is no stored membership list to refresh.

This page documents the four segment endpoints and the complete filter language: every field,
every operator, the grouping model, the validation errors, and how counts behave.

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

**Auth:** every route on this page takes `X-OP-API-Key` — your app's REST key.

---

## The segment object

| Field | Type | Description |
|---|---|---|
| `id` | string | Segment id, e.g. `seg_01hq7m9b2f` |
| `app` | string | The app it belongs to |
| `name` | string | Display name. Not required to be unique |
| `status` | string | `Active` or `Paused` |
| `rules` | array | The filter rows. See [Filter language](#filter-language) |
| `rule_text` | string | A human description of `rules`, rendered by the server |
| `subscriptions` | integer | Live count of matching subscriptions |
| `is_default` | integer | `1` for managed built-in segments, which cannot be deleted |
| `is_target_default` | integer | `1` for the segment pre-selected when composing a message |
| `created_at` | number | Unix seconds |
| `updated_at` | number | Unix seconds |

Every new app is seeded with two managed segments: **Total Subscriptions** (no rules — everyone)
and **Active Subscriptions** (last session less than 720 hours ago). A third, **Testing
Devices**, is created the first time you mark a test device; its rule is the ordinary
`test_users is true` filter, so it counts and targets through the same machinery as any other
segment.

---

## GET /v1/apps/{app_id}/segments

Lists every segment on the app, with a live count on each, plus the machine-readable filter
vocabulary.

**Auth:** `X-OP-API-Key`

Segments come back with the targeting default first, then most-recently-updated first.
`GET /v1/apps/{app_id}/segments/{seg_id}` reads one editable segment without
scanning the list; an unknown or other-app ID returns `404`.

### Example request

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c2a/segments \
  -H "X-OP-API-Key: $OP_API_KEY"
```

### Example response

```json
{
  "segments": [
    {
      "id": "seg_01hq7m9b2f",
      "app": "app_3f9c2a",
      "name": "Total Subscriptions",
      "status": "Active",
      "rules": [],
      "rule_text": "Default — every subscribed device",
      "subscriptions": 208114,
      "is_default": 1,
      "is_target_default": 1,
      "created_at": 1735689600.0,
      "updated_at": 1735689600.0
    },
    {
      "id": "seg_01hq7mc4kd",
      "app": "app_3f9c2a",
      "name": "Lapsed gold players in India",
      "status": "Active",
      "rules": [
        {"field": "tag", "op": "is", "key": "tier", "value": "gold", "group": "0"},
        {"field": "last_session", "op": "greater", "value": 168, "group": "0"},
        {"field": "country", "op": "is", "value": "IN", "group": "0"}
      ],
      "rule_text": "User Tag tier is gold AND Last Session greater than 168 hours ago AND Country is IN",
      "subscriptions": 4127,
      "is_default": 0,
      "is_target_default": 0,
      "created_at": 1738368000.0,
      "updated_at": 1738454400.0
    }
  ],
  "fields": [
    {"field": "first_session", "label": "First Session",
     "ops": ["greater", "less"], "hint": "hours ago"},
    {"field": "last_session", "label": "Last Session",
     "ops": ["greater", "less"], "hint": "hours ago"},
    {"field": "session_count", "label": "Session Count",
     "ops": ["greater", "less", "is"], "hint": "sessions"},
    {"field": "tag", "label": "User Tag",
     "ops": ["is", "is_not", "exists", "not_exists", "greater", "less"],
     "hint": "key + value"},
    {"field": "country", "label": "Country",
     "ops": ["is", "is_not"], "hint": "e.g. IN"},
    {"field": "location", "label": "Location",
     "ops": ["within"], "hint": "distance in meters"},
    {"field": "language", "label": "Language",
     "ops": ["is", "is_not"], "hint": "e.g. en"},
    {"field": "test_users", "label": "Test Users",
     "ops": ["is"], "hint": "true"},
    {"field": "device_type", "label": "Device Type",
     "ops": ["is", "is_not"], "hint": "Android | iOS"},
    {"field": "app_version", "label": "App Version",
     "ops": ["is", "is_not"], "hint": "e.g. 1.97"}
  ]
}
```

The `fields` array is the authoritative, machine-readable filter vocabulary — build a segment
editor against it rather than hard-coding the list.

---

## POST /v1/apps/{app_id}/segments

Creates a segment. The rules are compiled and validated before anything is stored, so a segment
that saves is a segment that runs.

**Auth:** `X-OP-API-Key`

### Body

| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `name` | string | **yes** | — | Display name. Trimmed; must not be empty |
| `rules` | array | no | `[]` | Filter rows. An empty array matches every subscribed device |
| `status` | string | no | `Active` | `Active` or `Paused` |
| `is_target_default` | boolean | no | `false` | Make this the segment pre-selected when composing. Setting it clears the flag on the previous default |

### Example request

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2a/segments \
  -H "X-OP-API-Key: $OP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Lapsed gold players in India",
        "rules": [
          {"field": "tag", "op": "is", "key": "tier", "value": "gold", "group": "0"},
          {"field": "last_session", "op": "greater", "value": 168, "group": "0"},
          {"field": "country", "op": "is", "value": "IN", "group": "0"}
        ]
      }'
```

### Example response

The full segment row, including the rendered `rule_text` and a live count:

```json
{
  "id": "seg_01hq7mc4kd",
  "app": "app_3f9c2a",
  "name": "Lapsed gold players in India",
  "status": "Active",
  "rules": [
    {"field": "tag", "op": "is", "key": "tier", "value": "gold", "group": "0"},
    {"field": "last_session", "op": "greater", "value": 168, "group": "0"},
    {"field": "country", "op": "is", "value": "IN", "group": "0"}
  ],
  "rule_text": "User Tag tier is gold AND Last Session greater than 168 hours ago AND Country is IN",
  "subscriptions": 4127,
  "is_default": 0,
  "is_target_default": 0,
  "created_at": 1738368000.0,
  "updated_at": 1738368000.0
}
```

### Errors

| Status | Message | Cause |
|---|---|---|
| `400` | `name required` | Missing or blank `name` |
| `400` | `is_target_default must be a boolean` | A string or number was sent |
| `400` | a filter message | See [Validation errors](#validation-errors) |
| `401` | `bad X-OP-API-Key` | Bad or missing REST key |
| `404` | `unknown app '<id>'` | No such app |

---

## PATCH /v1/apps/{app_id}/segments/{seg_id}

Updates a segment. Only the keys you send are touched; `rules` is replaced wholesale, not merged,
and is revalidated.

**Auth:** `X-OP-API-Key`

### Body

| Parameter | Type | Description |
|---|---|---|
| `name` | string | New display name |
| `status` | string | `Active` or `Paused` |
| `rules` | array | Replacement filter rows, revalidated on save |
| `is_target_default` | boolean | Only `true` is accepted |

An app always has exactly one targeting default, so there is no way to clear the flag — you move
it by setting `is_target_default: true` on a different segment. Sending `false` is refused with a
message that says so.

### Example request

```bash
curl -X PATCH \
  https://app.openpush.ai/v1/apps/app_3f9c2a/segments/seg_01hq7mc4kd \
  -H "X-OP-API-Key: $OP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "Paused"}'
```

### Example response

```json
{"id": "seg_01hq7mc4kd", "updated": true}
```

Read the segment back with `GET /v1/apps/{app_id}/segments` if you need the recompiled
`rule_text` or a fresh count.

### Errors

| Status | Message | Cause |
|---|---|---|
| `400` | `nothing to update` | Empty patch body |
| `400` | `status must be Active\|Paused` | Any other status value |
| `400` | `is_target_default must be a boolean` | Wrong type |
| `400` | `set another segment as default instead of clearing the current default` | `is_target_default: false` |
| `400` | a filter message | See [Validation errors](#validation-errors) |
| `401` | `bad X-OP-API-Key` | Bad or missing REST key |
| `404` | `unknown segment` | No such segment on this app |

---

## DELETE /v1/apps/{app_id}/segments/{seg_id}

**Auth:** `X-OP-API-Key`

```bash
curl -X DELETE \
  https://app.openpush.ai/v1/apps/app_3f9c2a/segments/seg_01hq7mc4kd \
  -H "X-OP-API-Key: $OP_API_KEY"
```

```json
{"id": "seg_01hq7mc4kd", "deleted": true}
```

Managed segments — the seeded **Total Subscriptions** and **Active Subscriptions**, and the
**Testing Devices** segment — cannot be deleted. You can still edit their rules through the same
routes as any other segment.

If the deleted segment was the targeting default, the flag falls back to **Total Subscriptions**,
or to the oldest segment on the app if that name is gone.

Deleting a segment does not affect messages already sent. A message that referenced it and has
not yet been resolved will fail to resolve its audience.

### Errors

| Status | Message | Cause |
|---|---|---|
| `401` | `bad X-OP-API-Key` | Bad or missing REST key |
| `404` | `unknown segment` | No such segment on this app |
| `409` | `managed segments cannot be deleted` | The segment is a built-in |

---

## Filter language

`rules` is a **flat JSON array of filter rows**. There is no tree, and no nesting beyond the two
levels described under [AND and OR](#and-and-or).

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

### Row keys

| Key | Type | Required | Description |
|---|---|---|---|
| `field` | string | **yes** | One of the ten fields below |
| `op` | string | no (defaults to `is`) | One of the supported operators below |
| `value` | string \| number \| array | usually | Comparison value or bounded list. Not read by `exists` / `not_exists` |
| `key` | string | tag rows only | The tag name to look up |
| `lat`, `lng` | number | location rows only | Centre of the radius |
| `group` | string | no (defaults to `"0"`) | OR bucket label |

### Fields and their operators

| `field` | Label | Allowed `op` | `value` |
|---|---|---|---|
| `first_session` | First Session | `greater`, `less` | Hours ago (number) |
| `last_session` | Last Session | `greater`, `less` | Hours ago (number) |
| `session_count` | Session Count | `greater`, `less`, `is` | Number |
| `tag` | User Tag | `is`, `is_not`, `exists`, `not_exists`, `greater`, `less`, `in`, `not_in` | Depends on the operator; also needs `key` |
| `country` | Country | `is`, `is_not`, `in`, `not_in` | Country code, e.g. `IN` |
| `location` | Location | `within` | Radius in metres; also needs `lat` and `lng` |
| `language` | Language | `is`, `is_not`, `in`, `not_in` | Language code, e.g. `en` |
| `test_users` | Test Users | `is` | `"true"` or `"false"` |
| `device_type` | Device Type | `is`, `is_not` | `Android` or `iOS` |
| `app_version` | App Version | `is`, `is_not`, `in`, `not_in` | Version string, e.g. `1.97` |

`in` and `not_in` take a bounded value list. An absent value matches `not_in`
and does not match `in`. The same tag comparisons are used by the `filters`
array on messages and previews. There is no substring or regular
expression operator.

### What is not filterable

- **Subscription status.** It is not a field, because it is hard-wired into every segment query:
  a segment only ever matches subscriptions whose status is positive and whose address has not
  been retired. A segment cannot target unsubscribed or uninstalled devices.
- **Events.** Custom events never reach segments. Behavioural targeting is expressible only
  inside a [journey](07-journeys.md), which can trigger and branch on events.
- **Message engagement** — sends, opens, clicks. There is no field for any of them.

---

### Session-recency filters

`first_session` and `last_session` take **hours ago**, and the operator names read from the
perspective of age:

- `greater` means *further in the past* — "at least this long ago".
- `less` means *more recent than* — "within this window".

A row is required to have the column set at all, so users with no recorded session never match
either direction.

Everyone who has not opened the app in the last week:

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

Everyone who installed within the last 24 hours and has been back in the last hour:

```json
[{"field": "first_session", "op": "less", "value": 24, "group": "0"},
 {"field": "last_session", "op": "less", "value": 1, "group": "0"}]
```

---

### Numeric comparison — `session_count`

`greater`, `less` and `is` map to `>`, `<` and `=`. A user with no recorded session count is
treated as `0`.

Players past their tenth session:

```json
[{"field": "session_count", "op": "greater", "value": 10}]
```

Exactly one session — installed, opened once, never returned:

```json
[{"field": "session_count", "op": "is", "value": 1}]
```

---

### String equality — `country`, `language`, `device_type`, `app_version`

`is` is an exact match. `country` is compared uppercased on both sides and `device_type`
lowercased on both sides, so `in`, `In` and `IN` are the same filter, as are `ios` and `iOS`.
`language` and `app_version` are compared as sent.

`is_not` treats a **missing value as "is not"**. A device that never reported a country matches
`country is_not IN`. That is usually what you want for an exclusion, and it is worth knowing
before you use `is_not` as a way to count.

iOS devices in India:

```json
[{"field": "device_type", "op": "is", "value": "iOS", "group": "0"},
 {"field": "country", "op": "is", "value": "IN", "group": "0"}]
```

Everyone not yet on the current build:

```json
[{"field": "app_version", "op": "is_not", "value": "1.97"}]
```

---

### Tag filters

Tags live on the user, are merged per key on every registration, and are deleted by sending an
empty value. A tag row needs a `key` as well as an `op`.

Tag keys must be made of `A–Z`, `a–z`, `0–9`, `_`, `-` and `.`, with no empty segment between
dots. `a..b` and a bare `.` are refused rather than silently building an unusable lookup path.

**`exists` / `not_exists`** — presence only. `value` is ignored:

```json
[{"field": "tag", "op": "exists", "key": "vip"}]
```

```json
[{"field": "tag", "op": "not_exists", "key": "onboarded"}]
```

**`is` / `is_not`** — string comparison of the stored value. As with the other string fields,
`is_not` also matches users who do not hold the tag at all:

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

**`greater` / `less`** — numeric comparison. The tag value is cast to a number, and a tag whose
value is a word simply fails to match rather than erroring:

```json
[{"field": "tag", "op": "greater", "key": "coins", "value": 5000}]
```

Because tags are stored as you send them, a numeric tag is only useful with `greater`/`less` if
you write it consistently — `"5000"` and `"5,000"` are not the same value.

---

### Location radius — `within`

`within` takes the radius in **metres** as `value`, plus `lat` and `lng` for the centre. It
matches subscriptions that have both coordinates stored.

Within 25 km of central Bengaluru:

```json
[{"field": "location", "op": "within", "value": 25000,
  "lat": 12.9716, "lng": 77.5946}]
```

Location filters only work when the app's location collection switch is on and devices have
actually reported coordinates — the switch is off by default, and a device that sent only one of
the two coordinates has no stored location at all. See
[Apps, keys and settings](01-apps-keys-settings.md).

Radius must be greater than zero; latitude must be between -90 and 90; longitude between -180
and 180.

---

### Test devices — `test_users`

`test_users` matches the devices marked as test subscriptions. `is true` selects them; a value of
`false`, `0` or `no` inverts the filter and excludes them.

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

Excluding test devices from a production campaign is more often done as an *exclude segment* on
the message than as a rule inside the audience segment — see
[Messages](02-messages.md).

---

## AND and OR

Grouping has exactly two levels:

- Rows that share a `group` value are **ANDed**.
- Groups are **ORed** with each other.

Deeper nesting is not representable. Rows with no `group` key all collapse into one group, which
is the all-AND behaviour.

Gold players in India **OR** anyone at all in Brazil:

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

`rule_text` renders that as:

```
User Tag tier is gold AND Country is IN OR Country is BR
```

Group labels are arbitrary strings — `"0"`, `"1"`, `"a"`, `"weekenders"` — and their only job is
to say which rows belong together. Group order in the response follows the order the labels first
appear in your array.

**Empty rules match everyone.** A segment with `rules: []` compiles to every subscribed device
and describes itself as `Default — every subscribed device`.

### How segments combine on a message

When a message names segments, includes are ORed together and excludes are subtracted:

```
(subscription is sendable)
AND (rules of include segment A OR rules of include segment B)
AND NOT (rules of exclude segment C OR rules of exclude segment D)
```

An empty include list means every sendable device on the app. Naming a segment id that does not
exist fails the send with `unknown segment '<id>'`.

---

## Validation errors

Every filter problem is a `400` with a plain-text message in the standard error body:

```json
{"detail": "tag supports is|is_not|exists|not_exists|greater|less"}
```

| Message | Cause |
|---|---|
| `unknown field '<f>'` | `field` is not one of the ten |
| `<what> needs a number, got <v>` | A numeric position received something unparseable |
| `<field> supports greater\|less` | Wrong operator on `first_session` or `last_session` |
| `session_count supports greater\|less\|is` | Wrong operator on `session_count` |
| `<field> supports is\|is_not` | Wrong operator on `country`, `language`, `device_type` or `app_version` |
| `location supports is within` | Any operator other than `within` on `location` |
| `location distance must be greater than 0 meters` | Radius of zero or less |
| `location latitude must be between -90 and 90` | Out-of-range `lat` |
| `location longitude must be between -180 and 180` | Out-of-range `lng` |
| `tag needs a key of [A-Za-z0-9_-.] with no empty .segment` | Missing or malformed tag `key` |
| `tag supports is\|is_not\|exists\|not_exists\|greater\|less` | Wrong operator on `tag` |
| `unhandled field '<f>'` | A known field reached with no compiler branch |
| `unknown segment '<id>'` | A message or preview referenced a segment id that does not exist |

Validation runs on create, on patch, and again whenever a message resolves its audience — a
segment cannot be saved in a state that would fail at send time.

---

## Counts

The `subscriptions` number on a segment is an exact `COUNT` of matching subscriptions, computed
when you read it. There is no estimator, no sampling, no timeout, and no "estimated" label
anywhere in the API.

Two things follow from that:

- Counts move between two reads. A segment is a live query, not a materialised list.
- Counting a very broad segment on a very large app is real work. Do not poll the list route in a
  tight loop for a dashboard.

The count is over **subscriptions**, not people. A user with a phone and a tablet counts twice.
To see the audience a specific message would reach — including the platform and language
breakdown, and how many devices the frequency cap and quiet hours would hold back — use the
audience preview endpoint documented in [Messages](02-messages.md).

---

## Limits and notes

- **No rate limit** applies to any segment route.
- There is no route to duplicate a segment; create a new one with the same `rules`.
- There is no paging on the segment list. It returns every segment on the app, each with a live
  count.
- `rules` is replaced wholesale by a patch. Read, modify, and send the full array back.
- Segment names are not unique, and nothing resolves a segment by name — messages target ids.
- A segment cannot reach unsubscribed, uninstalled or retired devices under any rule.

## Related

- [API overview](00-overview.md) — auth model and error convention
- [Messages](02-messages.md) — targeting with include and exclude segments, audience preview
- [Subscriptions and users](03-subscriptions-users.md) — where tags, country, language and
  location come from
- [Segments](../guides/segments.md) — filter recipes in prose
- [Journeys](07-journeys.md) — event-driven targeting, which segments cannot express
