> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Segments

> One declarative audience, reused by every module that needs to choose people — and the conditions it refuses to approximate.

A segment is a saved answer to "who are these people". Flags aim at one,
checklists are shown to one, changelog posts are addressed to one, surveys ask
one. That reuse is the whole point: a module that grew its own filter would be a
second vocabulary for *audience*, and the two would disagree the first time a
field was added to one of them.

## The shape

A conjunction of groups; within a group, a disjunction of conditions.

```json theme={null}
{
  "name": "Verified, quiet for a month",
  "definition": {
    "groups": [
      { "conditions": [{ "source": "attribute", "field": "email_verified", "operator": "is_true" }] },
      { "conditions": [{ "source": "event", "operator": "not_occurred", "value": "checkout.paid", "days": 30 }] }
    ]
  }
}
```

`(A OR B) AND C`, and **two levels is the whole grammar**. Arbitrary nesting is a
precedence question, a parser and a form nobody can draw; any-of AND any-of is
what an audience builder actually offers, and this one says out loud what it
cannot express instead of half-supporting it.

At most **8 groups of 12 conditions**. That is a large audience and a query
Postgres plans without noticing — the bound exists so a definition cannot be
made arbitrarily expensive by pasting.

An **empty group is refused**, never read as "always true": a builder that lost
its last condition would otherwise silently become everybody.

## Four sources

| Source      | What it reads                                                                                                       | Fields                                                                                                                                                                                      |
| ----------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `attribute` | The contact row — the profile, the verification marks, the first-touch attribution                                  | `email`, `name`, `email_verified`, `identified`, `created_at`, `first_seen_at`, `last_seen_at`, `identified_at`, `email_verified_at`, `first_utm_*`, `first_referrer`, `first_landing_page` |
| `plan`      | The subscription mirror                                                                                             | `key`, `subscription_status`                                                                                                                                                                |
| `event`     | What happened in your product — your own [`track()`](/en/guides/analytics) calls and the platform's `$auth.*` facts | the event name goes in `value`                                                                                                                                                              |
| `metric`    | The activity meter                                                                                                  | `active_months`                                                                                                                                                                             |

Which operators apply is decided by the field, and a mismatch is **refused**:
`created_at contains "@"` is not a query with an empty answer, it is a question
nobody meant to ask. Text fields take `eq`/`neq`/`contains`/`not_contains`/
`starts_with`/`ends_with`/`in`/`not_in`/`is_set`/`is_not_set`; booleans take
`is_true`/`is_false`; timestamps take `before`/`after`/`within_days`/
`not_within_days`; events take `occurred`/`not_occurred`; metrics take
`at_least`/`at_most`.

An operand the condition does not read is refused rather than dropped. A `days`
on a condition where days mean nothing would otherwise be a window you set, the
engine ignored, and you believe you have.

An event condition's **window is mandatory** (1 to 730 days). There is no
unbounded `occurred`, because a segment that scans every event ever stored is a
segment that gets slower every month it is left alone. If you mean "ever", say
two years and mean it.

A timestamp that is **not set** matches none of the four time operators —
`not_within_days` included. An absent value answers no question about itself;
reach those contacts with `is_not_set`.

## Filtering on an event property

An event condition can look at **one key** of what `track()` sent:

```json theme={null}
{
  "source": "event", "operator": "occurred",
  "value": "checkout.completed", "days": 30,
  "property": "plan", "property_operator": "eq", "property_value": "pro"
}
```

Without it the remaining path is encoding the dimension in the name —
`checkout.completed.pro` beside `checkout.completed.free` — which turns the
namespace into a product and takes the grouping the explorer is built on with it.

Four rules, each there for a reason that shows up later:

* **Only on `occurred`.** "Did not happen with plan=pro" reads two ways — never
  happened, or happened with another plan — and the engine refuses rather than
  picking. For the first, use `not_occurred` on the event itself.
* **One key per condition.** Two would be an AND, and a group of conditions is
  already how this engine spells AND.
* **The comparison is textual.** A property is compared as the text it renders
  as: `4` is `"4"`, `true` is `"true"`. The column carries no schema, and any
  other reading would need a declared type per key. The operators are the text
  ones minus `in` / `not_in` — over a property those are an OR, and an OR is a
  second condition in the same group.
* **Absent and `null` are the same state as empty.** So `is_not_set` is how you
  ask for the events that carry nothing, and `neq` does not match them by
  accident.

