Building a segment, and what each condition means
The condition language, every operator, and the main trap: a misspelled event name is not an error, it is an empty audience.
A segment is a JSON tree of conditions. The panel never sends SQL; it sends this tree and the server compiles it. For you that means anything the panel can do is reachable over the API, and several things are reachable only over the API: event-property conditions, aggregates and membership of another segment have no button in the panel at all.
This page is the whole condition language: the exact JSON shape, every condition kind, every operator, time windows, and what simply does not exist. If you are an AI agent, read the trap before you write your first filter.
The shape of a definition
The outermost object is a Definition and it has two fields.
{
"version": 1,
"root": { "kind": "group", "op": "and", "children": [] }
}
| Field | Type | Required | Notes |
|---|---|---|---|
version | integer | no | The compiler never reads it. The panel always writes 1. Omit it and 0 is stored, and nothing changes. |
root | one Node | yes | Omit it and the empty Node has an empty kind, which the compiler rejects with segment: unknown node kind: "". |
In every HTTP call this object sits one level deeper, inside a field called definition:
{"definition": {"version": 1, "root": {"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "Tehran"}}}}
root does not have to be a group. A single condition is a valid root.
Every node is one struct with a kind field that decides which of the other fields are read. Field order on the wire is load-bearing: the audience fingerprint, which the campaign approval flow uses to tell "the same audience" from "an audience edited after somebody approved it", is a sha256 of the marshalled JSON.
| JSON field | Type | Which kind reads it |
|---|---|---|
kind | string | all. One of group, trait, event, segment, engagement, churn |
op | string | group only |
not | boolean | group, engagement, churn only |
children | array of Node | group only |
trait | string | trait only |
compare_trait | string | trait only. A second trait in place of value, see comparing two traits |
event | string | event only |
negate | boolean | event only |
count | object | event only |
aggregate | object | event only |
properties | array of object | event only |
window | object | event only |
segment_id | integer | segment only |
in_segment | boolean | segment only |
band | string | engagement and churn |
metric | string | engagement only |
operator | string | trait, engagement, churn |
value | object | trait, engagement, churn |
Any field not listed in the third column is silently ignored on that node kind. This is the largest single source of confusion: not: true on a trait, event or segment node does nothing at all and raises no error. To negate an event use negate; to negate membership use in_segment: false.
The whole tree becomes one predicate over the profiles table:
SELECT user_id FROM segmentic.profiles FINAL
WHERE tenant_id = {tenant:UInt32} AND (<compiled predicate>)
FINAL is deliberate and it costs read performance. Without it a profile updated twice is counted twice, and a wrong audience size destroys trust instantly.
Groups: and, or and not
{"kind": "group", "op": "or", "not": false, "children": [ ]}
ophas two meaningful values,andandor. Anything that is not exactlyormeans AND."OR"in capitals means AND. Nothing validates this field and you get no error.childrenmust not be empty. An empty array issegment: group has no children.not: truewraps the whole group inNOT (...).- Nesting depth: the root is depth zero and the ceiling is depth 8, so nine levels in total. Deeper is
segment: nesting too deep. - Size: 200 nodes. The budget counts each node as one, and each entry of its
propertiesarray as one more, because an event-property predicate is a condition too. Over that issegment: too many conditions.
The panel's condition builder edits the whole tree. Every condition carries its own operator joining it to the one above, and choosing the operator its list does not already use puts those two conditions in a group of their own. The panel builds three levels of groups where the compiler accepts nine: a readability limit rather than a safety one, and a definition built deeper through the API still opens and still edits here, it just cannot be made deeper. A negated group and a segment-membership condition have no controls in the panel; both are shown and left untouched.
Conditions on a profile trait
{"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "Tehran"}}
The trait name is trimmed. Empty, or longer than 128 bytes, is segment: invalid identifier.
The name is resolved against four groups in this exact order, and the first hit wins: the six reachability flags, the seven computed numeric traits, the sixteen string columns, then birthday. A name in none of them is a custom trait.
Traits that are real columns
Six reachability flags. has_push, has_email, has_phone, push_opt_in, email_opt_in, sms_opt_in.
These become col = 1 or col = 0. The logic is short and surprising: the default is true, sending value.bool sets it, and an operator of neq inverts it. Every other operator, from gt to contains to is_set, behaves exactly like equality. So has_push with contains compiles cleanly and does what eq would have done.
Seven computed numeric traits.
| Trait name | What it counts |
|---|---|
total_events | every event on this profile |
total_revenue | the sum of purchase amounts |
order_count | orders |
days_since_last_seen | dateDiff('day', last_seen, now()) |
days_since_last_order | dateDiff('day', last_order_at, now()) |
days_until_birthday | days to the next birthday: today is 0, in three days is 3 |
days_until_signup_anniversary | the same arithmetic over first_seen |
days_until_birthday is an anniversary, not a date. A stored birthday is a date in the past, and comparing it to a window matches nobody after the first year. 29 February rolls onto 1 March in a common year. It is NULL for anybody with no birthday, so every comparison against it is false and they are simply never in the audience.
days_until_signup_anniversary truncates first_seen, which is a DateTime, before the month-day comparison. Without that, somebody who signed up at 23:30 would be a day out from somebody who signed up at 00:30 the next morning.
All seven are numeric, so is_set on them means expr != 0 and is_not_set means expr = 0. A numeric trait genuinely equal to zero reads as "not set".
Sixteen string columns. user_id, email, phone, first_name, last_name, gender, city, region, country, language, timezone, device_type, os_name, app_version, push_provider, national_id.
national_id was missing from this list for a while while being stored the whole time, so a segment on it matched nobody, for every customer, with no error anywhere. That incident is why the list exists.
Your own traits
Any name that is in none of those four groups is a custom trait and is looked up in the ClickHouse maps. An unrecognised name is not an error, because customers define their own traits constantly and rejecting unknown names would make the feature useless. Which branch is taken depends on the shape of the comparison.
| Shape of the comparison | SQL |
|---|---|
is_set or is_not_set | has(mapKeys(traits), {p0:String}), and its negation |
a bool value with eq or neq | lower(traits[{p0:String}]) = {p1:String} where {p1} holds the string true or false |
a numeric operator, or any operator carrying a number except in and not_in | (mapContains(traits_num, {p0:String}) AND traits_num[{p0:String}] < {p1:Float64}) |
| anything else | traits[{p0:String}] with the string rules in the operators section |
Three points, each of which was a real defect on real customer data.
Presence is a key lookup, not a value comparison. An unset trait and a trait set to the empty string are different things, and is_set on a custom trait is the only branch that tells them apart.
A bool is compared as text. A trait sent as JSON true is stored as the string "true" in traits and as 1 in traits_num. The text map is chosen because it is the half every profile already carries. The direction of the negation is deliberate too: neq true means "known to be false", not "not known to be true", so a profile that never sent the trait is in neither audience.
Numbers are guarded by mapContains. A ClickHouse map yields the zero value for a key it does not hold, so without the guard "balance under 10" matched every profile with no balance at all: that is how the bug was first reported, 114,943 users on a tenant of about 115,000, which reads like a real answer. The failure direction was chosen on purpose. A numeric condition on a trait nobody carries now selects nobody rather than everybody. An audience that is silently empty is a campaign that does not go out and gets noticed; an audience that is silently everybody is a campaign that has gone out and cannot be taken back.
birthday answers only two questions
birthday is a Nullable(Date) column and accepts only is_set and is_not_set:
{"kind": "trait", "trait": "birthday", "operator": "is_set"}
Any other operator is refused at compile time with this text:
segment: unsupported operator: birthday only answers is_set and is_not_set; for an anniversary use days_until_birthday
The reason is that a non-numeric is_set compares the column against an empty string literal, which ClickHouse refuses against a Date with Code: 38. Cannot parse date. For an anniversary question use days_until_birthday, which is a number.
Comparing two traits
compare_trait puts a second trait where the literal would go, so a condition can ask about two numbers the same profile carries:
{"kind": "trait", "trait": "gc_referrals_total", "operator": "gt", "compare_trait": "gc_referrals_active"}
That reads "has invited somebody who has not activated yet", and no literal says it: the line falls at two for somebody who invited two friends and at forty for somebody who invited forty. On the account this arrived with, that audience is 888 people. The closest a fixed threshold gets is 40.
value is not read when compare_trait is set.
Six operators. eq, neq, gt, gte, lt, lte. Anything else is refused at compile time, because between wants two bounds and in wants a list and one other trait is neither, while is_set asks about one side only:
segment: unsupported operator: comparing two traits takes eq, neq, gt, gte, lt or lte, got "between"
Both halves must be numbers. A reachability flag is a UInt8 and counts as one. A string column on either side is refused by name, and birthday keeps its own refusal:
segment: unsupported operator: city holds text, and comparing two traits compares numbers
The reason is that with a literal on neither side there is nothing to read the shape of the comparison from. The single-trait path picks traits_num or traits from the operator and the value it was handed, and here there is no value. Numbers stay unambiguous because a numeric trait is written to both maps at ingest, so mapContains(traits_num, key) is a reliable "this trait is a number". Nothing tests the other direction, so a text pair would have to guess a map, and a guess that reads the empty one selects nobody while looking like an answer.
A profile missing either half is not in the audience. Each custom trait carries its own presence guard, and days_until_birthday and the days_since_ form are already NULL when there is nothing to count from. That is the failure direction the numeric trait path chose: an audience that is silently empty is a campaign that does not go out and gets noticed, and one that is silently everybody has already gone out.
| The two sides | SQL |
|---|---|
| two custom traits | (mapContains(traits_num, {p0:String}) AND mapContains(traits_num, {p1:String}) AND traits_num[{p0:String}] > traits_num[{p1:String}]) |
| two promoted columns | (order_count > total_events) |
| one of each | (mapContains(traits_num, {p0:String}) AND order_count < traits_num[{p0:String}]) |
The panel cannot build this one. The condition row has a single value control and this needs a second trait picker, so the builder shows such a condition read-only rather than drawing a value box the compiler never reads.
Conditions on an event
{
"kind": "event",
"event": "order_completed",
"negate": false,
"window": {"kind": "last", "amount": 30, "unit": "day"},
"properties": [],
"count": {"operator": "gte", "value": 3}
}
The event name is trimmed. Empty, or longer than 128 bytes, is segment: invalid identifier.
Every event condition becomes a subquery over segmentic.events carrying three base conditions always:
tenant_id = {tenant:UInt32}
AND name = {p0:String}
AND is_bot = 0
The bot filter is unconditional and there is no way to turn it off. Nobody wants to send a push to a crawler.
Then the time window, then each properties entry, then a HAVING clause that comes from either aggregate or count. GROUP BY user_id is added only when a HAVING clause exists.
The outer wrapping:
negateabsent orfalse:user_id IN (subquery)negate: true:user_id NOT IN (subquery)
NOT IN is deliberate: "did not do" must include users with no events at all, and since the outer query drives off the profiles table, NOT IN gives that for free.
Read the combination of negate: true and count carefully. Both are applied, so the result is user_id NOT IN (... HAVING count() >= 3), which means "did not do it at least three times" and therefore keeps somebody who did it twice in the audience. The compiler does not warn. The description sentence does not help either: for a negated event the count is left out of the sentence entirely and you read only "did not do".
Event-property conditions
{"property": "category", "operator": "eq", "value": {"type": "string", "str": "mobile"}}
Three fields and no more: property, operator, value. The property name is required and capped at 128 bytes.
Resolution order:
revenuefilters the promotedrevenuecolumn directly, as a number.- A numeric operator, or any operator carrying a number except
inandnot_in, goes toprops_num[{key:String}]. - A bool value with
eqorneqcomparesprops_str[key]against the stringtrueorfalse. is_setandis_not_setbecomehas(mapKeys(props_str), {key:String}).- Everything else uses
props_str[{key:String}]with the string rules.
Two things to know. First, properties entries are always joined with AND; there is no way to OR two property conditions on one event. Second, unlike the trait path there is no mapContains guard here. props_num returns 0 for a key the event did not carry, so "revenue_share" lt 10 also matches events that never had the property.
How many times
{"count": {"operator": "gte", "value": 3}}
| Field | Type | Notes |
|---|---|---|
operator | string | one of eq, neq, gt, gte, lt, lte, between |
value | number | the bound, or the lower bound |
value2 | number | the upper bound, for between only |
This becomes HAVING count() <op> {value:Float64}, and for between, HAVING count() BETWEEN {a:Float64} AND {b:Float64}. The value binds as Float64, so a fractional count is accepted without complaint and does not do what you expect.
count() counts event rows. There is no distinct count.
The panel offers only gte, gt, lte, lt and eq. between is API-only.
Aggregating a numeric property
{"aggregate": {"function": "sum", "property": "revenue", "operator": "gte", "value": 2000000}}
| Field | Type | Notes |
|---|---|---|
function | string | sum, avg, min, max only. Case does not matter. |
property | string | revenue, or any numeric event property key |
operator | string | the six numeric operators, plus between |
value | number | the bound |
value2 | number | for between only |
Anything outside those four functions is segment: invalid identifier. There is no count function here; use count from the previous section. There is no first and no last either, so there is no way to filter on the property value of the most recent occurrence of an event.
There is no mapContains guard here either, so a sum over a property most events lack silently sums zeros.
aggregate silently wins over count. Send both and the count is ignored.
The window an aggregate runs over is the event node's own window. There is no separate aggregation period field.
Membership of another segment
{"kind": "segment", "segment_id": 1234, "in_segment": true}
becomes
user_id IN (SELECT user_id FROM segmentic.segment_members
WHERE tenant_id = {tenant:UInt32} AND segment_id = {p0:UInt64})
segment_id must be present and non-zero, otherwise segment: invalid identifier: segment_id must be set.
Two traps live here and both are silent.
in_segment defaults to false, and false compiles to NOT IN. Leaving the field out means "is not a member", not "is a member".
This condition reads only the static-list table. segment_members is where the membership of static segments is written. A dynamic segment has no rows there, so pointing at one resolves to an empty set. That is not an error; it is a zero.
Engagement
Engagement scores are computed nightly over the whole message history and live in their own table, because that is a scan no profile write could carry. That is why this is its own kind rather than a numeric trait.
It has two forms, checked in this order.
Band. Set band. Allowed values: engaged, passive, dormant, lost, new.
{"kind": "engagement", "band": "dormant"}
Metric. Set metric. Allowed values: score, ignored_streak, open_rate, click_rate, days_since_engaged.
{"kind": "engagement", "metric": "ignored_streak", "operator": "gte", "value": {"type": "number", "num": 10}}
The operator must be numeric: gt, gte, lt, lte, between only. Note that eq and neq are not numeric by that test and are refused, with segment: unsupported operator: engagement needs a numeric operator, got "eq". A rate or an ignored streak has no meaningful "contains".
Neither band nor metric gives segment: invalid identifier: engagement needs a band or a metric.
Band names and metric names are both validated, unlike an event name. The reason is written into the code: a typo would silently match nobody, and a segment that matches nobody looks exactly like a segment whose audience has gone quiet, which is the thing this feature exists to detect.
not: true works on this node and compiles to NOT IN, which is correct: somebody the nightly job has never scored is certainly not demonstrably engaged.
The subquery runs with FINAL, because the table is a ReplacingMergeTree that the nightly job rewrites, and without it a person scored on two consecutive nights matches on the older row as well.
Churn risk
{"kind": "churn", "band": "high"}
Band. band is one of high, medium, low, unknown.
Threshold. Set operator and make it numeric. The comparison runs against the probability column, which holds a whole percentage, not a fraction. So "churn risk over 70" is written as 70, not 0.7.
{"kind": "churn", "operator": "gt", "value": {"type": "number", "num": 70}}
Neither form gives segment: invalid identifier: churn needs a band or a threshold. A non-numeric operator gives segment: unsupported operator: churn risk needs a numeric operator, got "eq".
Note the asymmetry with engagement: churn selects the threshold form when operator is non-empty, whereas engagement selects the metric form when metric is non-empty. A churn node carrying both band and operator uses the band.
A person with no prediction is deliberately in no band and above no threshold, so they fall out of both forms. Somebody the model has never seen is not low risk; they are unknown, and a win-back campaign that treats the two the same wastes its budget on people nobody has looked at.
not: true works and gives NOT IN. The subquery uses FINAL.
Operators
These fifteen strings are every operator there is. Spell them exactly as written.
| Operator | Value type | Meaning | SQL emitted |
|---|---|---|---|
eq | string, number, bool | equals | lower(expr) = {p:String} or expr = {p:Float64} |
neq | string, number, bool | does not equal | lower(expr) != {p:String} or expr != {p:Float64} |
contains | string | substring, case insensitive | positionCaseInsensitiveUTF8(expr, {p:String}) > 0 |
not_contains | string | not a substring | positionCaseInsensitiveUTF8(expr, {p:String}) = 0 |
starts_with | string | prefix | startsWith(lower(expr), {p:String}) |
ends_with | string | suffix | endsWith(lower(expr), {p:String}) |
gt | number | greater than | expr > {p:Float64} |
gte | number | at least | expr >= {p:Float64} |
lt | number | less than | expr < {p:Float64} |
lte | number | at most | expr <= {p:Float64} |
between | number, both num and num2 | inclusive range | expr BETWEEN {a:Float64} AND {b:Float64} |
in | list of strings | one of these | has({p:Array(String)}, lower(expr)) |
not_in | list of strings | none of these | NOT has({p:Array(String)}, lower(expr)) |
is_set | no value | is present | depends on the column type, see below |
is_not_set | no value | is absent | depends on the column type, see below |
Every operator except is_set and is_not_set needs a value. Its absence is segment: operator requires a value.
is_set has three different meanings, and the difference is real rather than pedantic:
| Where | is_set | is_not_set |
|---|---|---|
| numeric column | expr != 0 | expr = 0 |
| string column | expr != '' | expr = '' |
| custom trait | has(mapKeys(traits), key) | its negation |
birthday | birthday IS NOT NULL | birthday IS NULL |
Every string comparison folds both sides, so a filter typed with a Persian yeh matches a profile stored with an Arabic one. That is the single most common reason a hand-built audience comes back short. تهراني and تهرانی bind to the same literal. The same folding is shared with the analytics compiler on purpose: a dashboard filtered on one city has to count exactly the people this segment counts.
The whole in list travels as one bound Array(String) parameter, so a thousand-city list is still a single placeholder.
Values
{"type": "number", "num": 1000, "num2": 5000}
type | Which field carries the payload |
|---|---|
string | str |
number | num, plus num2 for the upper bound of between |
bool | bool |
list | list, an array of strings |
date | date and date2 |
type is never cross-checked against the operator. A value of {"type": "string", "str": "5"} with a gt operator makes the compiler read num, which is 0, and the condition becomes expr > 0. You get no error.
type: "date" is accepted on the wire and the compiler never reads it. The comparison function reads only num, num2, str and list. A date value with a string operator therefore compares against the empty string. Date comparisons on a trait value are not implemented. For anniversary questions use days_until_birthday and days_until_signup_anniversary.
A list holds at most 1000 items; more is segment: list has too many values. An empty list with in or not_in is segment: operator requires a value.
Time windows
{"kind": "last", "amount": 30, "unit": "day"}
window is read on event nodes only. On trait, segment, engagement and churn it is silently ignored.
kind | Required fields | Predicate emitted |
|---|---|---|
all_time or the empty string | none | no predicate on event_time at all |
last | amount, unit | event_time >= now() - INTERVAL {p:UInt32} <UNIT> |
between | from, to | event_time BETWEEN {p:DateTime64(3)} AND {p:DateTime64(3)} |
after | from | event_time >= {p:DateTime64(3)} |
before | to | event_time < {p:DateTime64(3)} |
An absent window object is the same as all_time. Any other kind is segment: invalid time window: kind "...".
Relative windows. unit is one of minute, hour, day, week, month, case insensitive. The unit is the one part of the query that cannot be a bound parameter, which is exactly why the allow-list exists. amount must be between 1 and 10000.
INTERVAL n MONTH in ClickHouse is a calendar month. But the helper that works out how far back a definition reaches, which the scheduler uses, approximates a month as 30 days. So for month windows the scheduler's pre-flight and the actual query disagree slightly.
Absolute windows. from and to are RFC 3339 timestamps.
{"kind": "between", "from": "2026-03-21T00:00:00Z", "to": "2026-06-21T00:00:00Z"}
between needs both bounds and to must not be before from, otherwise segment: invalid time window: between needs from and to or segment: invalid time window: to is before from. after needs only from; before needs only to. Note that after includes the instant itself (>=) and before does not (<).
Everything is UTC. There is no timezone field on a window, no tenant timezone is applied to one, and no Jalali date is ever sent over the wire. That decision is explicit: the panel presents dates on the Jalali calendar and always sends a UTC instant.
Jalali appears only in the description sentence. The window {"kind": "after", "from": "2026-03-21T00:00:00Z"} reads in Persian as:
پس از ۱ فروردین ۱۴۰۵
and the same instant reads in English as 21 March 2026.
The panel writes between, but not before or after. The window control offers the same preset list of 1, 7, 14, 30, 90, 180 and 365 days plus "all time", and beside them "between two dates", which opens the Jalali calendar and writes from and to. A cohort, meaning "people who first did X between these two days", is the one audience a relative window cannot express, and that is why the option exists.
In that mode the calendar offers no relative shortcut. A shortcut hands back a range that keeps moving and this control has to produce two fixed dates, so an option whose result would have to be frozen immediately is not offered at all.
before and after are still reachable only through the API.
The trap: a misspelled event name compiles cleanly and matches nobody
An event name is checked against no list. The compiler verifies only that it is non-empty and at most 128 bytes, then binds it as a parameter. order_completd produces perfectly valid SQL that returns zero rows, and that is indistinguishable from a real audience of zero.
The same trap applies to trait names and to event-property names, both in properties and in aggregate.
Why it is built this way: customers define their own traits and events constantly, and rejecting unknown names would make the feature useless. There is precedent for the cost, too. The national_id trait was stored the whole time while missing from the column list, so a segment on it matched nobody, for every customer, with no error anywhere.
The remedy is to look the names up rather than guess them.
curl https://api.segmentic.net/v1/schema/events \
-H "Authorization: Bearer sk_seg_..."
{
"events": [
{"name": "order_completed", "volume": 812443, "prop_keys": ["revenue", "category", "coupon"], "last_seen": "2026-08-06"},
{"name": "product_viewed", "volume": 4192010, "prop_keys": ["sku", "category"], "last_seen": "2026-08-07"}
]
}
The list is ordered by volume. last_seen is the most useful column in this response: an event with a large volume and a last-seen three weeks ago is an integration that broke, and no other figure here says so. Volume alone looks healthy for a month afterwards, because its window is 90 days.
For profile traits:
curl https://api.segmentic.net/v1/schema/traits \
-H "Authorization: Bearer sk_seg_..."
{
"traits": ["city", "loyalty_tier", "gc_key_balance"],
"schema": [
{"name": "city", "kind": "string", "users": 114233},
{"name": "loyalty_tier", "kind": "string", "users": 40112},
{"name": "gc_key_balance", "kind": "number", "users": 98004}
]
}
kind says which map the trait lives in, and that is what decides which column "over 5,000,000" and "equals 5,000,000" compile against. users is how many profiles carry it; a trait three people have is probably not the one you meant.
Both routes require event.read.
Once the filter is written, read it back with POST /v1/audiences/validate and compare the Persian sentence against what you had in your head. A caller who sees Tehran when they meant Mashhad has found the bug before spending a query.
Eight complete examples
Each example is the full JSON plus the sentence the server returns for it.
The Persian sentence is what POST /v1/audiences/validate puts in description_fa. The management API always answers in Persian, because the language middleware is not mounted on it and the default locale is Persian. The English sentence below each example is what the panel renders when its language is English, reached through the panel's own describe route.
One complete exchange, so the shape is clear:
curl -X POST https://api.segmentic.net/v1/audiences/validate \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{"definition":{"version":1,"root":{"kind":"churn","band":"high"}}}'
{"valid": true, "description_fa": "کاربرانی که در گروه «ریسک ریزش بالا» هستند"}
Values are never translated. Whatever string you put in the filter comes back verbatim in the sentence.
Abandoned cart
{
"definition": {
"version": 1,
"root": {
"kind": "group",
"op": "and",
"children": [
{
"kind": "event",
"event": "product_added_to_cart",
"count": {"operator": "gte", "value": 1},
"window": {"kind": "last", "amount": 7, "unit": "day"}
},
{
"kind": "event",
"event": "order_completed",
"negate": true,
"count": {"operator": "gte", "value": 1},
"window": {"kind": "last", "amount": 7, "unit": "day"}
}
]
}
}
}
Users who in the last 7 days did “Added to cart” at least once and in the last 7 days did not do “Purchase”
A Tehran buyer who has not opened the app
{
"definition": {
"version": 1,
"root": {
"kind": "group",
"op": "and",
"children": [
{
"kind": "event",
"event": "order_completed",
"count": {"operator": "gte", "value": 3},
"window": {"kind": "last", "amount": 30, "unit": "day"}
},
{"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "Tehran"}},
{"kind": "event", "event": "app_opened", "negate": true}
]
}
}
}
Users who in the last 30 days did “Purchase” at least 3 times and have a city of “Tehran” and did not do “App opened”
The third condition carries no window, so it means "has never opened the app", not "has not opened it in the last thirty days".
Total spend over ninety days
{
"definition": {
"version": 1,
"root": {
"kind": "event",
"event": "order_completed",
"window": {"kind": "last", "amount": 90, "unit": "day"},
"aggregate": {"function": "sum", "property": "revenue", "operator": "gte", "value": 2000000}
}
}
}
Users who in the last 90 days have a total amount for “Purchase” that is at least 2,000,000
The root here is an event node rather than a group. That is valid.
The Persian version of this sentence sets the number in Persian digits with the Arabic thousands separator (U+066C), because that is how an amount is read in Iran.
Reachable people in Tehran, with a nested group
{
"definition": {
"version": 1,
"root": {
"kind": "group",
"op": "and",
"children": [
{"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "Tehran"}},
{
"kind": "group",
"op": "or",
"children": [
{"kind": "trait", "trait": "has_push", "operator": "eq", "value": {"type": "bool", "bool": true}},
{"kind": "trait", "trait": "has_email", "operator": "eq", "value": {"type": "bool", "bool": true}}
]
}
]
}
}
}
Users who have a city of “Tehran” and (have a push capability or have a email address)
Only nested groups are parenthesised; the top level reads better without. The panel can build this definition too.
A member of a static list
{
"definition": {
"version": 1,
"root": {"kind": "segment", "segment_id": 1234, "in_segment": true}
}
}
Users who are in segment 1,234
Drop "in_segment": true and the meaning inverts to "are not in segment 1,234". That is the only field in this whole language whose absence reverses a condition.
A purchase over 500,000 in one category
{
"definition": {
"version": 1,
"root": {
"kind": "event",
"event": "order_completed",
"window": {"kind": "last", "amount": 7, "unit": "day"},
"properties": [
{"property": "revenue", "operator": "gt", "value": {"type": "number", "num": 500000}},
{"property": "category", "operator": "eq", "value": {"type": "string", "str": "mobile"}}
]
}
}
}
Users who in the last 7 days did “Purchase” where its amount is over 500,000 and its category is “mobile”
revenue is the only event property with a name of its own, rendered as "amount". Every other key appears raw in the sentence, because there is no translation for a field the customer invented and inventing one would be worse than showing what they typed.
About to churn
{"definition": {"version": 1, "root": {"kind": "churn", "band": "high"}}}
Users who they are in the "high churn risk" group
This one and the dormant-engagement audience are the two that work on day one of an integration, because neither depends on an event name.
Ignored the last ten messages
{
"definition": {
"version": 1,
"root": {
"kind": "engagement",
"metric": "ignored_streak",
"operator": "gte",
"value": {"type": "number", "num": 10}
}
}
}
Users who have a messages ignored in a row of at least 10
The metric form reuses the same sentence template every other numeric comparison uses, so an engagement condition reads like the rest of the sentence rather than like a feature bolted on beside it. In English that reuse is what makes the article read awkwardly.
Sizing an audience: what each call gives you
There are six routes here and only the first two are reachable from the internet.
| Route | Host | Permission | What it returns | Cost |
|---|---|---|---|---|
POST /v1/audiences/validate | api.segmentic.net | segment.read | validity and the Persian sentence | 1 unit |
POST /v1/audiences/count | api.segmentic.net | segment.read | the exact count | 25 units |
POST /v1/segments/estimate | panel only | segment.read | a sampled estimate | none |
POST /v1/segments/preview | panel only | profile.read | a few real profiles | none |
POST /v1/segments/identifier-preview | panel only | profile.read | matched count and up to 100 profiles for ids, emails, or phones | none |
POST /v1/segments/describe | panel only | segment.read | the sentence only | none |
"Panel only" means those routes are registered on the dashboard's control plane, and that port is deliberately not routed from the internet. The proxy sends the public host to the API's second listener and nothing else. So you cannot call POST /v1/segments/preview from your own server; that preview button lives inside the panel.
The identifier preview accepts a body such as {"identifiers":["09123456789","user_42"],"limit":100}. It resolves at most 50,000 inputs in one tenant-scoped query and returns count, users, unmatched, and truncated when the input was cut short. It does not write segment membership.
The other five take the same body:
{"definition": { }, "limit": 10}
Only preview reads limit.
validate
curl -X POST https://api.segmentic.net/v1/audiences/validate \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{"definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"Tehran"}}}}'
{"valid": true, "description_fa": "کاربرانی که شهر آنها «Tehran» است"}
An invalid filter comes back as 422 in the management API error envelope:
{"error": {"code": "filter_invalid", "message": "segment: group has no children"}}
That is deliberate and differs from the panel's version of the same check: the panel answers 200 with valid: false, which is right for a form somebody is typing into and wrong for an integration whose error handling branches on status.
No database is touched. This is the cheapest way to be sure a filter says what you think it says.
count
curl -X POST https://api.segmentic.net/v1/audiences/count \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{"definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"Tehran"}}}}'
{"count": 114233, "approximate": false, "description": "کاربرانی که شهر آنها «Tehran» است", "took_ms": 812}
Three things here will surprise you and all three come from one cause: this route reuses the panel's handler unchanged.
- The field is
description, notdescription_fa, and its contents are always Persian. - Its errors use the panel's flat envelope, not the management one. An invalid filter is
400 {"error": "segment: ..."}and a warehouse failure is503 {"error": "count unavailable"}. That contradicts the management API's own promise of one error shape everywhere. approximateis alwaysfalse.
It costs 25 budget units, because it is a full FINAL scan of your profiles plus every subquery. Its timeout is 30 seconds.
There is no concurrency gate on this route and no refusal for an unbounded window: a filter with an event condition and no window compiles and runs.
estimate
The live counter under the panel's segment builder. It hashes user ids into buckets, reads one in 100, and scales the number back up.
{"count": 2400000, "approximate": true, "sample_rate": 100, "description": "کاربرانی که شهر آنها «Tehran» است", "took_ms": 41}
Its timeout is 3 seconds and it answers 503 {"error": "estimate unavailable"} rather than waiting.
It has one fallback to an exact count: if the sampled figure is under 3000, meaning fewer than 30 real rows in the sample, the exact query is run instead and the answer comes back with approximate: false and no sample_rate. The reason is that an audience of 40 people reads as 0 under a 1-in-100 sample, and a wrong zero is worse than an approximate number.
preview
Its permission is profile.read, not segment.read, because this route returns the names, mobile numbers and cities of real people.
{"users": [{"user_id": "u_1", "email": "ali@example.ir", "phone": "+989120000000", "first_name": "Ali", "city": "Tehran", "last_seen": "2026-08-01T09:00:00Z"}]}
limit defaults to 10 and is capped at 100. The cap is deliberate rather than merely a default: honouring a caller-supplied limit without a ceiling turns "preview" into a bulk export of the customer's list, reachable by anyone holding profile.read and indistinguishable in the audit log from somebody glancing at ten rows. Taking data out of the building is data.export, which is a separate permission.
The order is last_seen DESC, so you see the most recently active matches, not a random sample. Contact fields are not masked.
describe
Returns the sentence only and touches no database, which is what lets the segment builder call it on every keystroke.
{"description": "Users who have a city of “Tehran”"}
Its language comes from the Accept-Language header, because the language middleware wraps the panel's mux.
This route does not validate. A definition that cannot compile still gets a sentence, and an empty definition gets "Everyone". So describe and validate disagree about the empty definition: describe calls it everyone and validate rejects it.
Saved segments: create, update, delete
A saved segment is a row in Postgres with a name that is unique per account.
{
"id": 11,
"name": "Tehran buyers",
"kind": "dynamic",
"definition": {"version": 1, "root": { }},
"description_fa": "کاربرانی که شهر آنها «Tehran» است",
"last_size": 0,
"updated_at": "2026-08-07T11:20:00Z"
}
description_fa is always computed server side and cached at save time. Anything you send in that field is discarded.
There are three kinds:
dynamic, the default. Nothing is materialised. The definition is run fresh every time somebody counts it or a campaign pages through it.static. Membership is the rows somebody uploaded. The definition is not compiled and may legitimately be just{"version": 1}.realtime. Both the API and the database constraint accept the value and nothing in the backend implements it. The only code that treats it specially refuses direct membership writes to it exactly as it refuses a dynamic one. Treat it as reserved, not functional.
The kind cannot be changed after creation. An update writes name, definition and description_fa, and deliberately leaves kind alone. Turning a saved audience from static to dynamic would silently discard its membership on the next recompute, and turning it the other way would freeze a query somebody still believes is live.
From the management API
| Method and path | Permission | Success |
|---|---|---|
GET /v1/segments | segment.read | {"segments": [...]} |
GET /v1/segments/{id} | segment.read | the segment object |
POST /v1/segments | segment.write | 201 |
PUT /v1/segments/{id} | segment.write | 200 |
DELETE /v1/segments/{id} | segment.delete | 204 with no body |
The write body has exactly two fields:
curl -X POST https://api.segmentic.net/v1/segments \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{"name":"Tehran buyers","definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"Tehran"}}}}'
{"id": 11, "name": "Tehran buyers", "description_fa": "کاربرانی که شهر آنها «Tehran» است"}
There is no kind field on this body, so every segment the management API creates is dynamic. A static list cannot be created this way.
Errors:
| Status | Code | When |
|---|---|---|
| 400 | name_required | name empty or only whitespace |
| 400 | bad_id | the path id is not a positive integer |
| 400 | malformed_json | the body is not valid JSON |
| 422 | filter_invalid | the definition does not compile, with the compiler's exact text |
| 404 | not_found | on PUT: unknown id, or an id belonging to another tenant |
| 503 | segment_unavailable | the save or delete failed |
On PUT the read before the write is deliberate, so an id taken from another tenant's URL is a 404 rather than a write that silently creates a segment on your account. An empty name on PUT keeps the existing name.
Two routes on this table do not behave the way the rest of it does, and both are because they are the panel's handlers rather than the management API's.
GET /v1/segments/{id} answers its 404 in the flat envelope, {"error": "segment not found"}, with no code field. Parse error as string | object on this route.
DELETE /v1/segments/{id} never answers 404 at all. The archive is one UPDATE ... WHERE tenant_id = $1 AND id = $2 AND archived_at IS NULL and the affected row count is not read, so deleting an id that does not exist, an id belonging to another account, or an id you already deleted all answer 204 exactly as a real delete does. Nothing in the response tells the three apart. If it matters that a segment was really there, GET it first.
Three things that do not exist and that you might expect. There is no If-Match and no version token, so two concurrent writers silently clobber each other. No idempotency key is honoured. And a delete is never refused because the segment is in use: deleting the audience a scheduled campaign points at succeeds.
Delete is in fact an archive. The row stays and only archived_at is filled in. A campaign that already ran references this definition, and a report that cannot say who a send went to is worse than a slightly longer list.
GET /v1/segments on the management API is not paginated either. It is a fixed cap of 200 rows ordered by updated_at descending, and limit and cursor parameters are ignored. Because it is the panel's own handler, it does not use the standard page envelope; the response is {"segments": [...]}. The full definition is included in every list row.
The cap is silent, which is worse than the cap. There is no has_more, no next_cursor and no total in the response, so an account holding 250 segments sees the 200 most recently updated and 200 is all it will ever see. No route on either surface reaches the other 50. The only thing that moves one back into view is editing it, because a save writes updated_at. If you hold more than 200 audiences, keep your own index of their ids: GET /v1/segments/{id} fetches any of them by id and is not capped.
From the panel
The panel has four separate routes reachable only from the panel itself. The meaningful differences from the management API:
POST /v1/segmentsis an upsert: a non-zeroidmeans update, an absentidmeans create. The response is200 {"id": 11}in both cases, not 201.- It accepts a
kindfield, so a static list can only be created this way. A value outside the three allowed is400 {"error": "unknown segment kind"}. - For
static, the description sentence is replaced with a fixed string, "A manual list; you add the members yourself". Without that, the empty definition would describe as "Everyone" and a hand-uploaded list of forty thousand people would be labelled on screen as the entire user base. - A duplicate name violates the unique constraint and comes back as
503 {"error": "could not save segment"}, not a 409 and not a helpful 400. - The body cap is
1 MiB, where the management API allows8 MiB.
Static lists and their members
A static list is a named list somebody put people into: an agency's spreadsheet, a settlement report, the winners of a draw. It has three routes and none of them is on the management API; they are panel only.
| Method and path | Permission |
|---|---|
GET /v1/segments/{id}/members | segment.read |
POST /v1/segments/{id}/members | segment.write |
DELETE /v1/segments/{id}/members/{user_id} | segment.write |
GET returns only {"size": 4670} and does not check the segment kind, so a dynamic segment reports size: 0 here rather than an error.
The add body has one field and accepts user ids, mobile numbers and email addresses mixed together:
{"identifiers": ["09123456789", "ali@example.ir", "u-42", "۰۹۱۲۳۴۵۶۷۸۹"]}
Mixing is deliberate: a spreadsheet has one column and the marketer knows which; making them say so would be a field they get wrong. Persian digits are normalised to ASCII and numbers to E.164. Each value is tried as a user id first and as a phone or email second, because a tenant whose user ids are mobile numbers is common enough in Iran to be worth defaulting to.
The response:
{"added": 38210, "unmatched": ["09120000000"], "truncated": false, "size": 41902}
- The cap is 50,000 identifiers per request. Beyond that the list is truncated and
truncated: truecomes back. A larger file goes through the CSV import. unmatchedreturns the identifiers themselves rather than a count, because "3,412 of 40,000 did not match" is a number somebody has to act on and cannot: they need the rows to check against their own file. That list is capped at 100 entries.- An identifier matching more than one profile is skipped and reported as unmatched.
- Duplicates within one request collapse.
- Adding to a segment that is not static is
409with "This segment is defined by a rule; users can only be added to a fixed list". - An empty list is
400with "The list is empty".
Removing one member is a ClickHouse ALTER TABLE ... DELETE mutation, which is slow by design.
How membership is refreshed
For a dynamic segment there is no stored membership at all and no refresh job. The definition is the segment, and it is run fresh each time.
The practical consequences of that decision:
last_sizeis always 0 andlast_computed_atis always absent. The function that writes those two exists in the code and has no caller, so on a real install the columns stay empty forever. That is why the segment card in the panel permanently reads "size not updated" and the campaign audience picker never shows a people count.- The
refresh_croncolumn exists in the schema and no code reads or writes it. There is no recompute schedule. - Consumers resolve the definition live instead. A campaign pages the compiled query at send time and sizes it with the estimate, falling back to an exact count below 5,000 people. A condition inside a journey compiles the definition narrowed to one user id and counts.
The one place a dynamic segment's membership is remembered is a journey trigger. The scanner pages the segment, diffs it against the previous scan, and enrols the difference: segment_enter takes the new arrivals and segment_exit takes the departures. The first scan after a journey is published records the membership and enrols nobody, because otherwise publishing a win-back aimed at 400,000 lapsed customers means every one of them gets the message in the next five minutes. A segment larger than 250,000 members is truncated, and the truncation is recorded both in the log and on the trigger's state row.
For a static list, membership is the rows you wrote. The table is a ReplacingMergeTree keyed on a nanosecond version column and every read uses FINAL, so duplicate adds collapse.
Limits and defaults
| Thing | Value |
|---|---|
| maximum nesting depth | 8 (the root is depth zero, so nine levels) |
| maximum node count | 200, counting each properties entry |
maximum items in an in list | 1000 |
| maximum length of a trait, event or key name | 128 bytes, about 64 Persian letters |
amount on a relative window | 1 to 10000 |
| estimate sample rate | one in 100 |
| estimate timeout | 3 seconds |
| query timeout (count, preview, save) | 30 seconds |
| exact-count fallback threshold | an estimate under 3000 |
| preview rows | 10 by default, 100 maximum |
| identifiers per add-members request | 50,000 |
| unmatched identifiers reported | 100 |
| body cap on the management API | 8 MiB |
| body cap on the panel routes | 1 MiB |
| saved-segment list cap | 200 rows, no pagination |
| journey trigger scan cap | 250,000 members |
| budget cost: validate and segment CRUD | 1 unit |
| budget cost: count | 25 units |
The permissions involved are segment.read, segment.write, segment.delete, profile.read and event.read. Owner, admin and marketer hold all three segment permissions. Analyst, viewer and approver hold segment.read only. A viewer does not hold profile.read, so a viewer can count an audience but cannot preview it. Rate-limit detail is in limits.
The compiler's exact error text
These nine are everything compilation can return. The text is English and is not translated, and it reaches you verbatim in the message field.
| Base text | When |
|---|---|
segment: unknown node kind | kind empty or unrecognised |
segment: group has no children | an empty children array |
segment: nesting too deep | more than nine levels |
segment: too many conditions | more than 200 nodes and property conditions |
segment: invalid identifier | a bad trait, event, key or aggregate function name; or a zero segment_id; or an unknown band or metric |
segment: unsupported operator | an operator outside the list, or a non-numeric operator on engagement or churn, or anything but is_set on birthday |
segment: operator requires a value | value missing, or an empty in list |
segment: invalid time window | an unknown kind or unit, an out-of-range amount, or missing bounds |
segment: list has too many values | more than 1000 items in a list |
Most are wrapped with the offending value:
segment: unknown node kind: "wat"
segment: invalid identifier: trait " "
segment: invalid time window: unit "fortnight"
segment: unsupported operator: engagement needs a numeric operator, got "contains"
These are not stable machine codes. The only stable code to branch on is filter_invalid in the management API's error envelope. The full envelope is described in errors.
What does not exist
Each of these is something a customer reasonably looks for and does not find. A plausible sentence in place of this list would have cost you an afternoon.
- Date comparison on a trait value.
type: "date"is accepted and ignored. Usedays_until_birthdayanddays_until_signup_anniversary. firstandlastaggregate functions, and any way to filter on the property value of the first or most recent occurrence of an event. The closest available things aredays_since_last_seenanddays_since_last_order, which answer recency but not value.- Comparing two traits as text.
compare_traitcompares numbers on both sides, and a string column on either side is refused by name. See comparing two traits. - A sequence condition ("did A then B"). Event conditions are independent subqueries joined with AND.
- A distinct count.
count()counts rows. - A timezone on a window. Everything is UTC.
- Jalali dates on the wire. Explicitly ruled out; Jalali is a rendering concern only.
noton atrait,eventorsegmentnode. Only groups, engagement and churn read that field.- OR between the property conditions of one event. Always AND.
- A membership refresh job for dynamic segments.
- An implementation of the
realtimekind. The value is accepted and stored and nothing acts on it. - Creating a static list through the management API. Its body has no
kindfield. - Static-list membership routes on the management API. Panel only.
- A management-API estimate route, and a management-API ad-hoc preview route.
- Pagination on
GET /v1/segmentson either surface. - Optimistic concurrency on segment writes. No
If-Match, no version token. - A refusal to delete or edit a segment that is in use.
- An idempotency key on segment creation.
- A server-side template catalogue. The panel's nine templates are TypeScript constants in the browser bundle and no route returns them.
- Event-name validation at compile time. That is the trap.
- An event-property editor, an aggregate editor, a trait compared against another trait, or a segment-membership condition in the panel. All four exist in the language and are written only through the API.
If you are working with an AI agent, three MCP tools cover this page: list audiences, describe a filter and count a filter. Details in MCP.