Skip to content
Docs
FA
Sign in
Start here
  • Overview
  • Quickstart
  • Concepts
Collect data
  • Designing events
  • Event dictionary
  • Placing events
  • Identity
  • Web SDK
  • Android SDK
  • Devices and push
  • Server to server
  • Product catalogue
  • Webhooks
Engage customers
  • Segments
  • Journeys
  • Transactional
  • Consent and caps
  • In-app and inbox
Analyze and export
  • Reports and exports
Developer reference
  • API reference
    • Ingest endpoints
    • Management API
  • Errors
  • Limits
  • OpenAPI
Developer tools
  • MCP server
  • Working with an agent
Privacy and changes
  • Personal data
  • Versioning

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

RULE INPUTS
TraitsWho they are
EventsWhat they did
EngagementHow they respond
SEGMENTICSegment engineRules are evaluated against current customer state
READY TO USE
AudienceCurrent members
CampaignOne-time activation
JourneyContinuous automation
How traits, events and engagement rules become audiences, campaigns and journeys

The outermost object is a Definition and it has two fields.

JSON
{
  "version": 1,
  "root": { "kind": "group", "op": "and", "children": [] }
}
FieldTypeRequiredNotes
versionintegernoThe compiler never reads it. The panel always writes 1. Omit it and 0 is stored, and nothing changes.
rootone NodeyesOmit 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:

JSON
{"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 fieldTypeWhich kind reads it
kindstringall. One of group, trait, event, segment, engagement, churn
opstringgroup only
notbooleangroup, engagement, churn only
childrenarray of Nodegroup only
traitstringtrait only
compare_traitstringtrait only. A second trait in place of value, see comparing two traits
eventstringevent only
negatebooleanevent only
countobjectevent only
aggregateobjectevent only
propertiesarray of objectevent only
windowobjectevent only
segment_idintegersegment only
in_segmentbooleansegment only
bandstringengagement and churn
metricstringengagement only
operatorstringtrait, engagement, churn
valueobjecttrait, 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:

SQL
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

JSON
{"kind": "group", "op": "or", "not": false, "children": [ ]}
  • op has two meaningful values, and and or. Anything that is not exactly or means AND. "OR" in capitals means AND. Nothing validates this field and you get no error.
  • children must not be empty. An empty array is segment: group has no children.
  • not: true wraps the whole group in NOT (...).
  • 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 properties array as one more, because an event-property predicate is a condition too. Over that is segment: 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

JSON
{"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 nameWhat it counts
total_eventsevery event on this profile
total_revenuethe sum of purchase amounts
order_countorders
days_since_last_seendateDiff('day', last_seen, now())
days_since_last_orderdateDiff('day', last_order_at, now())
days_until_birthdaydays to the next birthday: today is 0, in three days is 3
days_until_signup_anniversarythe 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 comparisonSQL
is_set or is_not_sethas(mapKeys(traits), {p0:String}), and its negation
a bool value with eq or neqlower(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 elsetraits[{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:

JSON
{"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:

JSON
{"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 sidesSQL
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

JSON
{
  "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:

SQL
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:

  • negate absent or false: 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

JSON
{"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:

  1. revenue filters the promoted revenue column directly, as a number.
  2. A numeric operator, or any operator carrying a number except in and not_in, goes to props_num[{key:String}].
  3. A bool value with eq or neq compares props_str[key] against the string true or false.
  4. is_set and is_not_set become has(mapKeys(props_str), {key:String}).
  5. 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

JSON
{"count": {"operator": "gte", "value": 3}}
FieldTypeNotes
operatorstringone of eq, neq, gt, gte, lt, lte, between
valuenumberthe bound, or the lower bound
value2numberthe 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

JSON
{"aggregate": {"function": "sum", "property": "revenue", "operator": "gte", "value": 2000000}}
FieldTypeNotes
functionstringsum, avg, min, max only. Case does not matter.
propertystringrevenue, or any numeric event property key
operatorstringthe six numeric operators, plus between
valuenumberthe bound
value2numberfor 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

JSON
{"kind": "segment", "segment_id": 1234, "in_segment": true}

becomes

SQL
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.

JSON
{"kind": "engagement", "band": "dormant"}

Metric. Set metric. Allowed values: score, ignored_streak, open_rate, click_rate, days_since_engaged.

JSON
{"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

JSON
{"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.

JSON
{"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.

OperatorValue typeMeaningSQL emitted
eqstring, number, boolequalslower(expr) = {p:String} or expr = {p:Float64}
neqstring, number, booldoes not equallower(expr) != {p:String} or expr != {p:Float64}
containsstringsubstring, case insensitivepositionCaseInsensitiveUTF8(expr, {p:String}) > 0
not_containsstringnot a substringpositionCaseInsensitiveUTF8(expr, {p:String}) = 0
starts_withstringprefixstartsWith(lower(expr), {p:String})
ends_withstringsuffixendsWith(lower(expr), {p:String})
gtnumbergreater thanexpr > {p:Float64}
gtenumberat leastexpr >= {p:Float64}
ltnumberless thanexpr < {p:Float64}
ltenumberat mostexpr <= {p:Float64}
betweennumber, both num and num2inclusive rangeexpr BETWEEN {a:Float64} AND {b:Float64}
inlist of stringsone of thesehas({p:Array(String)}, lower(expr))
not_inlist of stringsnone of theseNOT has({p:Array(String)}, lower(expr))
is_setno valueis presentdepends on the column type, see below
is_not_setno valueis absentdepends 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:

Whereis_setis_not_set
numeric columnexpr != 0expr = 0
string columnexpr != ''expr = ''
custom traithas(mapKeys(traits), key)its negation
birthdaybirthday IS NOT NULLbirthday 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

JSON
{"type": "number", "num": 1000, "num2": 5000}
typeWhich field carries the payload
stringstr
numbernum, plus num2 for the upper bound of between
boolbool
listlist, an array of strings
datedate 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

JSON
{"kind": "last", "amount": 30, "unit": "day"}

window is read on event nodes only. On trait, segment, engagement and churn it is silently ignored.

kindRequired fieldsPredicate emitted
all_time or the empty stringnoneno predicate on event_time at all
lastamount, unitevent_time >= now() - INTERVAL {p:UInt32} <UNIT>
betweenfrom, toevent_time BETWEEN {p:DateTime64(3)} AND {p:DateTime64(3)}
afterfromevent_time >= {p:DateTime64(3)}
beforetoevent_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.

JSON
{"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.

Shell
curl https://api.segmentic.net/v1/schema/events \
  -H "Authorization: Bearer sk_seg_..."
JSON
{
  "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:

Shell
curl https://api.segmentic.net/v1/schema/traits \
  -H "Authorization: Bearer sk_seg_..."
JSON
{
  "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:

Shell
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"}}}'
JSON
{"valid": true, "description_fa": "کاربرانی که در گروه «ریسک ریزش بالا» هستند"}

Values are never translated. Whatever string you put in the filter comes back verbatim in the sentence.

#Abandoned cart

JSON
{
  "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

JSON
{
  "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

JSON
{
  "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

JSON
{
  "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

JSON
{
  "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

JSON
{
  "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

JSON
{"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

JSON
{
  "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.

RouteHostPermissionWhat it returnsCost
POST /v1/audiences/validateapi.segmentic.netsegment.readvalidity and the Persian sentence1 unit
POST /v1/audiences/countapi.segmentic.netsegment.readthe exact count25 units
POST /v1/segments/estimatepanel onlysegment.reada sampled estimatenone
POST /v1/segments/previewpanel onlyprofile.reada few real profilesnone
POST /v1/segments/identifier-previewpanel onlyprofile.readmatched count and up to 100 profiles for ids, emails, or phonesnone
POST /v1/segments/describepanel onlysegment.readthe sentence onlynone

"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:

JSON
{"definition": { }, "limit": 10}

Only preview reads limit.

#validate

Shell
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"}}}}'
JSON
{"valid": true, "description_fa": "کاربرانی که شهر آن‌ها «Tehran» است"}

An invalid filter comes back as 422 in the management API error envelope:

JSON
{"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

Shell
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"}}}}'
JSON
{"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, not description_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 is 503 {"error": "count unavailable"}. That contradicts the management API's own promise of one error shape everywhere.
  • approximate is always false.

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.

JSON
{"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.

JSON
{"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.

JSON
{"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.

JSON
{
  "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 pathPermissionSuccess
GET /v1/segmentssegment.read{"segments": [...]}
GET /v1/segments/{id}segment.readthe segment object
POST /v1/segmentssegment.write201
PUT /v1/segments/{id}segment.write200
DELETE /v1/segments/{id}segment.delete204 with no body

The write body has exactly two fields:

Shell
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"}}}}'
JSON
{"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:

StatusCodeWhen
400name_requiredname empty or only whitespace
400bad_idthe path id is not a positive integer
400malformed_jsonthe body is not valid JSON
422filter_invalidthe definition does not compile, with the compiler's exact text
404not_foundon PUT: unknown id, or an id belonging to another tenant
503segment_unavailablethe 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/segments is an upsert: a non-zero id means update, an absent id means create. The response is 200 {"id": 11} in both cases, not 201.
  • It accepts a kind field, so a static list can only be created this way. A value outside the three allowed is 400 {"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 allows 8 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 pathPermission
GET /v1/segments/{id}/memberssegment.read
POST /v1/segments/{id}/memberssegment.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:

JSON
{"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:

JSON
{"added": 38210, "unmatched": ["09120000000"], "truncated": false, "size": 41902}
  • The cap is 50,000 identifiers per request. Beyond that the list is truncated and truncated: true comes back. A larger file goes through the CSV import.
  • unmatched returns 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 409 with "This segment is defined by a rule; users can only be added to a fixed list".
  • An empty list is 400 with "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_size is always 0 and last_computed_at is 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_cron column 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

ThingValue
maximum nesting depth8 (the root is depth zero, so nine levels)
maximum node count200, counting each properties entry
maximum items in an in list1000
maximum length of a trait, event or key name128 bytes, about 64 Persian letters
amount on a relative window1 to 10000
estimate sample rateone in 100
estimate timeout3 seconds
query timeout (count, preview, save)30 seconds
exact-count fallback thresholdan estimate under 3000
preview rows10 by default, 100 maximum
identifiers per add-members request50,000
unmatched identifiers reported100
body cap on the management API8 MiB
body cap on the panel routes1 MiB
saved-segment list cap200 rows, no pagination
journey trigger scan cap250,000 members
budget cost: validate and segment CRUD1 unit
budget cost: count25 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 textWhen
segment: unknown node kindkind empty or unrecognised
segment: group has no childrenan empty children array
segment: nesting too deepmore than nine levels
segment: too many conditionsmore than 200 nodes and property conditions
segment: invalid identifiera bad trait, event, key or aggregate function name; or a zero segment_id; or an unknown band or metric
segment: unsupported operatoran operator outside the list, or a non-numeric operator on engagement or churn, or anything but is_set on birthday
segment: operator requires a valuevalue missing, or an empty in list
segment: invalid time windowan unknown kind or unit, an out-of-range amount, or missing bounds
segment: list has too many valuesmore 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. Use days_until_birthday and days_until_signup_anniversary.
  • first and last aggregate functions, and any way to filter on the property value of the first or most recent occurrence of an event. The closest available things are days_since_last_seen and days_since_last_order, which answer recency but not value.
  • Comparing two traits as text. compare_trait compares 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.
  • not on a trait, event or segment node. 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 realtime kind. The value is accepted and stored and nothing acts on it.
  • Creating a static list through the management API. Its body has no kind field.
  • 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/segments on 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.

PreviousProduct catalogueNextJourneys

On this page

  • The shape of a definition
  • Groups: and, or and not
  • Conditions on a profile trait
  • Conditions on an event
  • Membership of another segment
  • Engagement
  • Churn risk
  • Operators
  • Time windows
  • The trap: a misspelled event name compiles cleanly and matches nobody
  • Eight complete examples
  • Sizing an audience: what each call gives you
  • Saved segments: create, update, delete
  • Static lists and their members
  • How membership is refreshed
  • Limits and defaults
  • The compiler's exact error text
  • What does not exist

Segmentic

This page is written from the code