`count` still applies alongside it: "checked out with `plan = pro` at least
twice" is one condition.

An event property is **not** a contact attribute. Writing `properties.plan` as an
`attribute` gets a refusal that points here.

## What it refuses, and why refusing beats approximating

Two conditions are perfectly reasonable to want, and this engine will not state
them. Both name themselves in the error, so you hit a sentence rather than an
empty audience.

**A custom contact attribute.** `attributes.plan_tier` has nothing behind it —
the contact columns are the profile and the first-touch attribution, and there
is no attribute store to match against. The only way to honour it would be to
match something *nearly* right. A segment built on an approximation is not a
smaller mistake than an error message: it is an audience that gets mailed, or a
feature that ships to the wrong tenth of your users, and nobody finds out from
the definition.

**An entitlement.** Whether a customer *holds* a feature is resolved by the
[entitlements engine](/en/guides/entitlements) — overrides that replace rather
than maximise, an expiry, a dunning grace, and a status vocabulary whose unknown
member honestly answers "cannot tell". Restating that in SQL would be a second
copy of the one function that is deliberately kept alone, and the copy would be
wrong in the cases that matter most. What a segment *can* target is the **plan**,
which is a row: `plan.key` and `plan.subscription_status` are mirrors of your
gateway, not resolutions of anything.

A third refusal is narrower and worth knowing: a `metric` condition in a **test**
environment. The activity meter never records test, so the condition could only
ever match nobody — and an audience that is empty for a structural reason looks
exactly like an audience that is empty because nothing has happened yet.

## Two evaluators, one meaning

The same definition is compiled twice: into SQL over an environment's contacts
("who is in this"), and into an in-process evaluation of one contact ("is this
person in it, right now"). A campaign resolving recipients wants the first; a
flag deciding what to send a browser at `boot`, or a survey asking whether
somebody *just entered*, wants the second.

Two implementations of one meaning is the shape that drifts, so every field
carries both halves in one place and a test drives real contacts through both
paths and refuses a disagreement. It is worth knowing this exists, because it is
the reason you can trust the preview: the count you saw is produced by the same
rules the flag will apply one page load later.

## Membership, entry and departure

Who is in a segment is **written down**, not recomputed on every read — because
what the modules above actually subscribe to is the *entry*. "Somebody entered
trial-ending-in-3-days" is a difference between two evaluations, and a
difference has nowhere to live unless the previous evaluation was recorded.

Two things keep it current:

* **A fact lands** and the contacts it is about are re-decided in seconds. This
  is the path that carries anything caused by somebody doing something.
* **A nightly sweep** walks every live definition and recomputes. It is the only
  thing that can notice a condition that became true because *time passed* —
  "created more than 30 days ago", "not seen in three weeks" turn true with
  nothing published anywhere, because nothing happened.

Entering publishes [`segment.entered`](/en/guides/webhooks) and leaving publishes
`segment.left`. Both are deliverable as webhooks, and the payload carries the ids
and the segment's name — never the definition.

**The first computation of a definition announces nothing.** Writing an audience
down for the first time is a backfill: nobody changed, the audience did, and one
webhook per existing contact for an act you performed in the panel three seconds
ago is not a fact about anybody. Editing the definition puts it back in that
state, for the same reason — re-aiming an audience is not a hundred people
entering it. `definition_version` is bumped on a definition change and never on a
rename, so anything holding a compiled audience can tell it is stale by comparing
an integer.

`left_at` is a timestamp rather than a deletion, because leaving is also
something you will want to act on: a win-back audience is people who *left*
"active in the last 30 days".

## Preview before you aim anything at it

```http theme={null}
POST /v1/organization/segments/{id}/preview
```

The count and at most 25 contacts, newest first. It writes nothing and it costs
one query. The habit worth forming is previewing before a flag, a survey or a
changelog post names the segment — every module downstream inherits whatever this
definition means, and the cheapest place to discover it means something else is
here.

## Permissions

Reading a segment is `customers:read` — the same seat that can list contacts,
because a preview *is* reading contacts. Creating, editing and archiving is
`segments:manage`, which owner and admin hold by default.

The split is deliberate. An audience is a piece of product configuration that
every other module inherits; being able to look at one is not the same act as
being able to move what a flag, a survey and a release note are all aimed at.
