# Segmentic technical documentation
This file is every documentation page concatenated, built to be pasted into a language model's context.
```
Ingest host https://in.segmentic.net key: wk_... public, ships in your app
Management host https://api.segmentic.net key: sk_seg_... secret, server side only
Panel https://app.segmentic.net
```
Never put an `sk_seg_` key in browser code or inside a mobile app.
---
# Segmentic technical documentation
> A task-based map of Segmentic documentation: setup, data collection, engagement, analytics and developer reference.
> https://segmentic.net/en/docs
Segmentic collects product events, builds audiences and delivers messages through the right channel. If you are connecting the service for the first time, begin with the [quickstart](/en/docs/quickstart).
## Three surfaces {#surfaces}
Each surface has a distinct key and purpose:
- **Ingest:** `https://in.segmentic.net` with a `wk_seg_...` key, for recording events from a site, app or server
- **Management:** `https://api.segmentic.net` with an `sk_seg_...` key, for managing audiences, campaigns and reports from your backend
- **Panel:** `https://app.segmentic.net` with a user account, for configuring and inspecting your workspace
The write key is public on purpose: it is meant to ship inside your own JavaScript. It can write events, register a device, subscribe to browser push and fetch on-site messages. It cannot read a profile and it cannot mint another key. The management key is the opposite: it does whatever its role and permissions allow across the account, and it must never reach a browser. The two prefixes are separate so that a leak report is settled at a glance. A `wk_` in a public bundle is working as designed; an `sk_` in the same place is an incident.
## Start here {#start}
- [Quickstart](/en/docs/quickstart): create a key and record your first event
- [Concepts](/en/docs/concepts): understand the product's core terms
## Collect data {#collect}
- [Designing events](/en/docs/events) and the [event dictionary](/en/docs/event-dictionary): choose consistent names and properties
- [Instrumentation](/en/docs/instrument): place tracking calls at the right point in your product
- [Identity](/en/docs/identity): connect anonymous activity to signed-in users
- [Web SDK](/en/docs/sdk-web): connect a site or web app
- [Android SDK](/en/docs/sdk-android): events, identity, push and in-app messages in an Android app
- [Server to server](/en/docs/server): record authoritative events such as captured payments
- [Devices and push](/en/docs/devices): prepare mobile and browser push
- [Product catalogue](/en/docs/catalog) and [webhooks](/en/docs/webhooks): keep data in sync with other services
## Engage customers {#engage}
- [Segments](/en/docs/segments): build audiences from behaviour and traits
- [Journeys](/en/docs/journeys): run multi-step customer flows
- [Transactional messages](/en/docs/transactional): send urgent messages safely
- [Consent and caps](/en/docs/consent): respect recipient preferences
- [In-app messages](/en/docs/onsite): banners, modals, surveys and inboxes
## Analyze and export {#analyze}
- [Reports and exports](/en/docs/reports): funnels, retention and data files
## Developer reference {#reference}
- [API reference](/en/docs/api): choose the right host, key and route
- [Ingest endpoints](/en/docs/api/ingest) and [Management API](/en/docs/api/management): request and response details
- [Errors](/en/docs/errors) and [limits](/en/docs/limits): build a reliable client
- [OpenAPI](/en/docs/openapi): generate clients from the machine-readable contract
## Developer tools {#tools}
- [Working with an AI agent](/en/docs/ai): download the complete documentation and use the starter prompt
- [The MCP server](/en/docs/mcp): let Claude, Codex or Cursor read and act on your account
## Privacy and changes {#trust}
- [Personal data](/en/docs/privacy): access, deletion and retention
- [Versioning](/en/docs/versioning): API compatibility and change notices
## Machine-readable copies {#machine-readable}
Tools and AI agents can use [/llms.txt](/llms.txt), [/llms-full.txt](/llms-full.txt), [/docs-en.md](/docs-en.md) and [/docs-fa.md](/docs-fa.md). The OpenAPI document is available at [/openapi.json](/openapi.json).
---
# Quickstart: from key to first event
> Create a key, send your first event and watch it arrive, in ten minutes and with one HTTP request.
> https://segmentic.net/en/docs/quickstart
This page needs a Segmentic account and a terminal. No SDK, no npm, no library. By the end you will have sent an event, watched it arrive in the panel, and attached it to a named person.
> Diagram: The complete Segmentic flow from customer data sources to segments, journeys and reports
It starts in a browser, and that is said first because it is not what a reader expects: no public HTTP route mints a key. The two routes that do sit on the panel's internal listener, which nothing outside the network can address; the panel reaches it for you, carrying your signed-in session. So the first key comes out of a browser. There is no self-service signup either; an operator creates the account and you start with a username and a password. The honest shape of the path is two turns in a browser and one request in a terminal.
Everything you send here is real data on your account. If you would rather keep test data away from real data, create a separate app for it, which is the next step anyway.
## Create a key {#create-a-key}
Sign in and go to `https://app.segmentic.net/en/connect`. In the sidebar this page sits under Settings and is called SDK Setup. The page itself is titled Connect and has four steps in order: App, Key, Install, Check.
**Step one, the app.** Give it a name (for example "main site"), pick a platform and press Add. The platforms you can pick are `web`, `android`, `ios`, `windows`, `macos`, `linux` and `server`. Every site or app becomes its own row, because every row carries its own key and revoking one does not take the rest down.
**Step two, the key.** On the app you just created, press New key. What comes back starts with `wk_seg_` and carries forty-three characters after it, fifty in all. Copy it now, because the full key is displayed exactly once. If you lose it, mint a new one; there is no way to see the old one again and that is deliberate. From that moment only a hash of the key is stored, not the key.
This key sits in your site's public code and can only write events. It cannot read a profile and it cannot mint another key. The key the management API needs is a different thing, starts with `sk_seg_`, and is created somewhere else (Settings, then API keys). Sent to `in.segmentic.net` it answers 401.
> [!warn]
> The form on this page sends a name and a platform and nothing else, so an app created here is always `development`. The other two values, `staging` and `production`, are set only in the body of `POST /v1/apps`, which sits on the panel's internal listener beside the key routes: neither the ingest host nor the management host serves it, and the only public path to it is the panel's own server-side proxy at `https://app.segmentic.net/api/proxy/v1/apps`, on the signed-in user's session cookie. So a signed-in user who can mint keys can create a `production` app even though this form will not, and no route changes an app's environment after it is created. It makes no difference to the data either way: the environment is stored on the app and the collector never reads it. An event from a development app lands exactly where an event from a production app lands. Keeping data apart means a separate app with a separate key, not a different environment on one app.
## Your first event {#first-event}
Put your own key in place of `wk_seg_...` and run this in a terminal:
```bash title="the first event"
curl -X POST https://in.segmentic.net/v1/track \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"message_id": "qs-1",
"event": "install_check",
"anonymous_id": "quickstart-1",
"properties": { "source": "curl" }
}'
```
The response:
```json
{ "status": "ok", "accepted": 1 }
```
`accepted` is the number of events taken. It is omitted entirely when it is zero, so its absence means nothing was accepted.
A few things about that one request:
- The path decides the message type. `/v1/track` can only produce a `track` event, and a `type` field in the body is ignored rather than honoured.
- One of `user_id` or `anonymous_id` is required. With neither, the answer is 400 with the message `missing_identity`.
- On `track`, `event` is required. Without it the answer is 400 with `missing_event_name`.
- `timestamp` is optional, and if you omit it the server's receive time is recorded. If you do send one it must be `RFC 3339`. Unix seconds or a bare date fail the JSON decode and answer 400.
- A timestamp outside the window is moved, not refused. Older than the account's retention window it is clamped to that window's edge with a `timestamp_too_old` warning; more than an hour ahead it is clamped to the receive time with `timestamp_in_future`. Both answer 200, so if you do not read the warnings you never find out.
- The account id and the app id are read from the key, never from the body. The `ip` and the user agent come from the connection too, so a client cannot fake its own geography or its own device.
- Instead of the `Authorization` header the key can go in `X-Segmentic-Key` or in a `?write_key=` query parameter. The third exists for image beacons and `sendBeacon` calls, which cannot set headers.
## The warning in the response {#warnings}
Now send the same request with no `message_id`:
```bash title="without a message id"
curl -X POST https://in.segmentic.net/v1/track \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"event": "install_check",
"anonymous_id": "quickstart-1"
}'
```
It is still 200, but something comes with it:
```json
{
"status": "ok",
"accepted": 1,
"warnings": [
{
"code": "generated_message_id",
"field": "message_id",
"note": "no message_id sent; retries of this event cannot be de-duplicated"
}
]
}
```
A warning means the event was accepted and something was corrected. Here the server minted an id for you, and the reason it complains is this: a `message_id` is what makes a retry safe. An SDK on a mobile network resends on the smallest interruption, and without a stable id a customer's purchase count silently doubles. The server generates one so the event is not lost, but flags that the sender is at fault.
De-duplication is scoped to your account and the window is 48 hours by default. Send the same `message_id` again and you get 200 and `accepted: 1` again, with no second event stored. The answer is deliberately identical: an SDK that got an error would keep retrying for ever. A duplicate is also not metered and never appears on an invoice.
## When the answer is not 200 {#not-two-hundred}
| Status | Body | Meaning |
|---|---|---|
| 400 | `{"status":"error","message":"malformed JSON"}` | the body is not valid JSON |
| 400 | `{"status":"error","message":"missing_identity"}` | neither `user_id` nor `anonymous_id`. The message is the code itself |
| 401 | `{"status":"error","message":"missing write key"}` | no key in any header and none in the query |
| 401 | `{"status":"error","message":"invalid write key"}` | unknown key, revoked key, or a suspended account. All three give one answer, so the endpoint cannot be used to probe which keys exist |
| 402 | `{"status":"error","message":"..."}` with a Persian sentence | an account ceiling is full. Nothing changes until somebody makes a commercial decision, so retrying is pointless |
| 413 | `{"status":"error","message":"request body too large"}` | the body was over 5 MiB (`5242880` bytes) |
| 503 | `{"status":"error","message":"cannot verify the write key right now; retry"}` | the database was unreachable for the key lookup. A `Retry-After: 5` header comes with it |
| 503 | `{"status":"error","message":"temporarily unavailable, please retry"}` | both the message bus and the on-disk buffer failed |
The split between 401 and 503 is deliberate and came out of a real failure. An SDK reads 401 as permanent and discards the event; it reads 503 as transient and keeps it. Postgres was once scaled to zero and the key lookup answered 401, so eight events out of eight were discarded while the on-disk buffer existed precisely for that case. A failure on our side is now always a 503.
The 402 message is always Persian. This host has no locale middleware, so `Accept-Language` has no effect on it.
There is no rate limiting anywhere on the ingest host. The only volume control is the plan's event allowance, and that answers 402.
Every response, 200 or error, carries an `X-Segmentic-Trace` header: sixteen hex characters that find that one request in our logs. It appears in no error body, so it has to be taken off the header at the time. Send a valid one yourself and it is echoed back, so both sides hold the same id. It is the value to quote when you open a ticket.
## Watching it arrive {#see-it-arrive}
There are two places to see the event, and they answer two different questions.
**Step four of the Connect page, Check.** It polls every four seconds on its own, so there is no need to refresh. What it answers is small and sufficient: has this app ever delivered an event, when, and how many in the last 24 hours. It also shows the last few event names, so you can see that what arrived is what you sent. Zero is a real answer there rather than an empty screen.
**Live events, at `https://app.segmentic.net/en/debug`.** In the sidebar it is under Connections & Integrations and is called Live connection test. Each row shows the event name, the user id, the values that were sent, and any warnings that event raised.
> [!note]
> The order matters: start the recording on the Live events page first, then send. While nobody is watching, the collector writes nothing to the debugger, because one write per event for a screen nobody has open costs more than the write is worth. Closing the page stops the recording.
> [!warn]
> Live events records the single-event routes only: `/v1/track`, `/v1/identify`, `/v1/page`, `/v1/screen` and `/v1/alias`. `POST /v1/batch` is not recorded, and every SDK we ship sends over exactly that route. So after an SDK install this page stays empty even when the events are arriving perfectly. For an SDK install, take the answer from the Check step on the Connect page instead.
If the event you sent with curl never appears here, the problem is in sending it, not in the reports.
## Attaching the event to a person {#identify}
So far the event belongs to `quickstart-1`, which is an anonymous id. `identify` is what creates a named person with traits:
```bash title="creating a profile"
curl -X POST https://in.segmentic.net/v1/identify \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"message_id": "qs-2",
"user_id": "u_123",
"anonymous_id": "quickstart-1",
"traits": {
"email": "Ali@Digikala.COM",
"phone": "09123456789",
"city": "شيراز",
"key_balance": 428
}
}'
```
The response:
```json
{ "status": "ok", "accepted": 1 }
```
Four things happened that the response does not show:
- `email` was trimmed and lower-cased to `ali@digikala.com`. There is no format validation on an email address at all.
- `phone` was stored as E.164, `+989123456789`, and a second trait `phone_operator` was written with the value `mci`. Had the number not parsed, the raw value would have been stored as given, `phone_operator` would not have been written, and an `invalid_phone` warning would have come back.
- `city` was normalised: Arabic ye folded to Persian ye. Without that, a segment filtering on «شیراز» silently misses every user whose keyboard was Arabic.
- `key_balance` was written twice: once as the string `"428"` and once as the number `428`. The numeric half is what answers a condition like "greater than 100". A numeric-looking string is never parsed into a number, because `"0912..."` would lose its leading zero and a national id above two to the fifty-third loses its last digits to a float.
> [!warn]
> Sending `anonymous_id` alongside `user_id` on `identify` does not attach the earlier event to this profile. The identity link is written only by `POST /v1/alias`, and even that writes one link row: events already stored keep an empty `user_id` for ever. So a funnel that starts with an anonymous view and ends with a signed-in purchase will not join the two. [Identity](/en/docs/identity) has the full picture and what can be done about it.
After this request a profile with the id `u_123` exists. Before it there was none: an anonymous visitor gets no profile row at all.
## The same call from JavaScript {#javascript}
Now that you have seen an event with your own eyes, the SDK.
The `@segmentic/web` package is not published on any registry and `npm install @segmentic/web` fails. What works today is the script tag, served from the same host the events go to, so your content security policy needs only one origin:
```html title="at the end of the site's head"
```
Three things that otherwise look like a broken install:
- The SDK does not send immediately. It waits for twenty messages or ten seconds, whichever comes first. `await Segmentic.flush()` empties the queue at once, but it posts to `POST /v1/batch`, so the result does not show on the Live events page. Confirm arrival from the Check step on the Connect page.
- `init` sends a page view by itself, because `autoPageView` defaults to on.
- If the browser has Do Not Track set, nothing is sent at all, because `respectDoNotTrack` defaults to on. This is the first thing to check when no events arrive.
The rest of the methods, the offline queue and browser push are in [the web SDK](/en/docs/sdk-web).
## The same call from a server {#server}
The same write key works from a backend. In Python:
```python title="sending from Python"
import requests
response = requests.post(
"https://in.segmentic.net/v1/track",
headers={"Authorization": "Bearer wk_seg_..."},
json={
"message_id": "qs-3",
"event": "order_completed",
"user_id": "u_123",
"properties": {"revenue": 2500000, "currency": "IRR"},
},
timeout=10,
)
print(response.status_code, response.json())
```
The output:
```text
200 {'status': 'ok', 'accepted': 1}
```
`order_completed` is one of the eleven standard names, and it is what the ready-made funnels and journeys are built on. Revenue is read from that property; failing that, `total`, then `value`, then `price` multiplied by `quantity`. The currency defaults to `IRR` when it is not stated, and no conversion is ever guessed.
For an event only your backend is certain of there is a second door: `POST /v1/events` on `https://api.segmentic.net` with an `sk_seg_` key. That door differs in two ways and both are silent: it does not de-duplicate, so the same `message_id` sent twice becomes two events; and its window is a fixed thirty days, so anything older is clamped to the thirty-day edge and the warning about it is discarded. That is why history must not be migrated through it. The differences are in [server to server](/en/docs/server).
## What to read next {#next}
- [Concepts](/en/docs/concepts), if you want to know exactly how a segment, an audience and a journey differ.
- [Designing events](/en/docs/events), before you settle on event names. A wrong name is not fixed later.
- [Placing events](/en/docs/instrument), to get from this one test event to the real events of your own site or app.
- [Identity](/en/docs/identity), if you have anonymous visitors who later sign in.
- [The web SDK](/en/docs/sdk-web) or [the Android SDK](/en/docs/sdk-android) for a real installation.
- [Ingest endpoints](/en/docs/api/ingest) for the rest of the routes: batching, device registration, the inbox.
- [Errors](/en/docs/errors) and [limits](/en/docs/limits) when you want the sending path to survive a bad day.
---
# Concepts: events, profiles, segments, campaigns, journeys
> The ten ideas the rest of the documentation stands on, one paragraph each, named the way the panel and the API name them.
> https://segmentic.net/en/docs/concepts
This page is ten words and no more. Each one is a specific thing in the product, and the name is the same in the panel, in the API and in this documentation. If an AI agent is going to work against this service, this is the first page it should read.
Where this page says something does not exist, it does not exist. It is not a gap in the documentation.
| Concept | In the panel | On the wire and in Persian |
|---|---|---|
| event | Events | رویداد |
| profile | Profile, and Users in the navigation | پرونده |
| trait | Trait | ویژگی |
| segment | Segments | سگمنت |
| audience | Audience | مخاطب |
| campaign | Campaigns | کمپین |
| journey | Journeys | سناریو |
| channel | Channels | کانال |
| write key | Write key | کلید نوشتن |
| API key | API keys | کلید API |
## Event {#event}
An event is something that happened once, at a moment, to one identity. It has a name, a time, and it can carry properties. There are five message types and the request path decides which one you get: `track`, `identify`, `page`, `screen` and `alias`. There are two places you write the type yourself: the items of `POST /v1/batch` on the ingest host, and the items of `POST /v1/events` on the management host. In both, a type we do not recognise rejects that item with `unknown_type`.
An event name is at most 128 bytes after Persian normalisation and may hold no control characters. There is no allow-list, no case folding and no snake_case enforcement, and a Persian name is accepted. The direct consequence is that `Order Completed` and `order_completed` stay two different events for ever, and nothing warns you. Eleven standard names exist, and they are what the ready-made funnels and journeys are built on: `product_viewed`, `product_added_to_cart`, `product_removed_from_cart`, `cart_viewed`, `checkout_started`, `order_completed`, `order_refunded`, `order_cancelled`, `searched`, `signed_up` and `signed_in`.
At most 256 properties are kept per event. Keys are normalised: whitespace, full stops and hyphens all become underscores. Values land in two places, a string map and a numeric map, because a map in the warehouse has to be of one type. A `null` value is not stored at all, because "not set" and "the empty string" are different things, and if they were the same, an `is not set` filter would give the wrong answer.
What an event is not: it is not a state. "This user now has a gold subscription" is a trait; "this user bought a gold subscription" is an event. A stored event is also not editable. There is no endpoint to edit or delete one, and three things remove a row: your tenant's retention policy, the events table's own 400-day TTL, and the erasure of one named person.
## Profile and trait {#profile-and-trait}
A profile is one row per `user_id` per account, folded together from that user's events. In the panel's navigation the section is called Users, and the pages themselves say profile. A trait is something that is true about that person: an email address, a city, a balance, a signup date. Traits arrive on `identify`.
A few traits behave specially. `email` is trimmed and lower-cased and nothing else. `phone` is converted to E.164 and a derived `phone_operator` is written beside it. `national_id` is dropped entirely when its check digit fails. `gender` is mapped to one of `male`, `female` or `other`. Every other trait is free-form and is written twice, once as a string and once as a number. Only the numeric half answers a "greater than" condition. That double write came out of a real failure in which "balance of 100 or more" returned nobody and "less than 10" returned all 114,943 profiles including one holding 428, with no error and no warning.
Not mentioning a trait means "leave it alone". Sending an empty string also means "leave it alone" rather than "delete it": the empty value is dropped before the event is built and never reaches the profile. The profile layer does carry a "clear this" rule and no customer-facing route reaches it, and a journey's update-trait node refuses an empty value by name. So there is no supported way to delete one trait, and the only real removal is the personal-data erasure flow, which takes the whole profile. A new profile starts with all three sending consents (email, SMS, push) switched on, because a customer base arriving on the platform has already consented through the customer's own flow, and defaulting to off means the first campaign reaches nobody.
What a profile is not: an anonymous visitor does not have one. No row is created until an event carries a `user_id`.
## Identity {#identity}
There are three identifiers. The SDK mints and keeps `anonymous_id`. Your own authentication supplies `user_id`. `previous_id` means something only on an `alias` message and lives for that one message. At least one of the first two is mandatory, and whichever is in force is the partition key, so every message about one person is seen in order.
An `alias` does not move anonymous history onto the user. It does exactly one thing: it writes a row into the identity map saying that this anonymous id belonged to this user. Events already stored are never rewritten, so those rows keep an empty `user_id` for ever, and no report joins the identity map. A funnel that starts with an anonymous `product_viewed` and ends with a signed-in `order_completed` does not see the two as one person. The one live consumer of the identity map is the personal-data erasure path, which uses it to find and delete that person's pre-signin events as well.
Call `reset()` on sign-out. Without it, the next person on a shared device inherits the previous person's anonymous id, and the new identity-map row replaces the old one. The consequence is that if the first person later asks to be erased, their anonymous events are not found.
## Segment {#segment}
A segment is the saved object: a named filter with an id that lives in the panel and that campaigns and journeys refer to. Its definition is not SQL, it is a JSON tree. The interface never sends SQL, and that boundary is what makes the feature safe to put in a marketer's hands.
Three kinds are defined in the database. `dynamic` is the default and stores nothing: the definition is run fresh every time somebody counts it or a campaign pages through it. `static` is a list somebody put people into, an agency's spreadsheet or the winners of a draw, and its membership is real rows. A `dynamic` segment needs a definition; a `static` one does not. The third kind, `realtime`, is accepted by both the API and the database constraint, and no part of the server implements it. Treat it as reserved rather than working.
What a segment is not: a dynamic segment has no stored membership and no refresh job. Its last size and last computed time are never written on a real install, so the card in the segment library permanently reads that the size has not been computed. A size is calculated at the moment you ask for it.
## Audience {#audience}
An audience is the set of people a send reaches. Choosing a campaign's recipients in the panel is exactly this. On the management API the same word names two stateless routes: `POST /v1/audiences/validate`, which only says whether your definition compiles, and `POST /v1/audiences/count`, which returns a number. Both take the definition in the body and store nothing.
The practical difference is this: a segment is the name of the thing you save, an audience is the name of the set of people a filter resolves to, whether or not that filter was saved. Both words describe one filter language.
The double naming is real in the product and we are not hiding it: the counting route is `/v1/audiences/count` while the saved object lives under `/v1/segments` and the panel calls it a segment. There is no estimate route on the management API. The panel's live counter calls `POST /v1/segments/estimate`, which is registered on the dashboard's control plane, and that port is deliberately not routed from the internet. From your own server the route that answers is `POST /v1/audiences/count`, and it counts exactly rather than sampling.
## Campaign {#campaign}
A campaign is one message to one audience, once or on a schedule. Its states are `draft`, `scheduled`, `running`, `paused`, `completed`, `cancelled` and `failed`. The last three are terminal and there is no way back from them.
On the management API you can list campaigns, read one, create one, send one, and submit one for approval. The list is the two hundred most recently updated campaigns and no more. That cap is not announced in the response: there is no count, no `has_more` and no cursor. An account holding a two hundred and first campaign does not see it on this surface and has no way to page past. The same is true of `GET /v1/segments`. Pause, resume and cancel are not on that surface at all; they exist only in the panel. A state change that matches no row answers 404, and the three separate causes (not yours, does not exist, wrong state) deliberately give one answer.
What a campaign is not: it is not a journey. A campaign resolves its audience once and walks through it. A journey holds state for each person separately.
## Journey {#journey}
A journey is a graph that each user walks alone, with their own state. The Persian word is سناریو, and it is the only one: the panel used to say ژورنی on a handful of dashboard screens, which read as a second concept rather than the same one.
There are eight node kinds: `trigger`, `wait`, `condition`, `switch`, `split`, `action`, `goal` and `exit`. The entry rule is one of `once`, `every_time` or `max_n`. Exit criteria remove somebody the moment the criteria match, wherever in the graph they are. Published versions are immutable, and their segment definitions are copied inline rather than referenced, so a published version means today what it meant on the day it was published.
What a journey is not: there is no journey route on the management API at all. `GET /v1/capabilities` reports a `journeys` key under `features`, but that flag says only whether this deployment has the journey subsystem wired, and even when it is true it turns on no route on that host. None of the `features` flags is a fixed value: they differ from one installation to the next, so ask for them at runtime rather than writing them into your code. Building, publishing and pushing people in are all panel-only operations.
## Channel {#channel}
A channel is the route a message takes. The strings that exist are `push` (mobile push), `webpush` (browser push), `sms`, `email`, `inapp` (the in-app inbox), `messenger`, and the three messengers `bale`, `eitaa` and `rubika`.
You do not write the three messengers on a campaign directly: each is promoted to `messenger`, and at send time it is resolved per recipient. Whether a channel is available is computed from account configuration alone, with nothing dialled: `email`, for instance, is available when both an SMTP host and a from-address are set. An unavailable channel is listed with its reason rather than hidden.
One hole, stated plainly: `webhook` is accepted as a channel on a campaign, and no sender was ever built for it. A campaign authored on `webhook` fails for the whole audience. If what you want is a journey calling your own service, that is an action kind inside a journey, not this channel.
## Write key against management key {#keys}
There are two kinds of key and neither works where the other one does.
| | Write key | API key |
|---|---|---|
| Prefix | `wk_seg_` | `sk_seg_` |
| Host | `https://in.segmentic.net` | `https://api.segmentic.net` |
| Where it lives | inside the public code of a site or app | server side only |
| What it can do | write events, register a device, subscribe to push, fetch on-site campaigns | whatever its role and permissions allow |
| What it cannot do | read a profile, mint another key | be used on the ingest host |
| Where it is created | SDK Setup | Settings, then API keys |
Both are shown in full exactly once and only a hash is stored afterwards. The two prefixes are separate so that secret scanners recognise a leaked key, and so that the severity of a leak can be judged at a glance.
A write key sent to the management API answers 401 with its own code, `write_key_rejected`, and a message saying that this is an SDK key and this API needs a management key. It has a dedicated code because the mistake is common and a generic answer sends people looking for the wrong problem. The reverse mistake has no dedicated code.
Permissions on an API key come from its role. A key with the `owner` role cannot be created at all, so no key can ever transfer or delete an account. The column that would narrow a key below its role exists in the database and no code writes it, which means every key the product creates today carries its whole role, and `scoped` in the `GET /v1/whoami` response is always `false`.
The last difference is consumption. The management API gives each key a weighted budget per calendar minute, 600 units by default: a `whoami` costs one unit and a retention report costs twenty-five. The ingest host has no rate limiting at all.
---
# Designing events: what to send and what to call it
> Naming rules, choosing properties and building a stable tracking plan for reports and segments.
> https://segmentic.net/en/docs/events
An event is something a person did. It has a name, a moment, and a few properties that were true at that moment. Everything you build later, every segment, every campaign, every funnel, stands on those three things, and some of the decisions cannot be taken back. This page says exactly what the code does with what you send. If you are after the practical procedure instead, where to put the call in your own code and how to check that it arrived, that is [placing events](/en/docs/instrument).
> Diagram: How events move from web, mobile and server SDKs into profiles, segments and journey triggers
## What an event is, and is not {#what-an-event-is}
An event is not a state. "This user's wallet holds 120,000 toman" is not an event; it is a trait, and it goes through `identify`. "This user topped up their wallet by 120,000 toman" is an event. The difference is worked through in [a property or a trait](/en/docs/events#property-or-trait), because that is where most of the mistakes happen.
An event does not change after it is written. There is no endpoint to edit or delete one. Three things remove a row: your tenant's retention policy, the events table's own 400-day TTL, and the erasure of one named person. So every decision on this page applies to the data arriving from now on, not to the data that arrived yesterday.
Bot traffic is not thrown away. It is stored with `is_bot` set to one, and every report filters it out by default. Had we discarded it, nobody could later prove what actually happened that day.
An event must carry at least one of `user_id` or `anonymous_id`, or it is rejected with `missing_identity`. Both are capped at 256 bytes.
## The five message types {#message-types}
There are five message types and each has its own path. The name that gets stored does not always come from you:
| Path | Type | Name stored |
|---|---|---|
| `POST /v1/track` | track | the `event` you sent. Required |
| `POST /v1/page` | page | the `event`, defaulting to `page_viewed` |
| `POST /v1/screen` | screen | the `event`, defaulting to `screen_viewed` |
| `POST /v1/identify` | identify | always `identify`. Anything in `event` is discarded |
| `POST /v1/alias` | alias | always `alias`. As above |
| `POST /v1/batch` | any of the five | each item carries its own `type` |
On the five single-event paths the route decides the type and the body's `type` field is ignored. Post `{"type":"identify"}` to `/v1/track` and a track event is stored. That is deliberate: otherwise one mistyped line in your code would have an identify payload silently accepted as an event.
Inside `/v1/batch` the rule is inverted, because the path is one and the items differ: each item's `type` is read, and an unknown one rejects that item with `unknown_type` rather than the whole batch.
The traits on an `identify` are not stored on the events table. The `identify` event lands there with empty properties, and the trait values go only to the person's profile. If you want an event report to show what changed, send a separate track as well.
```bash title="One complete event"
curl -X POST https://in.segmentic.net/v1/track \
-H "Content-Type: application/json" \
-H "Authorization: Bearer wk_seg_..." \
-d '{
"message_id": "9f1c2b70-3a4d-4f2e-9a1b-0c7d8e5f6a21",
"user_id": "u_88123",
"event": "order_completed",
"timestamp": "2026-08-07T09:14:22.310Z",
"properties": {
"order_id": "A-100294",
"revenue": 2450000,
"currency": "IRR",
"item_count": 3,
"payment_method": "gateway"
},
"context": {
"locale": "fa-IR",
"timezone": "Asia/Tehran",
"library": { "name": "segmentic-js", "version": "1.0.0" }
}
}'
```
```json title="Response"
{"status":"ok","accepted":1}
```
The write key may arrive as `Authorization: Bearer wk_seg_...`, as the header `X-Segmentic-Key`, or as the query parameter `?write_key=`. The last exists only for `sendBeacon` and image beacons, which cannot set headers.
Omit `message_id` and the server generates one, and says so:
```json title="Response when you sent no message_id"
{"status":"ok","accepted":1,"warnings":[{"code":"generated_message_id","field":"message_id","note":"no message_id sent; retries of this event cannot be de-duplicated"}]}
```
That warning is serious. The `message_id` is the only thing that makes a retry safe: an SDK on an Iranian mobile network will resend, and without a stable id a customer's purchase count silently doubles. De-duplication is per tenant, so two tenants may legitimately use the same `message_id`.
## The naming rule {#naming}
This is the whole rule, and it is shorter than you expect. The event name is trimmed, then run through Persian normalisation (see [Persian text](/en/docs/events#persian-text)), and then exactly two things are checked:
| Rule | Limit | Reject code |
|---|---|---|
| length after normalisation | 128 bytes | `event_name_too_long` |
| Unicode control characters | none allowed | `event_name_invalid_chars` |
| a name on a track | required | `missing_event_name` |
That is all. **No lower-casing, no snake-casing, no character class filter.** Spaces, slashes, question marks, Persian letters, emoji, mixed case and a whole URL are all legal event names.
Two consequences that catch people out:
- `Order Completed`, `order_completed` and `order completed` are three separate events, for ever. Nothing merges them later.
- The limit is 128 **bytes**, not 128 characters. A Persian letter is two bytes in UTF-8, so a Persian event name may be about 64 letters long.
The convention we recommend, and the one the rest of these docs are written in: lower case ASCII, `snake_case`, past tense, object before verb. `order_completed` rather than `completeOrder`, `product_viewed` rather than `View Product`. The reason is not tidiness: the event name in the panel, in the segment builder, in a journey trigger and in a CSV export is byte for byte the string you sent, and a list written in three different styles is a list nobody can pick from.
The server places no limit on the **number** of distinct names you create. Take that seriously, because the next section is about exactly that.
If the names in your integration are already something else, `chk_out_v2` or `SUB_RENEW`, you do not have to change what your code sends to make the panel readable. The panel keeps a display name per event, set only from Data Management under "Events". It is a display name only: what the SDK sends, what a segment definition refers to and what an export contains never change, so a rename cannot break a saved segment or a funnel. Setting one needs `settings.write`.
## When the event name is a URL {#url-shaped-names}
Never build an event name from a variable. That sounds abstract until you see how it happens.
The web SDK's `page(name)` names the event after the exact string you hand it. One customer handed it `pathname + search`. The event name became the whole address, and every referral code and every profile id minted a new event type. The real numbers from that account:
- 169 distinct URL-shaped names across 5,618 rows.
- The catalogue reached 168 names in a day: 153 of them URLs and 15 of them real events.
- "Opened the games page" was spread across 46 of those names: `/games/`, `/games/?ref=GPE9UHTV`, `/games/?utm_source=ecrm&utm_medium=inapp_banner`, `/u/69235/`, and the rest.
- All 169 collapse to 23 routes once reduced to their route.
Why this is not merely untidy but loses data: the catalogue (`GET /v1/schema/events`) returns at most 500 names, ordered by volume descending, over the last 90 days only. A tenant in this state loses its real event names off the end of the list. The marketer cannot build the "bought something" segment, because the event name is not in the picker.
The fix: name the page view after the **route**, and let the address live where the address belongs. With `autoContext` on, the web SDK already attaches `context.page.url`, `context.page.path` and `context.page.search` to every message, and the collector stores the first two in the `page_url` and `page_path` columns. The exact address is not lost.
```js title="Name the route, not the address"
import { init, page } from "@segmentic/web";
init({
writeKey: "wk_seg_...",
apiHost: "https://in.segmentic.net",
// Off, because the automatic page view fires on init and would land
// beside the route-named one below.
autoPageView: false,
});
function routeNameOf(pathname) {
return pathname
.replace(/\/$/, "")
.replace(/\/u\/\d+/g, "/u/:id")
.replace(/\/(\d+)(?=\/|$)/g, "/:id") || "/";
}
// On the first load, and again on every client-side navigation.
page(routeNameOf(location.pathname));
```
One note about `autoPageView`, where the SDK's own type says something different. The type comment reads "send a page view on init and on history navigation". The code sends one on init only. The only listeners the web SDK installs are `visibilitychange`, `pagehide` and `online`, and there is no `popstate` handler and no `pushState` patch anywhere in the source. So **a single-page app must call `page()` itself on every navigation**, or a whole user session records one page view.
If you are already in this state, the script `deploy/scripts/rename-url-event-names.sh` on the branch `chore/rename-the-url-shaped-event-names` does it for the rows already on disk. It drops the query string, drops a trailing slash, replaces four named dynamic routes, and then turns any remaining digit segment into `:id`. It reports by default and acts only with `--apply`.
> [!danger]
> That branch is not merged into `main`, so the script is not present on a standard install. More importantly, what it does is an `ALTER TABLE ... UPDATE`, and that cannot be undone, because no previous value is kept anywhere. The script itself counts rows with no `page_url` before `--apply` and refuses if there is even one, because for those rows the address exists only inside the name.
## Choosing properties {#choosing-properties}
Put on the event what was true **at that moment**. The amount actually paid, the code that gave the discount, the category the product sat in. If the product's price changes tomorrow, yesterday's event still shows yesterday's price, and that is the only way "revenue last month" means anything.
Three things do not belong on an event:
- Anything the client already sends. `context` carries device, OS, app version, page, campaign, language and timezone. A property called `os_name` is a second and worse copy of a column that already exists.
- Tokens, passwords and card numbers. Properties are shown verbatim to your support team on the user timeline, and they appear in the CSV export.
- Anything that has one value per person and changes, such as loyalty tier. That is a trait.
Property keys are normalised too, and you should know how, because the key is what you will type into a filter later:
| Input | Stored key |
|---|---|
| `" spaced key "` | `spaced_key` |
| `"dotted.key"` | `dotted_key` |
| `"dashed-key"` | `dashed_key` |
| `"multi space"` | `multi_space` |
| `"_leading_"` | `leading` |
| `""` or `" "` | dropped |
So: whitespace runs, full stops and hyphens each collapse to a single `_`, leading and trailing `_` are trimmed, control characters are dropped, and the key is truncated to 128 bytes. But keys are **not lower-cased and not Persian-normalised**. `Price` and `price` are two different properties.
## What a property may hold {#property-types}
A ClickHouse `Map` is homogeneous, so every property is written to one or both of two maps: `props_str` for text and `props_num` for numbers. The full table:
| JSON value | In `props_str` | In `props_num` |
|---|---|---|
| `null` | not written | not written |
| string | Persian-normalised, truncated to 8192 bytes | not written |
| `true` or `false` | `"true"` or `"false"` | `1` or `0` |
| number | the number as text | the number |
| array or object | the JSON as a string, truncated to 8192 bytes | not written |
| anything that fails to marshal | not written | not written, plus the warning `unserialisable_property` |
A `null` means "not set". Storing it as `""` would make an "is not set" filter answer wrongly.
An integral number keeps no trailing zero: `1234` is stored as the string `"1234"`, not `"1234.0"`. Without that, an order id travelling as a JSON number would no longer join against the customer's own system.
```json title="What you send"
{
"type": "track",
"user_id": "u_88123",
"event": "order_completed",
"properties": {
"str": "hello",
"num": 42.5,
"int_like": 1234,
"bool_t": true,
"bool_f": false,
"nil": null,
"arr": [1, 2],
"obj": { "a": 1 }
}
}
```
```text title="What is stored"
props_str = { str: "hello", num: "42.5", int_like: "1234",
bool_t: "true", bool_f: "false",
arr: "[1,2]", obj: "{\"a\":1}" }
props_num = { num: 42.5, int_like: 1234, bool_t: 1, bool_f: 0 }
```
`nil` is in neither. `arr` and `obj` are text, and still filterable with ClickHouse's own JSON functions. Nothing is flattened and no depth is lost.
The most important consequence of that table: **a numeric-looking string never becomes a number.** Send `"price": "2450000"` and `props_num` never gets `price`, so a "greater than" filter on it finds nothing, ever. This is deliberate: parsing `"01234"` would throw away the leading zero of a postcode. If you mean a number, send a JSON number. The same holds for Persian digits: `"۲۴۵۰۰۰۰"` is a string and stays a string.
There is no type registry. Every event is normalised on its own, and nothing in the ingest path remembers what type this key was last time. So sending `price` as a number and then as text produces neither an error nor a warning. Some rows simply have it in `props_num` and some do not.
## Limits, and what happens when you cross one {#property-limits}
| Thing | Limit | On breach |
|---|---|---|
| properties per event | 256 | the first 256 are kept, the rest dropped, warning `too_many_properties` |
| property key length | 128 bytes | truncated |
| property value length | 8192 bytes | truncated, on a UTF-8 rune boundary |
| traits per message | 256 | the loop stops, warning `too_many_traits` |
| items per batch | 500 | the whole request is rejected with `batch_too_large` |
| body size | 5 MiB | HTTP 413 |
| event name length | 128 bytes | rejected with `event_name_too_long` |
| `user_id`, `anonymous_id`, `message_id` | 256 bytes | rejected with `id_too_long` |
| `session_id` | 256 bytes | truncated, not rejected |
| `previous_id` on an alias | no limit | nothing checks its length; only the 5 MiB body cap bounds it |
| page URL, path and referrer | 2048 bytes | truncated |
| nesting depth | no limit | arrays and objects are JSON-stringified whole |
Two details you only meet once it is too late:
**"The first 256" is in no defined order.** Go map iteration is randomised, so if you send 300 properties, which 44 are dropped differs from one event to the next. The result is a half-filled column that reads in a report like poor data rather than like a bug. The warning note gives the exact figures: `300 properties sent, keeping 256`.
**A property whose value is `null` consumes one of the 256 slots and stores nothing.** Traits do not behave that way: there a nil value is skipped before it is counted.
Truncation never splits a character. If it did, the string would be invalid UTF-8, ClickHouse would reject the whole batch, and one bad string would stall an entire partition.
## Revenue, and how it accrues without being asked {#revenue}
There is one behaviour here to understand before you design your events: **revenue is extracted from every event, not only from orders.**
Extraction runs in this order, and the first non-zero value wins:
1. `props_num["revenue"]`
2. `props_num["total"]`
3. `props_num["value"]`
4. and if none of those: `props_num["price"] × props_num["quantity"]`, with `quantity` defaulting to 1 when absent or not positive.
The currency comes from `props_str["currency"]`, upper-cased and truncated to 8 bytes, defaulting to `IRR`. `IRT` is a recognised value, but **nothing converts between rial and toman**. Whatever number you send stays in the unit you declared, and every total assumes you stayed consistent.
Now the expensive part: the profile accrues revenue from any event carrying it.
| Event name | Effect on the profile |
|---|---|
| `order_completed` | adds to `total_revenue`, increments `order_count`, moves `last_order_at` forward |
| `order_refunded` and `order_cancelled` | subtracts the absolute amount from `total_revenue` (floored at zero) and decrements `order_count` (floored at zero) |
| any other name | adds the amount to `total_revenue` |
So a `product_added_to_cart` carrying `price` and `quantity` adds to that person's lifetime value right now, with no order recorded anywhere. A "VIP customer" segment built on `total_revenue` fills up with people who only ever filled a basket.
> [!warn]
> Use the keys `revenue`, `total`, `value`, and the pair `price` with `quantity`, only on an event where money actually moved. For everything else pick another name: `unit_price`, `cart_total`, `estimated_value`. These keys are reserved and the code gives them meaning.
`order_id` and `product_id` are also declared as reserved keys, but no code reads them. They are ordinary properties.
## Names the platform gives meaning to {#reserved-names}
The platform declares eleven standard commerce names. Only three of them have behaviour (the section above); the rest are ordinary names whose only privilege is a Persian label in the panel:
`product_viewed`, `product_added_to_cart`, `product_removed_from_cart`, `cart_viewed`, `checkout_started`, `order_completed`, `order_refunded`, `order_cancelled`, `searched`, `signed_up`, `signed_in`.
Do not read more into "the default picker" than is there. Until the tenant's real schema loads, the segment builder offers exactly ten fixed names and no more: `order_completed`, `product_viewed`, `product_added_to_cart`, `checkout_started`, `order_refunded`, `searched`, `app_opened`, `signed_up`, `message_opened` and `message_clicked`. The other standard names, `cart_viewed`, `product_removed_from_cart`, `signed_in` and `order_cancelled` among them, are not in that placeholder list. Once the schema arrives the list is built from your own events, and these ten only supply a label.
Four names are produced by the server and you cannot override them: `page_viewed` and `screen_viewed` when a `page` or `screen` message carries no name, and `identify` and `alias`, which are always fixed.
A few more are written into your event stream by Segmentic's own workers, so that campaign interaction is queryable with exactly the same tooling as app events. Do not send these yourself:
| Name | Written by |
|---|---|
| `message_sent` | the delivery dispatcher, on a successful send |
| `message_failed` | the delivery dispatcher, on failure |
| `message_withheld` | the delivery dispatcher, control group only |
| `message_opened` | the open-tracking endpoint |
`message_withheld` deserves its own explanation: somebody was chosen for the campaign and deliberately not messaged. The row exists so that uplift has a baseline, because without a row saying "we picked this person and stayed quiet" there is nothing to compare the treated group against. Every other kind of suppression stays out of the stream on purpose, because recording those as sends would inflate every campaign's reach by exactly the people it did not reach.
`message_delivered`, `message_bounced` and `unsubscribed` are declared as constants and nothing else in the backend uses them: no worker writes them and nothing reads them, so a report built on `message_delivered` comes back empty. The panel asks for a delivery event anyway. Its messaging dashboard template and the starter metrics for delivery rate, open rate and click rate each need one mapped to a name from your own event schema, and activation does not proceed until you name one, so unless you send a delivery event yourself, that dashboard and those three metrics are never built.
What is missing is the event, not the data. A campaign report counts its own delivered messages from the receipts a transport sends back, the deliverability service reads bounces off the SMTP return path and writes them to the suppression list, and an unsubscribe lands on the person's record in the preference centre and comes back as the suppression reason on the next send. None of that arrives as a row in your event stream.
The attribution worker recognises exactly four names, `message_sent`, `message_withheld`, `message_opened` and `message_clicked`, and skips every other event it is handed. `message_clicked` is not written by a worker either: the [web SDK](/en/docs/sdk-web) raises it when a visitor lands on a link carrying `sg_mid`, once, until a different message id replaces it. The comparison is against the one id in storage, not against a history of them, so a visitor who lands on one message, then a second, then the first one again reports the first click twice.
> [!danger]
> Six names, `app_installed`, `app_opened`, `app_updated`, `app_removed`, `session_started` and `session_ended`, are declared in the code as "lifecycle events the SDKs emit automatically". **No SDK emits them.** Not the web SDK, not Android, not iOS; a search across all three finds zero emitters. `app_opened` is also in the panel's default event list and carries a Persian label, which makes it look automatic. If you want these events, you have to send them yourself.
## A property or a trait {#property-or-trait}
This is where most of the mistakes happen, and the mistake does not show itself for months.
| | Event property | Profile trait |
|---|---|---|
| How it is sent | `properties` on track, page or screen | `traits` on identify |
| Where it lands | on that one event row | on the person's profile, one value each |
| Keeps history | yes, every event holds its own copy | no, the newest value replaces the old one |
| Works for an anonymous visitor | yes | no, no profile exists until there is a `user_id` |
| What it is good for | "ordered more than 500,000 in the last 30 days" | "is currently on the gold tier" |
A precise example: `city` as a property on `order_completed` means "this order shipped to Tehran". The same `city` as a trait means "this person lives in Tehran now". The first never changes; the second is replaced on every identify and the old value is gone.
Three things follow from that difference, and you hit all three in the segment builder:
**Known traits are lifted out of the trait map.** `email`, `phone`, `first_name`, `last_name`, `gender`, `birthday`, `national_id`, `city`, `region`, `country`, `language`, `timezone`, `push_opt_in`, `email_opt_in` and `sms_opt_in` become real profile columns and are not also written into the free-form `traits` map. So they do not appear in the response of `GET /v1/schema/traits`. Your `email` trait has not vanished; it is a column.
**A numeric filter on a trait does not select people who lack it.** The segment compiler wraps the condition in `mapContains`. The failure direction was chosen deliberately: an audience that is silently empty is a campaign that does not go out and somebody notices; an audience that is silently everybody is a campaign that went to everybody and cannot be taken back.
**A numeric filter on an event property has no such guard.** The filter compiles against `props_num` with no `mapContains`, and a ClickHouse map yields the zero value for a missing key. So "price under 10,000" also matches events that never carried `price` at all. If you do not send the property on every event of that name, add an "is set" condition beside it.
The "is set" check for a property tests `props_str`, which is safe: every numeric value is always written to both maps, so `props_str` is the larger set.
## Persian text {#persian-text}
Every user-supplied string goes through `Normalize` before it is stored. These are the places it runs:
- the event name
- every string property value
- every custom trait value
- `context.location.country`, `.region` and `.city`
- `context.page.title`
And these are the places it does **not** run: property keys, trait keys, `user_id`, `anonymous_id`, `message_id`, `session_id`, page URL, path and referrer, and UTM values. `email` is only lower-cased, `phone` is parsed separately, and `national_id` only has its digits converted to ASCII.
What it does:
| From | To |
|---|---|
| Arabic yeh `ي`, alef maksura `ى`, `ے` | Persian yeh `ی` |
| Arabic kaf `ك`, `ڪ` | Persian keheh `ک` |
| `ة`, `ۀ` | `ه` |
| `ؤ` | `و` |
| `أ`, `إ`, `ٱ` | `ا` |
Beyond that, diacritics, tatweel and the zero-width joiner are stripped entirely, and every run of whitespace (including the non-breaking space, the Unicode spaces, and the byte-order mark) collapses to one plain space and is then trimmed from both ends. A paste out of Word or out of a right-to-left editor drags most of those in.
What is deliberately left alone:
- The zero-width non-joiner. `میرود` is a different word from `میرود` to a reader.
- Latin letter case. `Digikala` stays `Digikala`.
- Persian digits. `۱۴۰۵` stays `۱۴۰۵` and is not converted to ASCII.
- The zero-width space, alef with madda, yeh with hamza, and the standalone hamza.
The practical result: `تهراني` typed on an Arabic keyboard and `تهرانی` typed on a Persian one become one value and land in one segment. But `Tehran` and `tehran` do not, and `۱۲۳` never becomes `123`.
## Phone, national id and gender {#identity-traits}
Four traits get bespoke handling, because they are the person's identity and storing them in whatever shape they arrived guarantees two profiles for one human.
**`phone`** is parsed by `ParsePhone` and stored in `E.164` form, plus a derived trait called `phone_operator`. The algorithm: Persian and Arabic digits to ASCII, then keep only digits and a leading `+`, then strip the country prefix, and what remains must be exactly ten digits beginning with 9.
```text title="Shapes that all produce the same result"
09123456789 9123456789 +989123456789 00989123456789
989123456789 0912 345 6789 0912-345-6789 (0912) 345 6789
۰۹۱۲۳۴۵۶۷۸۹ ٠٩١٢٣٤٥٦٧٨٩
result: phone = "+989123456789" phone_operator = "mci"
```
```text title="Shapes that are refused"
"" "abc" "0812345678" "091234567" "091234567890"
"+981234567890" "12345" "+1234567890" "0000000000"
```
When parsing fails, the raw value is stored exactly as given and the warning `invalid_phone` comes back; `phone_operator` is not written. It is not dropped, because a badly shaped number is still the only way to reach that person.
The operator comes from the first four digits of the national form, the `09XX` shape:
| Operator | Prefixes |
|---|---|
| `mci` (Hamrah-e Aval) | 0910 to 0919, 0990 to 0997, 0999 |
| `irancell` | 0900 to 0905, 0930, 0933, 0935 to 0939, 0941 |
| `rightel` | 0920 to 0923 |
| `shatel` | 0998 |
| `samantel` | 0931 |
| `unknown` | anything else, for example 0906, 0932, 0934 |
**`national_id`** is checked with the standard mod-11 check digit. The length must be 8 to 10, and anything shorter than 10 is left-padded with zeros, because a leading zero is routinely lost in a spreadsheet. That padding is only for the check-digit arithmetic. What gets stored is the value you sent, with only its digits converted to ASCII, so `12345679` stays eight characters long. An all-identical id such as `1111111111` is refused even though it passes the checksum. If it is invalid the trait is **dropped entirely** and the warning `invalid_national_id` comes back. An invalid phone is kept as you sent it; an invalid national id is not kept at all, because enterprise customers key their CRM on this field and one wrong value creates a phantom profile.
**`email`** is trimmed and lower-cased. No format validation happens at ingest.
**`gender`** is mapped to exactly one of `male`, `female` or `other`. `male` comes from `m`, `male`, `man`, `مرد`, `اقا`, `پسر`; `female` from `f`, `female`, `woman`, `زن`, `خانم`, `دختر`; anything else becomes `other`. The comparison runs through `Fold`, so `آقا` also reaches `male`.
Every other trait, including `city`, `first_name`, `birthday`, `language` and the consent keys, gets Persian normalisation and truncation only.
## The cardinality guard {#cardinality}
Three things must be kept apart, because only one of them is actually guarded.
**What is guarded: the metric label.** When an event is rejected, what gets counted as the reason is one of the stable codes, not the full error text. The full text contains the event type or the timestamp the caller chose, and that text used to become the Prometheus label directly: anyone holding a write key could mint a new time series per request by varying a name. That is unbounded memory, and it degrades monitoring first, which is the thing you need working in order to notice. Now 300 distinct invalid types in one batch collapse to a single label. The response body still gives you the full text and the offending value.
**What is not guarded: the number of event names you create.** There is no counter and no ceiling. The only effect you will see is the catalogue truncating at 500 names.
**What is not guarded: the number of distinct values a property takes.** There is no cap there either. Sending a session id or an order number as a property value is correct and fine. Sending the same thing as an **event name** is the subject of [URL-shaped names](/en/docs/events#url-shaped-names).
## Seeing what actually arrived {#what-arrived}
Three ways, and each tells you something different.
**The response body.** Warnings come back immediately. The complete list of warning codes: `generated_message_id`, `timestamp_in_future`, `timestamp_too_old`, `too_many_properties`, `unserialisable_property`, `too_many_traits`, `invalid_phone`, `invalid_national_id`. On `/v1/batch` the collector stops collecting warnings once it is already holding 50, and it makes that test before appending a whole item's warnings, so a response can carry 49 plus everything the next item raised. The counts stay exact.
```json title="A batch with one bad item"
{"status":"ok","accepted":2,"rejected":1,"errors":[{"index":1,"reason":"missing_event_name"}]}
```
One bad item does not sink the batch. Rejected items are reported by their index in your array. Note that `accepted` includes duplicates, so that the SDK stops retrying them.
**The catalogue.** `GET /v1/schema/events` on `api.segmentic.net`, with a management key holding `event.read`:
```bash title="The event catalogue"
curl -H "Authorization: Bearer sk_seg_..." \
https://api.segmentic.net/v1/schema/events
```
```json title="Response"
{
"events": [
{ "name": "product_viewed", "volume": 88000000, "prop_keys": ["product_id","price"], "last_seen": "2026-08-06" },
{ "name": "order_completed", "volume": 4200000, "prop_keys": ["revenue","order_id"], "last_seen": "2026-08-07" }
]
}
```
`last_seen` is the most useful column here: an event with a large volume and a last-seen three weeks ago is an integration that broke, and no other figure says so, because volume alone looks healthy for a month afterwards. The window is 90 days, the cap is 500 names ordered by volume, and `prop_keys` is the union of both maps' key names with **no type information at all**. So this endpoint cannot tell you that a property changed type.
Freshness follows the ingestor's flush cycle: every 10,000 events or every 5 seconds, whichever comes first. There is no separate refresh job and no cache in front of it.
**The live debugger.** In the panel you turn recording on and watch events arrive for 30 minutes, with their warnings. The last 200 are kept. Nothing is recorded until somebody opens the debugger.
> [!danger]
> The debugger only records the single-event paths. `POST /v1/batch` hands it nothing, and all three SDKs batch. So on a fresh SDK install the debugger stays empty while events are arriving perfectly well, and you conclude the integration is broken. To confirm an SDK install use the panel's connect screen, which asks for the app's activity over the last 24 hours, or send one event through `POST /v1/track` by hand.
> [!warn]
> The table `segmentic.ingest_warnings` exists in the schema and its comment says it holds each tenant's warning history. **Nothing writes to it.** So there is no warning history: a warning is visible only in the response body and in the live debugger. If you want to know that your Android SDK stopped sending `user_id` yesterday, you have to log the responses yourself.
## Decisions that cannot be undone {#hard-to-reverse}
Every row here is written once into the data, after which you can only fix things from that point forward.
| Decision | Why it does not reverse | What to do instead |
|---|---|---|
| The event name | Renaming means an `ALTER TABLE ... UPDATE` over existing rows, which keeps no previous value. The script is not in `main` either | Write the list of names down before the first send. There is a ready-made one in the [event dictionary](/en/docs/event-dictionary) |
| A property you did not send | There is no way to add a property to rows already written | Send the contextual properties from day one, even the ones you do not need yet |
| A number sent as a string | A numeric-looking string is never parsed, and later events do not repair earlier rows | Send a JSON number. Persian digits are a string too |
| Migrating history through `POST /v1/events` | That path has a fixed 30-day window, anything older is clamped to its edge, the status is 202 and no warning comes back in the body at all. A year of orders lands as one enormous day | Use the import path, which in backfill mode refuses rather than moving |
| A trait that changed type | Sending `"abc"` after `428` replaces the text half and leaves the numeric half at `428`, and there is no way to clear it from the ingest path either: an empty string value is discarded before the profile sees it | Keep a trait's type stable |
| An event sent by mistake | There is no endpoint that deletes one event | Only the retention policy, the table's own 400-day TTL and a per-person erasure remove a row |
Two details on the migration row. The 30-day window belongs to `POST /v1/events` alone; the collector on `in.segmentic.net` uses the tenant's own retention policy as its window, and when it does clamp it returns a `timestamp_too_old` warning in the body. And the import path (`POST /v1/import/events`) is not registered on the public API: it is reachable only from the panel, so migrating history is something a person does in a browser, not something a script does with a management key.
And one limit that is not your policy but the platform's ceiling: the events table carries a 400-day TTL. No event survives 400 days, whatever retention you configured. If you need longer, export.
Next: the [event dictionary](/en/docs/event-dictionary) has ready-made lists for six kinds of business, and [identity](/en/docs/identity) explains how `user_id` and `anonymous_id` are joined.
---
# An event dictionary for each kind of business
> Ready-made event and property lists for online retail, super-apps, fintech, travel, education and subscription media. Copy and start.
> https://segmentic.net/en/docs/event-dictionary
This page is written to be copied. Six kinds of business, and for each one a list of events with their properties that you can take as it stands and start with. The reasoning behind the choices is in [designing events](/en/docs/events); if you have not read it, read at least [revenue](/en/docs/events#revenue), because that is the one place where a property name changes a number on a person's profile. And to get this list actually running on your own site or app, see [placing events](/en/docs/instrument). And if you want to recommend products in your messages, the id you put in `product_id` here has to match [the product catalogue](/en/docs/catalog#the-id).
## How to read this {#how-to-read-this}
The "status" column in every table has two values and the difference matters:
- **Standard** means the platform declares this name and the panel gives it a Persian label. That is the whole of it.
- **Proposed** means the name is our convention. The platform does not know it and treats it like any other name. You may change it; just change it before the first send.
One common misreading, closed here: "standard" does not mean "in the event picker". Until the tenant's real schema loads, the segment builder shows ten fixed names (`order_completed`, `product_viewed`, `product_added_to_cart`, `checkout_started`, `order_refunded`, `searched`, `app_opened`, `signed_up`, `message_opened`, `message_clicked`) and after that it builds the list from your own events. The journey builder labels only eight. A standard name you have never sent appears in no list at all.
Three things to keep separate about the standard list:
**Only three names actually have behaviour.** `order_completed` adds the amount to the profile's `total_revenue`, increments `order_count` and moves `last_order_at`. `order_refunded` and `order_cancelled` subtract the absolute amount and decrement the order count, both floored at zero. The rest of the standard names have no behaviour at all.
**Standard means recognised, not automatic.** No event is sent by itself. The names `app_installed`, `app_opened`, `app_updated`, `app_removed`, `session_started` and `session_ended` are declared in the code as "lifecycle events the SDKs emit automatically", and no SDK emits them. If you want them, send them.
**Do not send the `message_*` names yourself.** Segmentic's own workers write them, and they are listed under [what Segmentic writes](/en/docs/event-dictionary#segmentic-writes).
## The names the platform knows {#names-the-platform-knows}
| Name | Panel label | What the code does |
|---|---|---|
| `order_completed` | خرید | raises the profile's revenue and order count, moves `last_order_at`, and is the `orders` figure in the daily rollup |
| `order_refunded` | مرجوعی | lowers revenue and order count, floored at zero |
| `order_cancelled` | لغو سفارش | the same as `order_refunded` |
| `product_viewed` | مشاهده محصول | nothing. A label |
| `product_added_to_cart` | افزودن به سبد | nothing |
| `product_removed_from_cart` | حذف از سبد | nothing |
| `cart_viewed` | مشاهده سبد | nothing |
| `checkout_started` | شروع پرداخت | nothing |
| `searched` | جستجو | nothing |
| `signed_up` | ثبتنام | nothing |
| `signed_in` | ورود | nothing |
| `app_opened` | باز کردن اپ | nothing, and no SDK sends it |
| `page_viewed` | مشاهده صفحه | the default name of a `page` message with no name |
| `screen_viewed` | none | the default name of a `screen` message with no name |
## The properties you put on every event {#properties-on-every-event}
Before the tables, five rules that are the same across all six businesses.
**Do not resend the context.** `context` already carries device, OS, app version, page, campaign and timezone, and the collector puts each in its own column. A `platform` property on every event is a worse copy of `device_type`.
**Send identifiers as strings.** `"order_id": "A-100294"`, not `"order_id": 100294`. A number is stored as text too, but a string joins against your own system from the start.
**Send numbers as numbers and booleans as booleans.** `"quantity": 3`, not `"quantity": "3"`. A numeric-looking string never reaches the numeric map, and a "greater than" filter on it never answers.
**Pick one language for property values and stay with it.** Values are stored as sent, with Persian normalisation only. `gold` and `طلایی` are two values and do not land in the same segment.
**Use `revenue`, `total`, `value`, and the pair `price` with `quantity`, only on an event where money moved.** Revenue is extracted from any event carrying those keys and added to that person's lifetime value. For an amount that is not real money, use another name. The tables below deliberately use `unit_price`, `cart_total` and `amount`, none of which is reserved.
## Online retail {#online-retail}
| Event name | Status | When to send it | Properties |
|---|---|---|---|
| `product_viewed` | standard | a product page opened | `product_id` string, `product_name` string, `category` string, `brand` string, `unit_price` number, `currency` string, `in_stock` boolean |
| `searched` | standard | a search was submitted | `query` string, `results_count` number, `sort` string, `filters` string |
| `product_added_to_cart` | standard | added to the basket | `product_id` string, `product_name` string, `unit_price` number, `quantity` number, `cart_size` number |
| `product_removed_from_cart` | standard | removed from the basket | `product_id` string, `quantity` number, `cart_size` number |
| `cart_viewed` | standard | the basket page opened | `cart_size` number, `cart_total` number, `currency` string |
| `checkout_started` | standard | the payment step was entered | `cart_size` number, `cart_total` number, `currency` string, `shipping_method` string, `coupon_code` string |
| `order_completed` | standard | payment succeeded | `order_id` string, `revenue` number, `currency` string, `item_count` number, `shipping_cost` number, `discount` number, `coupon_code` string, `payment_method` string |
| `order_refunded` | standard | a refund was approved | `order_id` string, `revenue` number meaning the amount returned, `currency` string, `reason` string |
| `order_cancelled` | standard | the order was cancelled before dispatch | `order_id` string, `revenue` number, `currency` string, `cancelled_by` string |
| `signed_up` | standard | registration completed | `method` string, `referral_code` string |
| `signed_in` | standard | a successful sign-in | `method` string |
| `order_shipped` | proposed | the parcel was handed to the carrier | `order_id` string, `carrier` string, `hours_since_order` number |
| `order_delivered` | proposed | the parcel reached the customer | `order_id` string, `carrier` string, `days_since_order` number |
| `review_submitted` | proposed | a review was posted | `product_id` string, `rating` number, `has_photo` boolean |
| `wishlist_item_added` | proposed | added to the wish list | `product_id` string, `unit_price` number |
| `back_in_stock_requested` | proposed | asked to be told when it returns | `product_id` string |
The default funnel this list supports: `product_viewed`, then `product_added_to_cart`, then `checkout_started`, then `order_completed`. The abandoned-basket segment is the same list: anyone with a `product_added_to_cart` in the last seven days and no `order_completed`.
```bash title="One complete order"
curl -X POST https://in.segmentic.net/v1/track \
-H "Content-Type: application/json" \
-H "Authorization: Bearer wk_seg_..." \
-d '{
"message_id": "b3d51f28-4c6a-4b1e-9d70-2f8a3c5e7d10",
"user_id": "u_88123",
"event": "order_completed",
"timestamp": "2026-08-07T10:02:41.000Z",
"properties": {
"order_id": "A-100294",
"revenue": 2450000,
"currency": "IRR",
"item_count": 3,
"shipping_cost": 49000,
"discount": 150000,
"coupon_code": "NOWRUZ",
"payment_method": "gateway"
}
}'
```
```json title="Response"
{"status":"ok","accepted":1}
```
## Super-app and ride hailing {#super-app}
| Event name | Status | When to send it | Properties |
|---|---|---|---|
| `app_opened` | standard | every time the app comes to the foreground. You have to send it | `is_first_open` boolean, `source` string |
| `service_selected` | proposed | the user opened one of the super-app's services | `service` string |
| `ride_requested` | proposed | a ride was requested | `ride_id` string, `service_class` string, `origin_city` string, `destination_city` string, `estimated_fare` number, `surge_multiplier` number |
| `ride_accepted` | proposed | a driver accepted | `ride_id` string, `wait_seconds` number |
| `ride_cancelled` | proposed | the ride ended before completion | `ride_id` string, `cancelled_by` string, `seconds_to_cancel` number |
| `ride_completed` | proposed | the ride finished and the fare was settled | `ride_id` string, `revenue` number, `currency` string, `distance_km` number, `duration_minutes` number, `payment_method` string |
| `ride_rated` | proposed | a rating was submitted | `ride_id` string, `rating` number |
| `order_completed` | standard | a food or grocery order was paid for | `order_id` string, `revenue` number, `currency` string, `vendor_id` string, `item_count` number, `service` string |
| `wallet_topped_up` | proposed | the wallet was topped up | `revenue` number, `currency` string, `method` string |
| `promo_applied` | proposed | a promotion code was applied | `coupon_code` string, `discount` number, `service` string |
| `support_ticket_opened` | proposed | a support ticket was opened | `topic` string, `service` string, `ride_id` string |
Using `revenue` on `wallet_topped_up` and `ride_completed` is deliberate. The code explicitly allows for this: revenue accrues from any event, not only orders, because a customer may model a subscription or a top-up as its own event. Note though that only `order_completed` increments `order_count`, so the profile's order count does not count rides.
Put `service` on every super-app event. Without it you cannot ask for "took a taxi but never ordered food".
## Fintech and payments {#fintech}
| Event name | Status | When to send it | Properties |
|---|---|---|---|
| `signed_up` | standard | registration completed | `method` string, `referral_code` string |
| `kyc_started` | proposed | the user entered the verification flow | `level` string, `hours_since_signup` number |
| `kyc_submitted` | proposed | documents were uploaded | `level` string, `document_type` string |
| `kyc_approved` | proposed | verification passed | `level` string, `hours_to_approve` number |
| `kyc_rejected` | proposed | verification was refused | `level` string, `reason` string |
| `card_linked` | proposed | a bank card was attached | `bank` string, `card_type` string |
| `transfer_completed` | proposed | money was transferred | `transfer_id` string, `amount` number, `currency` string, `destination_type` string |
| `bill_paid` | proposed | a bill was paid | `bill_type` string, `amount` number, `currency` string |
| `payment_completed` | proposed | a payment at a merchant went through | `revenue` number meaning your fee, `currency` string, `merchant_id` string, `category` string, `amount` number |
| `loan_application_started` | proposed | a credit application began | `amount` number, `term_months` number |
| `loan_approved` | proposed | credit was granted | `amount` number, `term_months` number, `days_to_decision` number |
| `investment_order_placed` | proposed | a buy or sell order was placed | `asset` string, `side` string, `amount` number |
`amount` appears here instead of `revenue` on purpose. The value of a transfer is not your money: call it `revenue` and every user's `total_revenue` becomes their account turnover, so the "high-value customer" segment becomes "moves a lot of money around". On `payment_completed` both appear, because you want both figures: `revenue` is the fee that is yours and `amount` is what the user paid.
> [!warn]
> Do not put card numbers, IBANs, `cvv` values or payment tokens in properties. Event properties are shown verbatim to your support team on the user timeline and they appear in the CSV export. If you need it, put the last four digits in a separate property such as `card_last4`.
## Travel and booking {#travel}
| Event name | Status | When to send it | Properties |
|---|---|---|---|
| `searched` | standard | a route or stay search was submitted | `origin` string, `destination` string, `depart_date` string, `return_date` string, `passengers` number, `results_count` number |
| `product_viewed` | standard | a flight, hotel or tour was opened | `product_id` string, `product_type` string, `unit_price` number, `currency` string, `star_rating` number |
| `checkout_started` | standard | the booking flow began | `cart_total` number, `currency` string, `passengers` number, `product_type` string |
| `order_completed` | standard | the booking was confirmed and paid for | `order_id` string, `revenue` number, `currency` string, `product_type` string, `origin` string, `destination` string, `depart_date` string, `days_to_departure` number |
| `order_cancelled` | standard | the booking was cancelled | `order_id` string, `revenue` number, `refund_amount` number, `days_to_departure` number |
| `trip_started` | proposed | the departure day arrived | `order_id` string, `product_type` string, `destination` string |
| `trip_completed` | proposed | the trip ended | `order_id` string, `product_type` string, `nights` number |
| `price_alert_created` | proposed | a price alert was set | `origin` string, `destination` string, `target_price` number |
| `booking_modified` | proposed | dates or passengers changed | `order_id` string, `change_type` string, `fee` number |
`days_to_departure` is stored on the event rather than computed at query time. The difference is that months later you can still ask "how many people booked within three days of the flight", whereas with only `depart_date` you would have to subtract two strings at report time, and a date string in the property map is text.
## Education {#education}
| Event name | Status | When to send it | Properties |
|---|---|---|---|
| `signed_up` | standard | registration completed | `method` string, `referral_code` string |
| `course_viewed` | proposed | a course page opened | `course_id` string, `course_name` string, `category` string, `unit_price` number, `is_free` boolean |
| `course_enrolled` | proposed | enrolment completed | `course_id` string, `is_free` boolean |
| `order_completed` | standard | a course or subscription was bought | `order_id` string, `revenue` number, `currency` string, `course_id` string |
| `lesson_started` | proposed | a lesson was opened | `course_id` string, `lesson_id` string, `lesson_index` number |
| `lesson_completed` | proposed | a lesson was watched to the end | `course_id` string, `lesson_id` string, `lesson_index` number, `watch_seconds` number |
| `quiz_submitted` | proposed | a quiz was submitted | `course_id` string, `quiz_id` string, `score` number, `passed` boolean |
| `assignment_submitted` | proposed | an assignment was handed in | `course_id` string, `assignment_id` string, `days_late` number |
| `certificate_issued` | proposed | a certificate was issued | `course_id` string, `days_to_complete` number |
| `course_abandoned` | proposed | no lesson for a while and you have decided that counts as abandoned | `course_id` string, `last_lesson_index` number, `days_idle` number |
`course_abandoned` has to be decided by your own system and sent from your server; the platform does not manufacture an event out of the absence of one. If you would rather not build it, do the same thing with a journey that waits after `lesson_completed`.
Do not put course progress on the profile as a trait unless a user only ever has one course. A trait is one value per person, so with two courses the second erases the first.
## Subscription media {#subscription-media}
| Event name | Status | When to send it | Properties |
|---|---|---|---|
| `signed_up` | standard | an account was created | `method` string |
| `content_viewed` | proposed | playback or reading started | `content_id` string, `content_type` string, `title` string, `genre` string, `is_premium` boolean |
| `content_completed` | proposed | watched or read to the end | `content_id` string, `watch_seconds` number, `completion_ratio` number |
| `paywall_viewed` | proposed | the paywall was shown | `content_id` string, `plan_shown` string |
| `trial_started` | proposed | a trial began | `plan` string, `trial_days` number |
| `subscription_started` | proposed | the subscription went live and was charged | `plan` string, `revenue` number, `currency` string, `billing_period` string |
| `subscription_renewed` | proposed | an automatic renewal succeeded | `plan` string, `revenue` number, `currency` string, `renewal_number` number |
| `subscription_cancelled` | proposed | the user cancelled | `plan` string, `reason` string, `days_subscribed` number |
| `payment_failed` | proposed | a renewal failed | `plan` string, `attempt` number, `failure_code` string |
| `download_started` | proposed | downloaded for offline viewing | `content_id` string, `content_type` string |
The thing that always surprises people in subscriptions: `subscription_cancelled` returns no money. Only `order_refunded` and `order_cancelled` subtract from `total_revenue`. A cancellation means the next renewal does not happen, not that previous months are refunded, and the profile says exactly that.
## The traits you send with identify {#traits}
These are the keys the code gives meaning to, and which become real profile columns:
| Key | Type | Note |
|---|---|---|
| `email` | string | lower-cased only, with no format validation |
| `phone` | string | parsed and stored as `E.164`, plus a derived `phone_operator` |
| `first_name` and `last_name` | string | as sent |
| `gender` | string | mapped to `male`, `female` or `other` |
| `birthday` | date | the formats `2006-01-02`, `2006/01/02` and `RFC3339` are accepted; a malformed date is silently dropped |
| `national_id` | string | the check digit is verified; an invalid one is dropped entirely |
| `city`, `region`, `country` | string | filled from the event's location if you do not send them |
| `language` | string | filled from the first part of `locale` if you do not send it |
| `timezone` | string | filled from the event's `context.timezone` if you do not send it |
| `push_opt_in`, `email_opt_in`, `sms_opt_in` | boolean | the true values are `true`, `1`, `yes`, `on` and «بله». Everything else is false |
A new profile starts with all three consents on. An existing customer base has already consented through your own flow, and defaulting to off would make your first campaign reach nobody.
`name` and `created_at` are also declared as reserved keys, but they are not promoted to columns and stay ordinary traits. If you want a full name, send `first_name` and `last_name`.
Every other key is a free-form trait and lands in the `traits` and `traits_num` maps. A shop usually also sends: `loyalty_tier` string, `loyalty_points` number, `is_wholesale` boolean, `preferred_category` string.
```bash title="One complete identify"
curl -X POST https://in.segmentic.net/v1/identify \
-H "Content-Type: application/json" \
-H "Authorization: Bearer wk_seg_..." \
-d '{
"message_id": "c74a9e01-5f22-4a8b-8c31-7ee2d9b40a55",
"user_id": "u_88123",
"traits": {
"phone": "09123456789",
"email": "Hamid@Example.com",
"first_name": "حمید",
"city": "تهران",
"birthday": "1993-04-11",
"sms_opt_in": true,
"loyalty_tier": "gold",
"loyalty_points": 1840
}
}'
```
```json title="Response"
{"status":"ok","accepted":1}
```
What that does to the profile: `phone` becomes `+989123456789` and `phone_operator` is written as `mci`, `email` becomes `hamid@example.com`, `loyalty_points` lands in both the text and the numeric map so that a "greater than" filter works, and `loyalty_tier` stays text only.
## What Segmentic writes {#segmentic-writes}
These four names are written into your event stream by the platform's own workers. Do not send them yourself, but do build segments and funnels on them like any other event.
| Name | When it is written | Properties |
|---|---|---|
| `message_sent` | a campaign message was sent successfully | `channel`, `category`, `transport`, `node_id`, `arm` which is `treatment` or `control`, `can_open`, `can_click`. Plus `why_open` and `why_click` when a signal cannot be measured, which are ready-to-print Persian sentences |
| `message_failed` | the send failed | the same set, plus `reason` |
| `message_withheld` | the user was chosen for the campaign and deliberately not messaged | the same set, with `arm` as `control` and `reason` naming the hold |
| `message_opened` | the open-tracking endpoint was called | none. The campaign's message id lands in `source_message_id`, and the device and location come from the request the mail client made |
The `message_id` of these events combines the request id and the event name, so that a send and a failure for the same message do not de-duplicate against each other.
`message_delivered`, `message_bounced` and `unsubscribed` are declared as constants and nothing else in the backend uses them: no worker writes them and nothing reads them, so a report built on `message_delivered` comes back empty. The panel asks for a delivery event anyway. Its messaging dashboard template and the starter metrics for delivery rate, open rate and click rate each need one mapped to a name from your own event schema, and activation does not proceed until you name one, so unless you send a delivery event yourself, that dashboard and those three metrics are never built.
What is missing is the event, not the data. A campaign report counts its own delivered messages from the receipts a transport sends back, the deliverability service reads bounces off the SMTP return path and writes them to the suppression list, and an unsubscribe lands on the person's record in the preference centre and comes back as the suppression reason on the next send. None of that arrives as a row in your event stream.
The attribution worker recognises exactly four names, `message_sent`, `message_withheld`, `message_opened` and `message_clicked`, and skips every other event it is handed. `message_clicked` is not written by a worker either: the [web SDK](/en/docs/sdk-web) raises it when a visitor lands on a link carrying `sg_mid`, once, until a different message id replaces it. The comparison is against the one id in storage, not against a history of them, so a visitor who lands on one message, then a second, then the first one again reports the first click twice.
Next: [identity](/en/docs/identity) explains how these events attach to one person, and [designing events](/en/docs/events#hard-to-reverse) lists the decisions that cannot be undone.
---
# Putting events on your own site and app
> From the business question to the line of code on the page: which events you need, where in the code to call them, and how to be sure they arrived.
> https://segmentic.net/en/docs/instrument
This page assumes you have a site or an app that works and that nothing is coming out of it yet. [Designing events](/en/docs/events) says what our code does with what you send, and the [event dictionary](/en/docs/event-dictionary) gives you a ready-made list per industry. This page does a third thing: it starts from the question you want answered and ends at the line of code that goes on your page.
First, the thing most people expect and that does not exist: **Segmentic has no event registration.** There is no form, no endpoint and no screen for creating an event. Any name you send is accepted, and the event comes into existence the moment it first arrives. What you are actually doing is choosing a name and its properties, then placing one call in the right spot in your own code.
That freedom cuts both ways. The good side is that adding a new event needs nobody's permission. The bad side is that nothing stops a wrong name, and a wrong name is not corrected later. That is why this page starts with a list rather than with code.
## Start from the question, not from the page {#what-to-decide-first}
The first temptation is to walk through your own screens and send everything that can be clicked. What comes out is eighty names, none of which is attached to a decision.
Do the opposite. Write down three to five questions you would act on if you had the answer. Then, for each one, ask which event answers it.
| The question you are asking | What you would do with the answer | The event you need |
| --- | --- | --- |
| Who abandoned their cart | Send them a reminder | `cart_updated` and `order_completed` |
| Which homepage banner works | Replace the weak one | `banner_viewed` and `banner_clicked` |
| Who looked at the expensive item and did not buy | Send a targeted discount | `product_viewed` with `price` |
| Who has not come back in thirty days | Build a win-back campaign | Any event at all, the last-seen date is enough |
That last row is there on purpose. Some questions need no new event. Before adding a name, check whether what you already send answers it.
## Close the list on paper first {#the-list}
Before opening an editor, fill in a table with these four columns. It is what you hand to a developer, and it stays your own reference afterwards.
| Event name | When it fires | Properties | Who sends it |
| --- | --- | --- | --- |
| `banner_viewed` | When the banner actually enters the viewport | `banner_id`, `slot` | Browser |
| `banner_clicked` | A click on the banner | `banner_id`, `slot`, `destination` | Browser |
| `product_viewed` | A product page opens | `product_id`, `category`, `price` | Browser |
| `checkout_started` | Arrival at the payment screen | `cart_value`, `item_count` | Browser |
| `order_completed` | Payment confirmed | `order_id`, `revenue`, `currency` | Server |
The last column matters most and is the one usually left blank. Its answer is in [what the browser must not be trusted to say](/en/docs/instrument#from-the-server).
Write the "when it fires" column as a verb, not as a screen name. "When the banner enters the viewport" is implementable. "On the homepage" is not, because it does not say where the moment of sending is.
## How many events is enough {#how-many}
Five to fifteen, to start.
There is no technical ceiling and nobody is counting. The constraint is elsewhere: the panel's event catalogue shows the five hundred highest-volume names from the last ninety days. Let useless names pile up and your real events fall off that list, which means they are no longer pickable in the segment builder. This happened to a real customer, with names shaped like URLs; the account is in [when the event name is a URL](/en/docs/events#url-shaped-names).
The second constraint is human. An event that no segment, no report and no journey uses is one that, six months from now, nobody understands and nobody dares delete.
> [!note]
> Start small. Adding an event is possible at any moment and costs nothing. Taking a wrong name back out of history is not possible at all.
## Naming your own events {#naming-yours}
The server checks exactly two things: that the name is no longer than 128 bytes and that it holds no control characters. It does not lower-case, does not convert spaces to underscores, and enforces no pattern. The full rule is in [the naming rule](/en/docs/events#naming).
So everything below is a convention rather than a constraint. It is the convention the platform's own standard names follow, though, and staying with it is what keeps your list legible.
| Rule | Write | Do not write |
| --- | --- | --- |
| Object first, then a past-tense verb | `banner_clicked` | `click_banner` |
| Lower case and underscores | `wallet_topped_up` | `WalletToppedUp` |
| No variable inside the name | `banner_clicked` with `banner_id` | `banner_nowruz_hero_clicked` |
| One name per thing that happened | `product_viewed` | `product_view` and `productViewed` side by side |
Rows two and four share a trap: because the server does not fold case, `Banner_Clicked` and `banner_clicked` are two separate events forever. Nothing errors. One day you simply notice the numbers have halved.
Row three is the expensive one. A name carrying an id or any varying value mints a fresh event per value, and that is exactly what fills the catalogue.
## One name, several places {#one-name-many-places}
Your banner appears on the homepage, at the top of a category page, and inside an email. Do not create three names.
Use one name and put the location in a property:
```js
Segmentic.track("banner_clicked", {
banner_id: "nowruz_hero",
slot: "homepage_top"
});
```
Your questions want both shapes. "How many clicks did this banner get in total" is answered by the one name, and "which slot performs better" by splitting on the property. Three names leave the first question with no simple answer.
The general rule: what you want to **add up** is the name, and what you want to **break down by** is a property.
## Where in the code to call it {#where-in-the-page}
This is where instrumentation goes wrong. An event at the wrong moment is worse than no event, because it produces a number and the number is false.
| What happened | Where to call it | Why not elsewhere |
| --- | --- | --- |
| A click on a link or button | One listener on `document` using `closest` | Banners are usually rendered later or inside a carousel, so a direct listener never reaches them |
| A form submission | After the server answers successfully | On `submit` you also send the event for forms that were rejected |
| A section becoming visible | With an `IntersectionObserver` | On page load counts every visitor who never scrolled that far |
| A route change in a single-page app | The SDK itself, via `autoPageView` | A manual call in `useEffect` fires again on every re-render |
| A successful payment | From your server | The browser does not know whether the money actually settled |
The next worry is usually that the visitor clicks a link, the page navigates away, and the event never gets sent. That one is already handled: the web SDK flushes every twenty messages or every ten seconds, and on top of that sends with `sendBeacon` on `pagehide` and when the page is hidden, which survives unload. The detail is in [the offline queue](/en/docs/sdk-web#unload).
## A worked example: a banner click {#worked-example}
Say the question is: which homepage banner works, and who clicked it.
Install the SDK first. Take the write key from the Connect screen in the panel:
```html
```
Then mark the banners themselves. Putting the id in the HTML means the next banner needs no JavaScript change at all:
```html
```
Then one listener, once, for every banner on the site:
```js
document.addEventListener("click", function (e) {
var el = e.target.closest("[data-banner]");
if (!el) return;
Segmentic.track("banner_clicked", {
banner_id: el.dataset.banner,
slot: el.dataset.slot,
destination: el.getAttribute("href")
});
});
```
If you want a click-through rate, send the impression too. Firing once per element matters, otherwise every scroll past the banner counts again:
```js
var seen = new WeakSet();
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting || seen.has(entry.target)) return;
seen.add(entry.target);
var el = entry.target;
Segmentic.track("banner_viewed", {
banner_id: el.dataset.banner,
slot: el.dataset.slot
});
});
}, { threshold: 0.5 });
document.querySelectorAll("[data-banner]").forEach(function (el) {
io.observe(el);
});
```
That is all of it. No registration, no migration, no panel setting. `banner_clicked` exists from the first click.
## Inside a mobile app {#in-an-app}
Do not change the names. The same `banner_clicked` the web sends is the one the app should send. Do not invent `banner_clicked_android`, or every question gets asked twice and every report grows a second column.
The SDK puts the platform in `context` and reports can split on it, so there is no reason to repeat it in the name or in a property.
Installation and methods are in [the Android SDK](/en/docs/sdk-android#api). Where the call goes follows the same logic as above: at the moment of real interaction, not in `onCreate` and not in a view's constructor.
## What the browser must not be trusted to say {#from-the-server}
The write key is public by design. It sits in your page, anyone can read it, and anyone can send events with it. For `banner_clicked` that does not matter. For a number that lands in a revenue report it does.
So send these from your own backend rather than from the browser:
- Anything carrying an amount, especially `order_completed` and its `revenue`
- Anything that is a system of record: shipped, refunded, subscription renewed
- Anything the browser never learns, such as a payment gateway callback that arrives at your server
There are two doors for this and the differences are laid out in [server to server](/en/docs/server#two-doors). In short: `POST /v1/batch` on `https://in.segmentic.net` with the same write key, or `POST /v1/events` on `https://api.segmentic.net` with a secret `sk_seg_` key.
> [!warn]
> The second door differs in two silent ways: it does not de-duplicate, so a plain retry in your code creates the event twice, and its window is a fixed thirty days. Do not migrate history through it.
If you send the same event from both the browser and the server, you get two. Pick one. For anything involving money, always the server.
## Tying the event to a person {#tie-to-a-person}
Everything sent so far carries only an `anonymous_id`. You know a browser clicked the banner. You do not know who, and you cannot email them.
`identify` is what connects the two. Call it wherever you know who the visitor is: after sign-in, after registration, or on any page where a valid session exists.
```js
Segmentic.identify("u_8842", {
email: "ali@example.com",
phone: "09121234567"
});
```
Worth knowing: `identify` does not have to happen before the click. On the first `identify`, the SDK also sends an `alias`, so that browser's anonymous history joins the profile. Without that, every funnel crossing the sign-in boundary would report the wrong number.
Call `Segmentic.reset()` on sign-out, or the next person on that device accumulates onto the previous person's profile. The harder cases, such as a shared device, are in [identity](/en/docs/identity).
## Verify each one actually arrived {#verify-each-one}
Do not skip this. The event you forgot to check is usually the one that turns out, three months later, never to have arrived at all.
Step one, **immediately**: leave the Debug screen open in the panel, click the banner in another tab, and watch it appear within a few seconds. If it does not, the problem is the installation and not the event name. One caveat: batched sends are not recorded on that screen, so test with single calls.
Step two, **a few minutes later**: open the Data screen. The event should be there with its volume and its property list. If the name is there but a property you expected is not, that property was `null` or empty.
Step three, **while you are there**: give the event a display label. "Banner click" reads better than `banner_clicked` for whoever builds the segment. The label is presentation only and the stored name never changes.
> [!warn]
> The `ingest_warnings` table exists but nothing writes to it. Warnings live only in the response body of that one request, so if you send from a server, log that response. The ways of seeing what landed are in [seeing what actually arrived](/en/docs/events#what-arrived).
## Checklist before going live {#go-live-checklist}
- Every event on your list has been seen once on the Debug screen.
- No name contains a variable, and they all follow one casing convention.
- Ids are strings and numbers are numbers, not numeric strings. The rule is in [what a property may hold](/en/docs/events#property-types).
- `revenue` appears only on genuinely monetary events. On any event at all it accrues to lifetime value, including on `cart_viewed`. See [revenue](/en/docs/events#revenue).
- `identify` is called wherever the visitor is known, and `reset` on sign-out.
- Monetary events come from the server, not the browser.
- No event is sent twice, once from the browser and once from the server.
- Every event has a display label on the Data screen.
## Common mistakes {#common-mistakes}
| Mistake | What breaks | Instead |
| --- | --- | --- |
| A name containing an id or a URL | The catalogue fills and real events drop off the five-hundred list | Make the id a property |
| The event on form `submit` | Rejected submissions get counted | After a successful response |
| `revenue` on a non-monetary event | Lifetime value quietly inflates | Only on a purchase |
| Sending `url` and `referrer` as properties | Consumes property slots and adds nothing | The SDK already sends them in `context` |
| Localised values for categories | Filters break across spellings | One language for values, put the label in the panel |
| `identify` only on the sign-in page | A returning visitor with a live session stays anonymous | Wherever the session is valid |
| No `reset` on sign-out | Two people accumulate onto one profile | `reset` in the sign-out path |
| The same event from browser and server | Every number doubles | Pick one |
## What to read next {#next}
- [Designing events](/en/docs/events) for the exact rules on names, properties and limits.
- [The event dictionary](/en/docs/event-dictionary) if you run retail, fintech, travel or education and want a ready-made list.
- [Identity](/en/docs/identity) if you have anonymous visitors who later sign in.
- [The web SDK](/en/docs/sdk-web#methods) for every method and option.
- [Server to server](/en/docs/server#which-one) for choosing between the two doors.
- [Segments](/en/docs/segments) once the events arrive and you want to build on them.
- [The product catalogue](/en/docs/catalog) if you run a shop and want to recommend products in your messages.
---
# Identity: anonymous and signed-in users
> How the history of somebody who has not signed in yet joins their account, and what happens when two people share one device.
> https://segmentic.net/en/docs/identity
Most people visit you several times before you know who they are. This page says what Segmentic does with that period: what is joined, what is not, and which actions cannot be undone.
> Diagram: How anonymous ids, user ids and verified traits become one customer view
## The two identifiers {#two-identifiers}
| | `anonymous_id` | `user_id` |
|---|---|---|
| Where it comes from | the SDK mints it | your own authentication supplies it |
| Where it is kept | browser local storage, or a private file in the app | the same place, after the first `identify` |
| When it changes | only on `reset` | on the next `identify`, or on `reset` |
| Creates a profile | no | yes |
At least one of the two must be on every message, or the event is rejected with `missing_identity`. Both are capped at 256 bytes, and anything longer is `id_too_long`.
Every message goes onto the event bus under an identity key: the `user_id` when it is not empty, otherwise the `anonymous_id`. The reason is ordering: all of one person's messages must land on one partition so that the stateful consumers, profile updates, journey state and session stitching, see them in order without any cross-partition coordination.
The one fact the rest of this page follows from: **a profile is keyed on `user_id` alone.** The ingestor skips every event with an empty `user_id` before profiles are touched. An anonymous visitor has no profile at all.
## How the anonymous id is made and kept {#anonymous-id}
**The browser.** The web SDK reads the key `segmentic_anonymous_id` from `localStorage` and mints one if it is absent. It uses `crypto.randomUUID`, falling back to a version 4 UUID built from `crypto.getRandomValues`, and on very old browsers to `Math.random`.
Before any of that, `localStorage` is probed with a real write rather than checked for existence. In Safari private mode, in hardened enterprise browsers, and when the origin's quota is exhausted, `localStorage` exists and throws on write. If the probe fails the SDK falls back to an in-memory store: the customer's site keeps working, but the anonymous id lives only as long as that page, and every reload is a new person.
**Android and iOS.** The same key, but as a file of that name inside a `segmentic` directory in the app's private storage, minted with `UUID.randomUUID`. It survives until the app is deleted.
Three things the anonymous id is **not**:
- It is not a device id. The Apple SDK keeps a separate install id under `segmentic_install_id`, deliberately not `identifierForVendor` (which changes when the last app from a vendor is deleted) and deliberately not the advertising identifier (which is a privacy question the customer has to answer, not us).
- It is not shared between two browsers or two devices. One person on a phone and a laptop has two anonymous ids.
- It is not changed by `identify`. The same value rides every subsequent message.
Every message the SDK builds always carries `anonymous_id`, and carries `user_id` once it knows one.
## What `identify` does {#identify}
On the client, in all three SDKs, in this order:
1. An empty user id is ignored, with a console warning.
2. The user id is stored.
3. **If the user id differs from what was stored and an anonymous id exists, an `alias` message is enqueued first**, carrying `previous_id` set to the current anonymous id.
4. Then the `identify` message is enqueued with the traits.
5. On web and Android, scalar traits are cached locally so that in-app message targeting can match on them. The browser has only what it was given, not the warehouse; pretending otherwise would make every trait rule on every campaign silently false. The iOS SDK does not keep this cache.
The `anonymous_id` is **not** changed. Both the `alias` and the `identify` message carry the same anonymous id and the new user id.
Call `identify` twice with the same id and exactly one alias is produced. The comparison is against the stored value, not against memory for this run, so a page reload does not mint a duplicate alias either.
On the server, `identify` becomes an ordinary event named `identify`. Its traits are **not** stored on the events table; they go only to the person's profile. The profile has two rules: never erase (an event that does not mention a trait must leave it alone) and never regress (events replay out of order after a consumer restart, so an older event must not overwrite newer state).
```js title="Browser"
import { init, identify } from "@segmentic/web";
init({
writeKey: "wk_seg_...",
apiHost: "https://in.segmentic.net",
});
// After your own sign-in succeeds, and again on every page load
// while the session is still valid.
identify("u_88123", {
phone: "09123456789",
first_name: "حمید",
loyalty_tier: "gold",
});
```
```kotlin title="Android"
Segmentic.identify(
userId = "u_88123",
traits = mapOf(
"phone" to "09123456789",
"first_name" to "حمید",
"loyalty_tier" to "gold",
),
)
```
## `alias` and `previous_id` {#alias}
`identify` sends the alias for you, so you rarely call this yourself. If you do, `previous_id` is required and a message without it is rejected with `missing_previous_id`.
One asymmetry you will not find in any limits table: unlike `user_id` and `anonymous_id`, which are rejected past 256 bytes, nothing checks the length of `previous_id`. Whatever you send rides intact into `identity_map`, and its only ceiling is the 5 MiB body cap.
What an alias message produces on the server is exactly this:
```sql title="segmentic.identity_map"
CREATE TABLE segmentic.identity_map (
tenant_id UInt32,
anonymous_id String,
user_id String,
linked_at DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(linked_at)
PARTITION BY tenant_id
ORDER BY (tenant_id, anonymous_id);
```
One row. Plus the alias message itself, which lands in the events table as an event named `alias` like any other message.
Note that the sorting key is `anonymous_id`, not `user_id`. The whole of [two people on one device](/en/docs/identity#two-people-one-device) follows from that single line.
`alias` does not join two `user_id` values. Whatever you put in `previous_id` lands on the anonymous side of the map, and nothing merges two profiles. **There is no operation that merges two profiles.**
## What the link is actually used for {#what-the-link-does}
`identity_map` has three consumers in the entire codebase: the insert, the per-person erasure path, and its own integration test. That is all.
The thing it is actually used for is erasure. When somebody asks to be forgotten, their event rows are deleted by `user_id`, but their pre-sign-in events carry an empty `user_id` and that pass cannot see them at all. The only route to those rows is the anonymous ids named in `identity_map`:
```sql title="Erasing the pre-sign-in history"
ALTER TABLE segmentic.events DELETE
WHERE tenant_id = ? AND user_id = '' AND anonymous_id IN (
SELECT anonymous_id FROM segmentic.identity_map
WHERE tenant_id = ? AND user_id = ?
) SETTINGS mutations_sync = 2;
```
And only after that, never before, is `identity_map` itself deleted. The order is the whole of it: the first version of this code deleted the map first, so the subquery matched nothing, the delete removed nothing, and it reported success. That person's browsing history from before they signed in, which is a large part of what "forget me" means, survived every erasure on the platform silently. An integration test caught it.
## What does not happen {#not-stitched}
This is the most important section on the page and it is written bluntly on purpose, because believing the opposite costs you an afternoon on a number that never comes right.
**Historical anonymous event rows are never rewritten.** No code updates `events.user_id` for rows whose anonymous id appears in the identity map. Those rows keep an empty `user_id` for ever.
**The function that merges an anonymous profile into a known one exists and is never called.** A repository-wide search for `ApplyAlias` finds its definition in `profile/merge.go` and call sites that are all inside `merge_test.go`, and nothing else.
**An anonymous visitor never gets a profile at all.** The ingestor skips every event with an empty `user_id`, so there is no anonymous profile to merge. That person's `total_events`, `total_revenue` and `first_seen` all begin at the moment they signed in.
**No analytics query joins `identity_map`.** The segment compiler, the funnel, retention and path reports, and the user timeline all read `events.user_id` directly.
**Anonymous rows are excluded from both daily rollups**, by `WHERE user_id != ''`.
The precise practical consequence: a funnel that begins with an anonymous `product_viewed` and ends with an identified `order_completed` does not join the two, because the compiler's event subquery groups on `user_id` and the anonymous row's is empty.
> [!warn]
> Wherever you read that "the anonymous history is connected to the user", the exact meaning is: the rows are kept and the link is recorded. It does not mean that reports attribute those rows to that user. Pre-sign-in funnels are not stitched.
So what to do: call `identify` as early as you legitimately can. If the user is still signed in from a previous session, call `identify` at the very start of the page load or app launch, before any other event, so that the session's events carry a `user_id` from the first message. An event sent with a user id is attributed correctly; the only thing that is not stitched is what came before it.
## Two devices, one person {#two-devices}
Device A mints `anon_A`, device B mints `anon_B`. Both call `identify("u_1")`.
- Two alias messages are sent, one per device.
- Two rows land in the identity map, keyed `anon_A` and `anon_B`, both with the value `u_1`. Both survive: a ReplacingMergeTree only collapses rows that share an anonymous id.
- Every event carrying `user_id` as `u_1`, from either device, folds into one profile row. The counters accumulate across both devices.
- The device facts, `device_type`, `os_name`, `app_version`, `push_provider`, `city`, `timezone` and `language`, describe the latest session, not the union of the two. They update only when the event's timestamp is not older than the last-seen time, and an empty incoming value never replaces a known one. An event sent from a background thread with no device context must not make a reachable user unreachable: that person would drop out of every push campaign with nothing in any log to explain it.
- Both devices' pre-sign-in rows remain unattributed.
- Erasure handles this case correctly: it deletes anonymous events for every anonymous id the map associates with that user.
## Two people, one device {#two-people-one-device}
The device mints `anon_X`. The first person calls `identify("u_A")`. Later, **without `reset` being called**, a second person calls `identify("u_B")`.
- The stored user id changes from `u_A` to `u_B`, so the "it changed" condition is true and a second alias is enqueued.
- The `anonymous_id` has not changed, because only `reset` mints a new one. So the second alias carries `previous_id` as `anon_X` again.
- The identity map is ordered on `(tenant_id, anonymous_id)`, so the second row **replaces** the first. After the merge, `anon_X` points at `u_B` and the fact that it once belonged to `u_A` is gone.
> [!danger]
> The consequence for erasure: if `u_A` later asks to be forgotten, the lookup no longer finds `anon_X`, so `u_A`'s pre-sign-in events are not deleted. This is a real gap, it does not reverse, and no test covers it. The only thing that prevents it is calling `reset` on sign-out.
What stays sound: `u_A` and `u_B` are two separate profiles and each keeps its own counters. Both people's identified events remain correctly attributed, because every event carried the user id that was current when it was enqueued.
## `reset` {#reset}
Call it on sign-out. Every time.
What it does:
1. The user id is cleared, from memory and from storage.
2. A **new** anonymous id is minted and stored.
3. The session key is removed.
4. On web only: the stored campaign attribution is cleared. On a shared computer, the next person's purchase must not be credited to the message the last one received. Android has no client-side campaign replay, so it has nothing to clear.
What it does **not** do, in all three SDKs:
- It does not clear the queue and it sends nothing. Buffered messages still go out carrying the user id they were built with, which is the correct behaviour.
- It does not clear the locally cached traits. Until the next `identify`, in-app targeting rules still match against the previous person's traits.
- It does not clear the "has been here before" flag, deliberately. Signing out does not make somebody a new user, and a "first launch" campaign that reappears after every sign-out would be a bug the customer hears about from their users.
- It sends nothing to the server. **There is no such thing as a reset on the server**: only the five message types are accepted, and there is no unlink or de-alias operation.
`reset` is not a consent control. That is `optOut`, which stops collection **and clears the buffer**, because honouring an opt-out only for future events while quietly delivering what was already captured is not an opt-out.
## `user_hash`, and what it is for {#user-hash}
The write key is public. It ships inside the customer's own page and anyone can read it out of the source. For writes that is acceptable: the worst a stranger can do with one is add noise to the customer's own data, which is visible and repairable.
The in-app inbox is the first **read**. Its rows carry the message body and the personalised discount code generated for one named customer, and "give me the inbox of user 91372" behind a key anyone can read out of the page source is not an endpoint that can exist.
So two separate checks run, because they answer different questions. The write key says which tenant's data is in play, and says nothing about who is asking. The hash says the customer's own backend authenticated this person. Only the second one stands between "show me my messages" and "show me everyone's".
The formula, exactly:
```text
user_hash = hex(hmac_sha256(identity_secret, user_id))
```
The `identity_secret` belongs to your tenant and **it must never reach a browser**. Your backend computes the hash at sign-in and hands it to the SDK, which sends it with every inbox request.
Obtaining it is not self-service, and you should plan around that: no HTTP route writes this secret and no panel screen exists for it. The only thing that sets it on a tenant is the `adminctl` command-line tool, which means a Segmentic operator. Until that has happened, the in-app inbox answers you with 403 and nothing else.
```js title="On your server, at sign-in"
import { createHmac } from "node:crypto";
export function userHashFor(userId) {
return createHmac("sha256", process.env.SEGMENTIC_IDENTITY_SECRET)
.update(userId)
.digest("hex");
}
```
```bash title="Reading the inbox"
curl -X POST https://in.segmentic.net/v1/inbox \
-H "Content-Type: application/json" \
-H "Authorization: Bearer wk_seg_..." \
-d '{
"user_id": "u_88123",
"user_hash": "3f6c1d0a9b8e4725c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7",
"limit": 20
}'
```
```json title="Response when there is nothing waiting"
{"status":"ok","messages":[]}
```
```json title="Response when the hash is wrong or missing"
{"status":"error","message":"user identity is not verified"}
```
Several exact behaviours to know:
- Only two endpoints want this hash: `POST /v1/inbox` and `POST /v1/inbox/ack`. Every other collector path is a write, and a write key is enough for those.
- It fails closed, though not in the way you would expect. Registering the two routes is an install-wide decision, not a per-tenant one: on any install with the inbox configured, both routes exist for everybody. What a tenant with no identity secret gets is a 403 on every request. There is no unverified mode.
- A body with no `user_id` gets 400 with the message `user_id is required`, before any identity check runs.
- The comparison is constant time. The endpoint is open to the world, and a byte-at-a-time compare leaks the expected value one character at a time to anyone patient.
- An upper-case hash is accepted; the proof is trimmed and lower-cased before the comparison.
- A wrong hash and a missing hash both get 403 with the identical body. Distinguishing them would turn this into an oracle for which user ids exist.
- Rotating the secret invalidates every hash your backend has already handed out, which signs your whole app out of its inbox until you redeploy. Not something to do by accident.
## Identity when you send from your own server {#server-side}
From your own backend, events go to `POST /v1/events` on `api.segmentic.net` with a management key holding `profile.write`. A successful response is 202, not 200: the events are queued, not stored, and become queryable seconds later. Saying 200 would invite a caller to read them back immediately and conclude they were lost.
Why `profile.write` and not a new permission: that is what this does, it writes to people's profiles and their event history, and inventing a second name for the same capability would let somebody grant one believing they withheld the other.
```bash title="An event from your server"
curl -X POST https://api.segmentic.net/v1/events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_seg_..." \
-d '{
"events": [
{
"type": "track",
"user_id": "u_88123",
"event": "order_completed",
"timestamp": "2026-08-07T10:02:41.000Z",
"properties": { "order_id": "A-100294", "revenue": 2450000, "currency": "IRR" }
}
]
}'
```
```json title="Response, with status 202"
{"accepted":1}
```
Three ways this path differs on identity:
- You almost always have the `user_id`, so send it and leave `anonymous_id` out entirely.
- This path normalises with no IP address and no User-Agent, because it is a server-to-server call and attributing a recipient's city from your data centre address would put every one of your users in one place. So there is no geolocation, no browser, no bot flag and no device derivation on this path.
- Your server does not know the browser's anonymous id and cannot know it. If you want an anonymous session and your server events to meet, the alias has to come from the browser, which means from the `identify` the SDK calls.
And three things that are not about identity but will catch you here anyway, because all three are silent:
- **This path does not de-duplicate.** `message_id` does not protect you. Post the same batch twice and it is written twice. The 48-hour de-duplication belongs to the collector, and this door goes past it.
- **Warnings are computed and thrown away.** The response body carries `accepted` and, when it applies, `rejected`. So you never see `invalid_phone` or `generated_message_id` on this path even when they happened.
- **The time window here is a fixed 30 days**, not the tenant's retention policy. Any older `timestamp` is silently clamped to the edge of it and the answer is still a successful 202. That is why history is not migrated through this door.
## Rules that buy you time later {#rules}
| Rule | What it prevents |
|---|---|
| Call `identify` the first moment you know who the user is, and on every page load or app launch for someone still signed in | pre-sign-in history is not stitched, so the later you call it the more of your data stays ownerless |
| Call `reset` on sign-out, without exception | the next person on the device inherits the previous person's anonymous id, and the previous person's link is erased for ever |
| Use your own primary key for `user_id`, not an email and not a phone number | `user_id` is the profile's primary key and there is no rename operation. Somebody who changes their email gets a second profile |
| Never use a value that changes each session | you create one profile per session and the user count stops meaning anything |
| Agree on one `user_id` format before two systems start writing | there is no operation that merges two profiles, and `alias` does not do it either |
| Send email and phone as traits, not as the `user_id` | traits are where they belong and where they are normalised; a `user_id` shows up in exports and in the timeline URL |
Next: [designing events](/en/docs/events) covers what to send and what to call it, and the [event dictionary](/en/docs/event-dictionary#traits) has the list of traits you send with `identify`.
---
# The web SDK
> Installing on a site, every method, the options, the offline queue and browser push.
> https://segmentic.net/en/docs/sdk-web
The web SDK collects events in the browser, holds them in `localStorage` until the network comes back, and sends every message with a stable identifier so that a resend over a bad connection does not count somebody's purchase twice. The same file also drives browser push and on-site messages.
The library identifies itself on every message with `library.name = "segmentic-js"` and `library.version = "0.1.0"`.
## Installing {#install}
The bundle is served from the same origin the events are posted to. That is deliberate: it means one entry in the customer's Content-Security-Policy, not two.
```text
https://in.segmentic.net/sdk/segmentic.js
```
### Script tag {#script-tag}
This is what the Connect screen in the panel generates. The write key (`wk_seg_...`) is public and is meant to be visible in the page source.
```html
```
In local development the bundle is served by the dashboard out of its own `public` directory, and the collector is somewhere else:
```html
```
> [!warn]
> There is no versioned form of this URL. The path `/sdk/segmentic.js` always serves the current build, and no version-pinned address is published. No `integrity` hash is published either, so if your security policy requires Subresource Integrity you have to host the file yourself and compute the hash yourself.
### With a bundler {#bundler}
**There is no npm package.** The name `@segmentic/web` appears in the repository's `package.json`, but it is not published to any registry, no CI step builds or publishes it, and `npm install @segmentic/web` fails.
The npm tab on the panel's Connect screen still shows `import segmentic from "@segmentic/web"`. That snippet does not run today. Until the package is published, the script tag is the only supported path.
If your project must import a module, take the ESM file out of the same bundle and vendor it beside your own code. The type definitions (`index.d.ts`) are not served from any public address, so TypeScript gets no types through that route.
## init and every option {#init}
`writeKey` and `apiHost` are required, and their absence throws: `segmentic: writeKey is required` and `segmentic: apiHost is required`. That is the only place the SDK throws. Trailing slashes on `apiHost` are stripped.
| Option | Type | Default | What it does |
|---|---|---|---|
| `writeKey` | `string` | none, required | Write key from the panel. Public by design |
| `apiHost` | `string` | none, required | Collector base URL |
| `batchSize` | `number` | `20` | Send immediately once this many messages are buffered |
| `flushInterval` | `number` ms | `10000` | Send at least this often |
| `maxQueueSize` | `number` | `500` | How many messages may wait on disk while offline |
| `maxRetries` | `number` | `10` | Caps how far the retry interval grows. Not a cap on the buffer |
| `autoContext` | `boolean` | `true` | Collect page, locale, screen and timezone automatically |
| `autoPageView` | `boolean` | `true` | Send one `page` message during `init()` |
| `respectDoNotTrack` | `boolean` | `true` | Honour the browser's Do Not Track setting |
| `onsite` | `boolean` | `true` | Fetch and draw the tenant's on-site campaigns |
| `debug` | `boolean` | `false` | Log SDK activity to the console with the prefix `[segmentic]` |
| `sessionTimeout` | `number` ms | `1800000` | Idle gap after which the session identifier rotates |
| `now` | `() => number` | `() => Date.now()` | Time source. For tests |
| `fetchImpl` | `typeof fetch` | `globalThis.fetch` | Fetch implementation. For tests |
`onsite` defaults on deliberately: an on-site campaign is published by hand, and a customer who publishes one and sees nothing on their site has no way to tell a broken install from an empty campaign list.
`init()` does this, in order: create the store, probing `localStorage` with a real write; load the queue from disk; load or start the session; load the opt-out state and check Do Not Track; load or mint the anonymous id; install the lifecycle hooks; schedule the periodic send; record the campaign click; then `page()` if `autoPageView` is on; then an immediate `flush()` to drain anything buffered offline; and finally start on-site.
The campaign click is recorded **before** the page view, so the click is the first thing recorded and the page view that follows already carries the attribution.
Calling `init()` again closes the previous client with `close()` first.
> [!warn]
> `autoPageView` sends a page view once, inside `init()`, and never again. The SDK installs no hook on `pushState`, `replaceState` or `popstate`. The doc comment on the `Options` type says "on init and on history navigation" and the second half of that does not exist in the code. In a single-page app, call `Segmentic.page()` yourself after each route change.
## Methods {#methods}
```ts
init(options: Options): SegmenticClient
track(event: string, properties?: Properties, context?: Context): void
identify(userId: string, traits?: Traits, context?: Context): void
page(name?: string, properties?: Properties, context?: Context): void
screen(name: string, properties?: Properties, context?: Context): void
alias(previousId: string, context?: Context): void
reset(): void
flush(): Promise
optOut(): void
optIn(): void
isOptedOut(): boolean
getAnonymousId(): string | null
getUserId(): string | null
stats(): Stats | null
subscribeToPush(options: PushOptions): Promise
unsubscribeFromPush(): Promise
pushPermission(): NotificationPermission | "unsupported"
```
`track` with an empty event name does nothing and logs to the console. So does `identify` with an empty user id. Neither throws, because an analytics call must never be the thing that breaks a customer's checkout page. Eleven of these methods called **before** `init()` write `[segmentic] () called before init(); ignoring` to the console and return: `track`, `identify`, `page`, `screen`, `alias`, `reset`, `flush`, `optOut`, `optIn`, `subscribeToPush` and `unsubscribeFromPush`. The five that only read a value say nothing at all before `init()`: `isOptedOut()` returns `false`, `getAnonymousId()`, `getUserId()` and `stats()` return `null`, and `pushPermission()` returns `"unsupported"`. The `false` is the one to watch, because it reads as "not opted out" and no warning tells you the SDK was never started.
`identify` does three further things. First, if this is the first identify after anonymous browsing and the id differs from the previous one, it enqueues an `alias` message **before** the `identify`; without it the entire pre-login history is orphaned and every funnel crossing the login boundary reports the wrong number. Second, it flattens the scalar traits into `localStorage` so on-site targeting can match on them; anything of type `object` and anything null or undefined is skipped, and the rest is coerced with `String()`. Third, it calls `refreshOnsite()`, because a signed-in visitor may now match a campaign an anonymous one did not.
`page(name)` sends the name both as `event` and copied into `properties.name`.
`screen(name)` sends a message of type `screen`. On the web, `page()` is the right one; `screen` exists so the surface matches the mobile SDKs.
`reset()` clears the user id, mints a **new** anonymous id, drops the session, and drops the stored campaign attribution as well (on a shared computer, the next person's purchase must not be credited to the message the last one received). It deliberately does not clear the returning-visitor flag: signing out does not make somebody a new visitor.
`flush()` resolves when the attempt finishes, not necessarily when anything was sent: inside the backoff window it returns without issuing a request. Concurrent calls are chained rather than coalesced, because an event enqueued after the running pass emptied the queue but before it settled would otherwise be reported as delivered while still sitting on disk.
`stats()` returns this shape:
```ts
{
queued: number; // messages in the queue right now
sent: number; // messages accepted since init
dropped: number; // messages discarded
failures: number; // current run of consecutive failures
optedOut: boolean;
durableStorage: boolean; // false means memory only, the queue dies on reload
anonymousId: string;
userId: string | null;
}
```
### The global and the instance {#surfaces}
Two surfaces exist and they are not the same.
`window.Segmentic` is the module namespace. It carries the seventeen methods above, plus `SegmenticClient`, `pushSupported`, `decodeVapidKey`, the on-site targeting evaluator (`eligible`, `matches`, `maySee`, `isLive`, `deviceOf`, `readSeen`, `writeSeen`, `recordSeen`, `recordAction`) and a `default` key.
The instance returned by `init()` has three methods that are **not on the global**:
```ts
close(): void // stops timers and listeners
onsiteCampaigns(): OnsiteCampaign[] // the campaign list as last fetched
refreshOnsite(): void // decide again which campaign to show
```
To reach those three you have to keep the return value of `init()`:
```js
const segmentic = Segmentic.init({
writeKey: "wk_seg_...",
apiHost: "https://in.segmentic.net"
});
// after each route change in a single-page app
router.afterEach(() => {
segmentic.page();
segmentic.refreshOnsite();
});
```
## What goes on the wire {#wire-format}
Every message has this shape. Empty fields are omitted; nothing is sent as null.
```ts
{
type: "track" | "identify" | "page" | "screen" | "alias";
message_id: string; // always present
timestamp: string; // ISO 8601, always present
sent_at?: string; // stamped at send time
event?: string;
user_id?: string;
anonymous_id?: string;
previous_id?: string;
properties?: Properties;
traits?: Traits;
context?: Context;
}
```
The body posted to `POST {apiHost}/v1/batch` wraps the messages in `batch` and writes `sent_at` **both on the envelope and on every message inside it**:
```bash
curl -X POST https://in.segmentic.net/v1/batch \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"sent_at": "2026-07-30T12:00:00.000Z",
"batch": [
{
"type": "track",
"message_id": "0f9c6f1e-3d4a-4a1e-9c5b-2b7a1f6d8e30",
"timestamp": "2026-07-30T12:00:00.000Z",
"sent_at": "2026-07-30T12:00:00.000Z",
"anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
"event": "order_completed",
"properties": { "revenue": 2500000, "currency": "IRR", "city": "تهران" },
"context": {
"library": { "name": "segmentic-js", "version": "0.1.0" },
"session_id": "2a3c1d55-77b0-4c8e-9a1f-0e6d4b3a2c19",
"locale": "fa-IR",
"timezone": "Asia/Tehran",
"page": {
"url": "https://shop.example.com/checkout/done",
"path": "/checkout/done",
"search": "",
"title": "سفارش ثبت شد",
"referrer": "https://shop.example.com/cart"
}
}
}
]
}'
```
The response to a fully accepted batch:
```json
{ "status": "ok", "accepted": 1 }
```
The response when one item of three is bad. The rest are accepted, and the collector says exactly which index was refused:
```json
{
"status": "ok",
"accepted": 2,
"rejected": 1,
"errors": [{ "index": 1, "reason": "missing_identity" }]
}
```
Headers: `Content-Type: application/json` and `Authorization: Bearer {writeKey}`. The `keepalive` flag is set only when the body length is under 60000 bytes.
Server-side ceilings: at most 500 items per batch and at most five megabytes of body. With the default `batchSize` of 20 you approach neither.
### Automatic context {#context}
With `autoContext: true`, these travel on every message:
- `library` with the library name and version. Always, and **not overridable**: a context you pass yourself is merged over the rest, but `library` is forced back to ours
- `session_id`, always
- `locale` from `navigator.language`
- `timezone` from `Intl.DateTimeFormat().resolvedOptions().timeZone`, inside a try and catch, because Intl is missing on some embedded browsers
- `screen` with `width`, `height` and `density`
- `page` with `url`, `path`, `search`, `title` and `referrer`
- `network` with `cellular` and `wifi`, only when `navigator.connection.type` exists
`device` and `os` are deliberately not sent. The collector derives them from the User-Agent header, which a client cannot forge; anything sent from here would be a hint rather than a fact. The IP likewise comes from the connection, never from the body.
With `autoContext: false` only `library` and `session_id` travel. Campaign attribution is attached even then, because it is an answer the customer asked for rather than something we collected about the visitor.
A per-call context is merged over the collected one, with the `app`, `page` and `campaign` sub-objects shallow-merged:
```js
Segmentic.track("video_played", { id: 42 }, {
app: { name: "shop-web", version: "5.2.1" }
});
```
### Campaign attribution {#attribution}
These URL parameters are read:
| URL parameter | goes to `context.campaign.` |
|---|---|
| `utm_source` | `source` |
| `utm_medium` | `medium` |
| `utm_campaign` | `name` |
| `utm_term` | `term` |
| `utm_content` | `content` |
| `sg_mid` | `message_id` |
| `sg_t` | `token` |
| `sg_cid` | `campaign_id`, only if finite and greater than zero |
An attribution starts only with `sg_mid`. A URL carrying only UTM parameters records no click.
When a new `sg_mid` is seen, the SDK enqueues a `track` message with the event name `message_clicked`, **once, until a different message id replaces it**. A refresh, a back button, or a link passed around among colleagues does not count as a second click. The comparison is against the one id in storage, not against a history of them, so a visitor who lands on one message, then a second, then the first one again reports the first click twice.
The captured campaign is stored in `localStorage` with a timestamp and **replayed onto every later event for seven days**. Seven days matches the server's window exactly; two different windows would mean the SDK sending conversions the server silently discards.
Because it is replayed from storage rather than re-read from the address bar, tidying the URL with `replaceState` does not lose the attribution. Reading the query string on every event was the alternative, and it is wrong twice: on a single-page app the parameters linger and the whole visit is credited to the message, while a site that tidies its URL loses the credit one click later. Neither is visible in testing.
A newer `sg_mid` replaces the older one and reports a second click. `reset()` drops the attribution. A visitor who has opted out records nothing at all, not even the campaign click.
The SDK never looks at `sg_t`. Message ids are derived and guessable, and that token is what separates a real click from one anybody could have typed into a URL bar; the SDK's job is only to carry it back.
### Sessions {#sessions}
One `session_id` per visit. The identifier rotates after `sessionTimeout` of idleness rather than on page load, so somebody who reads an article for ten minutes and then clicks stays in the same session. An expired session is not resurrected on the next page load.
## The offline queue {#queue}
Every message is written to `localStorage`, under the key `segmentic_queue`, **before any network attempt**. Closing the tab mid-request, losing signal, or an outage at our end costs nothing.
`localStorage` is probed with a real write (the key `__segmentic_probe__`) rather than by trusting that the object exists. In Safari private mode, in hardened enterprise browsers, and when the origin's quota is exhausted, that write throws and the SDK falls back to an in-memory store that at least keeps the current page working. `stats().durableStorage` reports which one is in use.
A send happens in two cases: when the queue length reaches `batchSize`, and every `flushInterval`. The browser's `online` event also triggers one. Draining loops, taking `batchSize` items at a time until the queue is empty; with `batchSize: 2` and five events the batch sizes are exactly 2, 2 and 1.
A corrupt queue costs nothing. JSON that will not parse causes the key to be removed and an empty queue returned, and entries that do not look like a message (no string `message_id`, no string `type`) are filtered out individually rather than the whole buffer being discarded.
### What is dropped and how it is reported {#drops}
There are three drop reasons. All of them accumulate in `stats().dropped` and, with `debug: true`, are written to the console as `[segmentic] dropped messages: `.
| Reason | When |
|---|---|
| `queue_full` | the queue length exceeded `maxQueueSize` |
| `storage_quota` | the write to disk failed, half the buffer was shed and the write retried |
| `storage_unavailable` | the second write failed too. Work continues in memory and what is held dies with the tab |
Overflow drops **from the front**, that is, the oldest first. After a long outage the freshest events are the ones still worth having. With `maxQueueSize: 10` and twenty-five events, the last ten survive and fifteen are counted in `dropped`.
There is a fourth way to lose a message that also lands in `dropped` and does not come from the queue: a `4xx` from the server, described below.
### Retries and status codes {#retries}
| Response | Behaviour |
|---|---|
| `2xx` | acked, removed from the queue, the failure counter resets |
| `4xx` except `429` | **dropped permanently.** Removed from the queue, counted in `dropped`, and with `debug` this line is logged: `server rejected batch permanently: ` |
| `429` | stays queued and is retried |
| `5xx` | stays queued, backoff applied |
| network, DNS or CORS error | assumed transient, stays queued |
The reasoning behind `4xx`: it means the payload is wrong and will never be accepted. Retrying forever would block every later event behind it. So it is dropped, but loudly.
The same logic is honoured on the server. When the write key lookup itself fails, the collector answers `503` with `Retry-After: 5` rather than `401`. An SDK reads `401` as "this key will never work" and discards the events; it reads `503` as "try again later" and keeps them.
Backoff is exponential with full jitter: the delay is a uniform random number between zero and `min(300000, 1000 * 2^n)` milliseconds, where `n` is the run of consecutive failures. So the ceiling is five minutes. The jitter matters more than the curve: when a backend recovers, thousands of devices that failed at the same moment must not all retry at the same moment and knock it over again.
`maxRetries` caps only how far that interval grows, **not how long data is kept**. When the failure count reaches it, this line is logged: `max retries reached; messages stay queued for the next session`. The messages stay. The user may simply be on a train.
### Deduplication by message_id {#dedup}
`message_id` is a UUID generated **once**, at enqueue time, and reused on every retry. That is the entire basis of deduplication: on a flaky mobile network the SDK will resend, and without a stable id the customer's purchase count silently doubles.
The id comes from `crypto.randomUUID()`, otherwise from `crypto.getRandomValues()` with the RFC 4122 version four bits set by hand, and finally from `Math.random()` for very old browsers. That last path is weaker, but a colliding id costs one deduplicated event, whereas throwing there would cost all of them.
On the server the collector remembers the ids it has seen. How long is set by `DEDUPE_TTL`, which defaults to 48 hours. Duplicates count as accepted to the sender, because the SDK already delivered them once and must stop retrying.
### Clock-skew correction {#clock-skew}
Every batch carries `sent_at` on the envelope and on each message. The collector measures the offset between its own receive time and that `sent_at`, and applies the same offset to the event's `timestamp`. A clock that is wrong but consistent is recovered that way.
The details that matter:
- an offset under one minute is ignored
- the correction is applied only if the corrected time stays inside the permitted window; otherwise the original timestamp is kept
- a timestamp more than one hour ahead of the server is clamped to the receive time and a `timestamp_in_future` warning is returned
- a timestamp older than the past window is clamped to that window's edge and a `timestamp_too_old` warning is returned. The window is the account's own events retention: an account that keeps events for ever gets 3650 days, an account that has set 90 days gets 90, and an account on the 30 day floor still gets the full 30 day default
### Leaving the page {#unload}
The SDK listens for `visibilitychange` and `pagehide`, and on both sends one batch with `navigator.sendBeacon`, as a `Blob` of type `application/json`, to:
```text
{apiHost}/v1/batch?write_key={writeKey}
```
The write key travels in the query string here because `sendBeacon` cannot set headers and the collector accepts it there for exactly this case.
Beaconed messages are **not removed from the queue**. `sendBeacon` reports only that the request was handed to the browser, never that it arrived. Leaving them means the next page load may resend, which is safe, because every message carries a stable `message_id`.
### Storage keys {#storage}
| Key | Contents |
|---|---|
| `segmentic_anonymous_id` | this browser's anonymous id |
| `segmentic_user_id` | the last id given to `identify()` |
| `segmentic_queue` | the outbound queue |
| `segmentic_session` | session id, start time and last-seen time |
| `segmentic_opt_out` | the value `1` means opted out |
| `segmentic_campaign` | the campaign this visit is credited to, with its timestamp |
| `segmentic_traits` | the scalar traits from the last `identify()`, for on-site targeting |
| `segmentic_seen` | this browser has been here before. Not cleared by `reset()` |
| `sg_onsite` | how often each on-site campaign was seen, dismissed or converted |
Plus one Cache API entry named `segmentic-config` at the key `/__segmentic_push_config`, used only by browser push.
No cookie is ever written.
## Consent, opt-out and Do Not Track {#consent}
`optOut()` stops collection **and clears the queue**. That is deliberate: honouring an opt-out only for future events, while quietly delivering what was already captured, is not an opt-out.
After `optOut()`:
- the enqueue path returns immediately, so `track`, `identify`, `page`, `screen` and `alias` produce nothing
- `flush()` resolves without any request
- the unload beacon does nothing
- the campaign click is not recorded, so `segmentic_campaign` is never written
- `subscribeToPush()` returns `{ state: "failed", reason: "..." }`
- no on-site message is drawn
The opt-out flag lives in `localStorage`, so it survives a reload. `optIn()` clears the flag and the key and restarts the send timer.
> [!warn]
> One honest caveat: `optOut()` stops the send timer only. The sixty-second on-site poll timer is not stopped, so if the SDK had already started polling before the opt-out, it keeps issuing a `GET /v1/onsite` every sixty seconds. That request carries no identity, and nothing is drawn or reported, but the request continues. Only `close()` on the instance stops it.
**Do Not Track** is read when `respectDoNotTrack` is true, which is the default: `navigator.doNotTrack`, then `globalThis.doNotTrack`, then `navigator.msDoNotTrack`, with `"1"` or `"yes"` meaning opted out. It is read **once, in the constructor**. Changing the browser setting mid-session has no effect until the next `init()`. That is also why `optIn()` successfully re-enables collection within the same session even under Do Not Track.
## Browser push {#push}
There are two prerequisites and neither is optional.
**One: `identify()` must have run.** A subscription is stored against a person, and one saved for an anonymous visitor could never be targeted by a segment. Without it you get `{ state: "failed", reason: "اول باید identify صدا زده شود تا اشتراک به کاربر وصل شود" }`.
**Two: the VAPID public key.** Without it you get `{ state: "failed", reason: "کلید VAPID تنظیم نشده است" }`.
> [!warn]
> The VAPID public key is shown on no screen in the panel today, and no endpoint returns it. It is one key for the whole installation, generated with `adminctl vapid` and set as `VAPID_PUBLIC_KEY` in the collector's and the worker's environment. Ask your Segmentic contact for it. Changing that key invalidates the subscription of every browser already subscribed.
> [!warn]
> **Subscribing is not the same as being reachable, and today that gap is real.** Before the delivery path ever reaches the browser-push sender, it asks the device registry how many installs this `user_id` has, and it treats the `webpush` channel exactly like mobile push: as an address belonging to an install. If that user has no row in the device registry, the message is set aside as `not_reachable` before anything is attempted. This SDK calls `POST /v1/webpush/subscribe` and never `POST /v1/devices`, so a visitor who only has a browser and has installed no app gets their subscription stored and no campaign at all. It does not show up as a failure in the report; it shows up as unreachable. Check with your Segmentic contact before building browser push into a site with no app behind it.
### The service worker {#service-worker}
You have to serve `segmentic-sw.js` **from your own origin root**. A worker's scope cannot be broader than the path it is served from, so a worker served from `/static/` can only receive pushes for pages under `/static/`. Our origin is no use either: a service worker has to be same-origin with the page.
The current file is downloadable from the panel:
```bash
curl -o segmentic-sw.js https://app.segmentic.net/segmentic-sw.js
```
Put it beside your own `index.html` so that it is served at `https://your-site.example/segmentic-sw.js`. If you put it somewhere else, pass `serviceWorkerPath` and `scope` to match.
What the worker does: `install` calls `skipWaiting` and `activate` calls `clients.claim`, so a new version takes over without every tab being closed. Notifications are drawn with `dir: "rtl"` and `lang: "fa"`, and `badge` falls back to `icon`. A push that is not our JSON still shows a notification titled `پیام جدید`, because no browser permits a silent push and Chrome substitutes its own "this site has been updated in the background" notice, which is worse.
A click on a notification focuses an existing tab on the same URL if there is one, and opens a window otherwise. The identifiers (`sg_mid` and a signed token) ride on the URL itself, so the click is reported by your own site on page load and no redirect service of ours sits in the middle. That is what keeps the link working when our analytics is not.
The worker also listens for `pushsubscriptionchange` and re-posts the replacement subscription. To do that it needs `apiHost`, the write key, the public key and the user id, which are stashed in the Cache API at subscribe time, because that event can fire with no tab open and a worker has no access to the page's variables. Without it, a push service can rotate a subscription on its own and the person silently falls out of every campaign; the only symptom is a delivery rate that drifts down over months.
### The push methods {#push-api}
```ts
subscribeToPush(options: {
publicKey: string; // required
serviceWorkerPath?: string; // defaults to "/segmentic-sw.js"
scope?: string; // defaults to "/"
}): Promise<{ state: PushState; reason?: string }>
unsubscribeFromPush(): Promise
pushPermission(): NotificationPermission | "unsupported"
```
`PushState` is one of `subscribed`, `denied`, `dismissed`, `unsupported` or `failed`. `reason` is a Persian string, safe to show a visitor or to log.
**Call `subscribeToPush` from a click.** A browser lets a site ask for notification permission once, and Chrome blocks a site's prompts outright if enough people dismiss them. An unprompted request on arrival is therefore the most reliable way to lose the channel permanently. The SDK does not enforce this and will not stop you; the consequence is simply irreversible.
```html
```
The order the checks run in, and what each one answers:
| Case | `state` | `reason` |
|---|---|---|
| the user opted out | `failed` | `کاربر از ردیابی انصراف داده است` |
| `identify()` has not run | `failed` | `اول باید identify صدا زده شود تا اشتراک به کاربر وصل شود` |
| the browser lacks one of the three APIs | `unsupported` | `این مرورگر از اعلان وب پشتیبانی نمیکند` |
| `publicKey` is empty | `failed` | `کلید VAPID تنظیم نشده است` |
| permission was already refused | `denied` | `کاربر قبلاً اجازهٔ اعلان را رد کرده است` |
| the prompt was answered with no | `denied` | `اجازهٔ اعلان داده نشد` |
| the prompt was closed unanswered | `dismissed` | `پنجرهٔ اجازه بسته شد` |
| the server registration failed | `failed` | `ثبت اشتراک روی سرور انجام نشد` |
| success | `subscribed` | none |
Three things in that table.
If `Notification.permission` is already `denied`, the SDK **does not call `requestPermission` at all**. Asking again somebody who already said no is both useless and, in Chrome, a step towards having the site's prompts blocked permanently.
`dismissed` is distinct from `denied` because it means something different. Somebody who closed the prompt has not refused; the site may ask again later, which a hard `denied` would wrongly rule out.
If a subscription already exists on this registration it is **reused** rather than replaced. Unsubscribing and resubscribing mints a new endpoint, which leaves the old row in the table pointing at a subscription the browser has forgotten, and every campaign then reports one failure per stale endpoint.
Three more checks the SDK makes that are not in the table: `pushSupported()` requires all three of `serviceWorker` in `navigator`, `PushManager` in `window` and `Notification` in `window`; after registering the worker it awaits `navigator.serviceWorker.ready`, because calling `pushManager` on a registration that is still installing throws on Safari; and it always subscribes with `userVisibleOnly: true`, which every browser implementing the Push API requires.
On success this request is sent directly rather than through the queue:
```bash
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_123",
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
"p256dh": "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
"auth": "tBHItJI5svbpez7KI4CCXg"
}
}'
```
```json
{ "status": "ok" }
```
It does not go through the queue because no buffer can replay it: the permission has already been granted and the browser will not ask again, so a subscription that never reaches us is a person who agreed to be notified and can never be reached. That is why a non-`2xx` response is returned to the caller as `{ state: "failed" }`, so the host page can retry.
`unsubscribeFromPush()` reads the browser's subscription, calls `unsubscribe()`, then posts `POST {apiHost}/v1/webpush/unsubscribe` with the body `{ "endpoint": "..." }`. If there was no subscription it returns `false` without any request. The server requires no user id and checks none: the endpoint is the subscription's own secret, and possession of it is already sufficient to send to that browser, so demanding more before allowing somebody to **stop** receiving would be protecting the wrong direction.
`pushPermission()` returns `Notification.permission` without prompting, or `"unsupported"` when the three APIs are not all present.
## On-site messages {#onsite}
With `onsite: true`, the default, the SDK fetches the list of live campaigns and decides **in the browser** which one to show.
```text
GET {apiHost}/v1/onsite?write_key={writeKey}
```
Once during `init()` and then every sixty seconds. Sixty matches the response's `Cache-Control` exactly; fetching faster misses the cache and puts a request on the customer's page load for an answer that cannot have changed. The response carries no identity, so one copy serves every visitor and is cacheable.
A failure of that request is completely silent. It runs inside somebody else's page load; a failure of ours degrades to "no banner today", never to a console error on their site. The same holds on the server: a database error returns an empty list rather than a `5xx`.
The targeting is local because the alternative is one request per page view, a million a day for a mid-sized Iranian shop, on the critical rendering path of their site, with our latency in front of their content and our availability in front of their business. The cost of that decision is that the rules are public: anybody can read them in the network tab, which is why the rule vocabulary deliberately contains nothing a customer would mind a competitor seeing.
The rules evaluated in the browser: `url_contains`, `url_not_contains`, `devices`, `new_visitors_only`, `returning_only`, `logged_in` and `traits`.
URL matching is **substring, never regular expression**. A pattern written by a marketer is one that can be catastrophically slow, and this runs on every page of somebody else's site.
The device class comes from the **viewport width**, not the user agent: under 768 is `mobile`, under 1024 is `tablet`, anything else is `desktop`. User-agent sniffing is wrong on every device that lies about itself, which by now is most of them, and what a campaign targeting "mobile" actually means is "a narrow screen".
`traits` is plain equality against the traits the last `identify()` left in this browser, not against the warehouse. The browser has only what it was given, and pretending otherwise would make every trait rule silently false.
The frequency cap is applied in this exact order:
1. the campaign is outside its `starts_at` and `ends_at` window: no. `ends_at` is exclusive, so the end instant is already not live
2. never seen: yes
3. `converted` is recorded: no, for ever. Somebody who did the thing should never be asked again, and that outranks every other rule including a campaign that is still running
4. `dismissed` is recorded **and** the campaign is `dismissible`: no. A non-dismissible banner keeps showing
5. `max_impressions` is greater than zero and the seen count has reached it: no. Zero means no ceiling
6. `cooldown_hours` is greater than zero and less than that has passed since the last impression: no
Both the browser and the server apply this cap. Local storage alone means clearing it gives an uncapped modal; the server alone means a request per page view, which is what this whole design exists to avoid.
A click counts as a **conversion** for capping purposes: somebody who followed the link has done the thing, and showing it again asks them to do it twice.
### What is drawn {#onsite-render}
All four kinds are drawn: `banner` (a strip at the top or bottom), `modal` (centred, with a backdrop), `slidein` (a corner) and `survey` (a corner, with either an NPS zero to ten scale or a list of choices).
**At most one campaign at a time.** Two modals at once is not a design anybody chose, and the second would cover the first's close button. The first eligible campaign, in the order the server sent them, is the one shown.
Three rules shape the whole rendering code, and all three are about being a guest on somebody else's page:
- **Never throw.** An analytics widget must not be the thing that breaks a checkout. Every entry point is wrapped
- **Never inherit.** The host page's CSS reset, font stack and `* { }` rules would otherwise reshape the widget in ways nobody previewed, so every property that matters is set explicitly on the element
- **Never inject markup.** Content is set with `textContent`, never `innerHTML`. A headline comes from a panel field, and a customer who pastes markup into it must not get a script running on their own site
A button URL becomes an `href` only if it starts with `http://` or `https://` or with `/`. A `javascript:` URL gets no `href` at all. The button text stays and the click is still reported.
Layout details: the container is one `position: fixed` div with the id `segmentic-onsite`, `z-index` of `2147483000` (below the maximum, so a customer's own overlay can still win), `pointer-events: none` on the container with each widget re-enabling clicks for itself (otherwise an invisible full-page div would swallow every click on the customer's site), and `direction: rtl` on the container, because a widget authored in Persian must read right to left even on a page that does not. The close button sits on the **left** and its `aria-label` is `بستن`. The default colours are background `#1f2937`, text `#ffffff`, accent `#2563eb`.
Triggers: scroll, exit intent and delay. The delay is a trigger of its own **only when neither scroll nor exit intent is set**; alongside either of those it would race them and show the message on a timer the marketer meant as a minimum. Exit intent is desktop only: a touch device has no pointer to leave for the tab bar, and libraries that fake this fire on every upward scroll.
The survey shows the question, then either an NPS row of zero to ten drawn `direction: ltr` inside the right-to-left card (zero on the left through ten on the right, as the scale is universally drawn) or the list of choices. If `follow_up` is set, a textarea appears after the answer and its text is sent as a second response. That second post carries the **whole** answer, the score or the choice as well as the new text, because the save behind it is idempotent on (campaign, person) and replaces the row rather than adding to it. The default thank-you is `ممنون از وقتی که گذاشتید.` and it stays up for **two seconds** before closing: a widget that vanishes the moment somebody answers reads as a page glitch, not as an acknowledgement.
The impression is recorded locally **before the report is sent**, so the cap holds even if the request never lands.
The reports go directly rather than through the queue, with `keepalive: true`, and their errors are swallowed. An impression that arrives ten seconds late is fine, but it must not be held behind a batch waiting for nineteen more messages on a page the visitor is about to leave.
```bash
curl -X POST https://in.segmentic.net/v1/onsite/event \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"campaign_id": 7,
"action": "impression",
"anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
"user_id": "u_123"
}'
```
`action` is one of `impression`, `click`, `dismiss` or `convert`. `user_id` is omitted when the visitor is anonymous.
A survey answer goes to its own endpoint. The server reads two answer fields and no others: `score`, zero to ten on an NPS survey, and `answers`, a map of string to string, which is where a multiple choice or a free text answer belongs. **Any other key is discarded when the body is decoded and the answer still comes back `200`**, so if you draw the survey yourself, post `answers` and nothing else. Our renderer uses the keys `choice` and `text` inside that map; the panel shows those two under a readable label and any key of your own exactly as you sent it.
Omitting `score` on an NPS survey is refused with `400` rather than read as zero. That is deliberate and it is the second half of a bug this SDK had: a missing number is not the worst number on the scale.
Both of these were wrong until recently, and the answers lost in between are gone. [On-site messages](/en/docs/onsite#survey-defects) has what to check.
```bash
curl -X POST https://in.segmentic.net/v1/onsite/response \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"campaign_id": 7,
"anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
"user_id": "u_123",
"score": 9,
"answers": { "reason": "ارسال سریع بود" }
}'
```
### Rendering them yourself {#onsite-own}
If your shop has its own design system, turn our rendering off and keep the eligibility and frequency-cap logic. The targeting evaluator is exported rather than hidden precisely because it is the part a customer may want to run themselves.
```html
```
> [!note]
> With `onsite: false` nothing is fetched, so `onsiteCampaigns()` returns an empty array and `refreshOnsite()` returns immediately. If you leave `onsite` on but want your own rendering, there is no way to disable only the rendering.
## CORS and CSP {#cors}
Every write-key endpoint on the collector writes these headers before it does anything else, including before authenticating, so a browser sees the real status code rather than a CORS error. That covers everything this SDK calls: `/v1/batch`, `/v1/webpush/subscribe` and `/v1/webpush/unsubscribe`, and the three on-site endpoints.
```http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Segmentic-Key
Access-Control-Max-Age: 86400
```
`Access-Control-Allow-Credentials` is deliberately not set. A wildcard origin is only safe because of that.
`GET /v1/status` carries none of these headers, so a browser cannot read it from your page. It is there for a monitor or a `curl`, not for a health check you run in the front end. The email endpoints under `/e/` carry none either.
The methods list has no `GET`, and does not need one: the only `GET` the SDK issues is the on-site campaign fetch, which sets no custom header, so it is a simple request and is never preflighted.
For Content-Security-Policy one entry is enough, because the bundle and the events share an origin:
```text
script-src https://in.segmentic.net;
connect-src https://in.segmentic.net;
```
If you publish an on-site message with an `image_url`, `img-src` also needs that image's origin. The widgets set their styles inline on the element rather than through a `style` tag, so `style-src` is untouched.
## Measured size {#size}
Measured on the built files that are being served right now:
| File | Raw | Gzipped |
|---|---|---|
| script-tag bundle (IIFE) | 26091 bytes | 8832 bytes, as served |
| ESM | 25579 bytes | 8580 bytes under a local `gzip -9` |
| CJS | 26380 bytes | 8869 bytes under a local `gzip -9` |
There is no single gzipped number and it would be dishonest to print one: it depends on the compressor and the level. 8832 is what `in.segmentic.net` actually returns on the wire and therefore what a visitor downloads; the same bytes under `gzip -9` on a laptop come to 8793. We host neither the ESM nor the CJS file, so for those only the local number exists.
So about eight and a half kilobytes for the script-tag form. There are no runtime dependencies.
The `README` inside the repository used to say "4.2 KB gzip". That number is stale and roughly half the real one; the growth came from the on-site renderer and browser push being added afterwards. The README now lists it among the claims it got wrong and prints this same 8832. If you meet the old figure anywhere else, trust this page.
## Debugging {#debug}
Pass `debug: true` and every action is written to the console with the prefix `[segmentic]`: each message being queued, beacons being sent, drops with their reason, campaign attribution, send failures with the estimated retry delay, and reaching the retry ceiling.
```js
const segmentic = Segmentic.init({
writeKey: "wk_seg_...",
apiHost: "https://in.segmentic.net",
debug: true
});
setInterval(() => console.table(segmentic.stats()), 5000);
```
Three things to look for in `stats()`:
- `durableStorage: false` means `localStorage` was unavailable and the queue dies with the tab. Safari private mode, or an exhausted quota
- `dropped` climbing while `failures` stays at zero means the server is answering `4xx`. Turn on `debug` to see the status code
- `queued` climbing while `sent` does not move means no send has succeeded. Check CORS first, then that `apiHost` is right
One thing you will **not** see in the panel: the live event debugger does not show an install of this SDK. Debug recording is called only on the single-event path, and this SDK always posts `/v1/batch`, so that screen stays empty for an SDK install no matter how much traffic is arriving. To confirm events landed, leave the panel's connect screen open instead; it asks about the app's activity over the last twenty-four hours and does see the batches.
For a quick check without a browser:
```bash
curl -X POST https://in.segmentic.net/v1/track \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"anonymous_id":"test-1","event":"install_check"}'
```
```json
{ "status": "ok", "accepted": 1 }
```
## What it deliberately never does {#never}
- **It writes no cookie.** Only `localStorage`, and one Cache API entry for push
- **It captures nothing automatically.** No clicks, no form submissions, no JavaScript errors, no session replay. Only what you call goes out
- **It does not hook browser history.** In a single-page app, call `page()` yourself
- **It does not send `device` or `os`.** The collector derives them from the User-Agent header, which a client cannot forge
- **It does not fingerprint.** No canvas, no font enumeration, no identifier derived from hardware
- **It does not inspect the click token.** `sg_t` is only carried and handed back
- **It runs no regular expression in targeting.** Substrings only
- **It writes no `innerHTML`.** Anywhere
- **It does not remove beaconed messages from the queue.** Server-side deduplication is what makes that safe
- **It does not keep the queue on opt-out.** It clears it
- **It does not throw**, except from `init()` without a `writeKey` or without an `apiHost`
- **It does not register the push subscription in the device registry.** Only `POST /v1/webpush/subscribe`. [Browser push](/en/docs/sdk-web#push) says why that matters
- **It has no inbox client.** The endpoints `POST /v1/inbox` and `POST /v1/inbox/ack` exist on the collector and are tested, but no SDK has code for them. If you need them, call them yourself
- **It is published to no npm registry.** See [with a bundler](/en/docs/sdk-web#bundler)
---
Related: [Quickstart](/en/docs/quickstart) for the first event, [Identity](/en/docs/identity) for how anonymous history joins an account, [Consent](/en/docs/consent) for the platform-wide opt-out policy, [On-site messages](/en/docs/onsite) for building the campaigns in the panel, [Devices and push](/en/docs/devices) for the other notification channels, and [Errors](/en/docs/errors) and [Limits](/en/docs/limits) for collector behaviour.
---
# The Android SDK
> The official Android SDK for events, identity, push and in-app messages.
> https://segmentic.net/en/docs/sdk-android
The Android SDK records events, resolves identity, receives push notifications and draws in-app messages. It is three Gradle modules with a sample app that calls them exactly the way your app would.
## How it reaches your build {#how-it-ships}
The SDK ships as source, from the repository, rather than from a package repository. You add it once and then declare the ordinary coordinate:
```kotlin
implementation("net.segmentic:segmentic-android:0.1.0")
```
Two routes get you there and both are written out in full, with every command, under [adding it to your app](/en/docs/sdk-android#install): a composite build with `includeBuild`, or a local publish with `publishToMavenLocal`. Pick the first if the SDK repository sits beside your own, the second if it does not.
Everything below describes code that has run, on an Android 15 emulator rather than on a diagram: a real Firebase token registered through `POST /v1/devices`, a message delivered by FCM v1 with the app both in the foreground and in the background, and an in-app banner drawn and then stopped by its own frequency cap.
## Three modules {#modules}
| Module | What it is | Where it is tested |
|---|---|---|
| `segmentic-core` | Pure Kotlin, without a single Android import. The queue, the backoff, the wire format, the in-app message rules, the tri-state permissions | A plain JVM, in milliseconds, with no emulator |
| `segmentic-android` | The thin Android layer: where files live, what the device reports, which thread the work runs on, and the in-app renderer | A device or an emulator |
| `sample` | An app that calls the SDK exactly as a customer would. Never published | An emulator |
The reason for the split is written in `settings.gradle.kts` itself: every rule that can be got wrong lives in the first module and is tested on a plain JVM. A rule that can only be checked with an emulator is a rule that gets checked less often.
You declare only `segmentic-android`. `segmentic-core` arrives with it, because the Android module's POM declares the dependency.
## Adding it to your app {#install}
### Route one: a composite build {#install-composite}
If the SDK repository sits next to your own, this is the simplest path and needs no publishing at all. Gradle substitutes the coordinate with the local project.
```kotlin title="settings.gradle.kts (your app)"
includeBuild("../segmentic/sdk/android")
```
```kotlin title="app/build.gradle.kts"
dependencies {
implementation("net.segmentic:segmentic-android:0.1.0")
}
```
### Route two: a local publish {#install-mavenlocal}
Run this once in the SDK repository:
```bash
cd segmentic/sdk/android
./gradlew :segmentic-core:publishToMavenLocal :segmentic-android:publishToMavenLocal
```
Four files land in `~/.m2/repository/net/segmentic/`. Then add `mavenLocal()` in your own app:
```kotlin title="settings.gradle.kts (your app)"
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
mavenLocal()
}
}
```
```kotlin title="app/build.gradle.kts"
dependencies {
implementation("net.segmentic:segmentic-android:0.1.0")
}
```
Both modules also publish a `sources.jar`, on purpose: the comments inside `segmentic-core` explain why the queue sheds from the front and why a `4xx` is dropped, and somebody debugging their own integration at two in the morning should be able to read that in their IDE rather than guess it.
When a repository does exist, this is the publish command, and nothing else in the code changes:
```bash
./gradlew publish \
-PsegmenticRepoUrl=https://example.invalid/maven \
-PsegmenticRepoUser=... \
-PsegmenticRepoPassword=...
```
## No dependencies, and what that buys {#no-dependencies}
`segmentic-core` declares no production dependencies. Zero. No JSON library, no HTTP client. `segmentic-android` declares exactly one, and it is `segmentic-core`.
One point the SDK's own README does not make, and this page has to: both published POMs declare `org.jetbrains.kotlin:kotlin-stdlib:2.0.21` at `compile` scope. So "no third-party dependency" is true, and "no dependencies at all" is not literally true. Every Kotlin app already resolves the Kotlin standard library.
What it buys is one specific thing: the SDK cannot pick a library version on your behalf. A library that drags in a JSON parser or an HTTP client can collide with the version your own app already uses, and then you are the one debugging our dependency tree.
There are no resources inside the library either: `buildConfig = false` and `androidResources = false`. That is why the close button on an in-app message is the character `×` rather than an icon; an icon would have been the first resource to enter your APK.
`consumer-rules.pro` is deliberately empty of rules. Nothing is reflected over and nothing is loaded by name, so R8 is free to shrink and rename all of it. The file exists so that stays a recorded decision rather than something somebody has to re-derive later.
Measured sizes, from a real `publishToMavenLocal`:
| File | Bytes |
|---|---|
| `segmentic-android-0.1.0.aar` | `31730` |
| `segmentic-android-0.1.0-sources.jar` | `6705` |
| `segmentic-core-0.1.0.jar` | `54948` |
| `segmentic-core-0.1.0-sources.jar` | `19996` |
The AAR holds five entries: `AndroidManifest.xml`, `classes.jar`, an empty `R.txt`, `proguard.txt` and one metadata file. No resources, exactly as the build file promises.
The number we do **not** have: the SDK's real cost inside your app, meaning its method count or its dex delta after R8. No such measurement exists in the repository. We have the AAR and jar sizes, and those are what is written above.
Platform floor: `minSdk = 24` (Android 7), `compileSdk = 35`, Java 17 for source and target. Going lower would mean carrying desugaring rules into your build for a share of devices that is now under one percent.
## What it adds to your manifest {#manifest}
Two things, and nothing else.
```html title="the library manifest, merged in from the AAR"
```
`INTERNET` is a normal permission and prompts the user for nothing.
`ACCESS_NETWORK_STATE` is deliberately **not** declared. It would let us report whether the connection is cellular or wifi, which is a nice column and not worth quietly adding a permission to somebody else's manifest. The code reads it only when your own app already holds it, and in that case `context.network` is attached to events. If you do not hold it, the key is simply never sent.
The `queries` block is needed because Android 11 hides other packages. Without it, the Play Services probe raises `NameNotFoundException` on a phone that does have Play Services, we report `has_gms=false` for it, and every push for that device is routed away from FCM for no reason.
## Initialisation {#init}
Once, in `Application.onCreate`:
```kotlin title="MyApp.kt"
package com.example.shop
import android.app.Application
import net.segmentic.sdk.SegmenticOptions
import net.segmentic.sdk.android.Segmentic
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Segmentic.init(
this,
SegmenticOptions(
writeKey = "wk_seg_...",
apiHost = "https://in.segmentic.net",
),
)
}
}
```
And in your own manifest:
```html title="AndroidManifest.xml"
```
Three things that cost time if you do not know them:
**Pass the `Application`, not an `Activity`.** The code takes `context.applicationContext`, but it needs the `Application` itself to follow which Activity is in front. If what you passed is not an `Application`, one warning line goes to logcat and in-app messages can no longer be drawn. Everything else keeps working.
**A second `init` is ignored.** The method is `@Synchronized` and the second call only logs "init called twice, ignoring the second call". This differs from the [web SDK](/en/docs/sdk-web), where a second `init` closes the previous client and replaces it.
**`init` does one small read on the calling thread**, to load the queue left over from last time. That is why its place is `Application.onCreate`: a few milliseconds there is normal, and the alternative, a racy getter, is worse.
## Options {#options}
`SegmenticOptions` is a `data class` in `segmentic-core`. Every default is the web SDK's default, on purpose: a customer running both should not see two different batching stories in the same dashboard.
| Option | Type | Default | Meaning |
|---|---|---|---|
| `writeKey` | `String` | none, required | The write key from the panel. Public by design, it may ship in the APK |
| `apiHost` | `String` | none, required | The collector base URL, for example `https://in.segmentic.net` |
| `batchSize` | `Int` | `20` | Send as soon as this many messages are buffered |
| `flushIntervalMs` | `Long` | `10_000` | The longest gap between two sends |
| `maxQueueSize` | `Int` | `500` | How many messages may wait on disk |
| `maxRetries` | `Int` | `10` | How many consecutive failures before the retry cadence stops growing |
| `autoContext` | `Boolean` | `true` | Attach app, OS, screen, locale and timezone to every message |
| `sessionTimeoutMs` | `Long` | `30 * 60_000` | The idle gap after which the next event starts a new session |
| `debug` | `Boolean` | `false` | Log to logcat under the tag `segmentic` |
**This is the only place the SDK throws.** The `SegmenticOptions` constructor raises `IllegalArgumentException` on a blank `writeKey` or a blank `apiHost`. That is deliberate and it happens when you construct the object, not later inside a `track()`. No other method throws under any circumstances.
Values that cannot be honoured are brought into range before anything else runs:
| Field | Coerced to |
|---|---|
| `apiHost` | trailing `/` stripped |
| `batchSize` | between `1` and `1000` |
| `flushIntervalMs` | at least `1000` |
| `maxQueueSize` | at least `batchSize` |
| `maxRetries` | between `1` and `100` |
| `sessionTimeoutMs` | at least `1000` |
The `maxQueueSize` floor is not cosmetic. At least one full batch has to fit, or a queue that is already full could never assemble a send and the buffer would drain only by dropping.
Three options the web SDK has and this one does **not**: `autoPageView` (an app has no page), `respectDoNotTrack` (Android has no such signal) and `onsite`. In-app messages are always on and are evaluated on every `screen()`.
## Every public method {#api}
`Segmentic` is a Kotlin `object` and every method is `@JvmStatic`, so Java callers see ordinary statics.
```kotlin
val isInitialised: Boolean
fun init(context: Context, options: SegmenticOptions)
fun track(event: String, properties: Map? = null)
fun screen(name: String, properties: Map? = null)
fun identify(userId: String, traits: Map? = null)
fun alias(previousId: String)
fun reset()
fun optOut()
fun optIn()
fun isOptedOut(): Boolean
fun registerDevice(tokens: Map, hasGms: Boolean? = null)
fun dismissOnsite()
fun flush()
fun stats(): SegmenticStats?
fun anonymousId(): String?
fun userId(): String?
fun shutdown()
```
A complete example, the way a retail app calls it:
```kotlin title="CartActivity.kt"
import net.segmentic.sdk.android.Segmentic
// A screen view, which is also the moment an in-app message is decided
Segmentic.screen("cart", mapOf("items" to 3))
Segmentic.track(
"product_viewed",
mapOf("product_id" to "DK-991", "price" to 18_500_000, "currency" to "IRR"),
)
// After sign-in: an alias is queued automatically and the anonymous history joins this user
Segmentic.identify(
"u_123",
mapOf("email" to "ali@example.com", "city" to "شیراز"),
)
Segmentic.track("order_completed", mapOf("revenue" to 2_500_000, "currency" to "IRR"))
// Sign-out
Segmentic.reset()
```
Behaviours worth knowing:
- **A blank name is ignored, not thrown.** `track("")`, `screen("")` and `identify("")` write one log line and return. An analytics call must never be the reason a customer's checkout page breaks.
- **The first `identify` queues an `alias` message ahead of itself.** Only when the `userId` differs from the one already stored. Without it every event from before the first sign-in belongs to a stranger, and any funnel crossing the login boundary reports the wrong number for ever after. The detail is in [identity](/en/docs/identity).
- **`reset` mints a new `anonymousId`**, clears the `userId` and drops the session. On a shared phone the next person's purchase must not be credited to the one who just left. It does not clear the "has launched before" flag: signing out does not make somebody a new user, and a first-launch campaign must not reappear after every sign-out.
- **`flush()` returns nothing.** It hands the work to the SDK's network thread and returns immediately. This differs from the web SDK, which returns a `Promise`. If you need to know what happened, read `stats()`.
- **Any method before `init` writes one `Log.w` and returns.** Nothing throws and nothing is buffered.
- **`shutdown()` is not for an app.** It shuts both executors down and nulls the client. It exists for a customer's own instrumented tests: an app that is being killed does not need to tidy up, and the queue is already on disk.
`stats()` returns these nine fields, and `null` before `init`:
| Field | Type | What it is |
|---|---|---|
| `queued` | `Int` | How many messages are waiting on disk right now |
| `sent` | `Long` | How many messages the collector has accepted |
| `dropped` | `Long` | How many were dropped, by a full queue, a full disk, or a permanent server refusal |
| `consecutiveFailures` | `Int` | Consecutive failures |
| `optedOut` | `Boolean` | Whether collection is stopped |
| `durableStorage` | `Boolean` | Always `true` on Android, because `FileStore` is used |
| `anonymousId` | `String` | The current anonymous id |
| `userId` | `String?` | The signed-in user, or `null` |
| `devicePending` | `Boolean` | A device registration has not landed yet and is being retried |
## Threads {#threading}
This is the part a customer feels.
- `track`, `screen`, `identify` and the rest **return immediately**. The disk write happens on a thread called `segmentic-work`, so no analytics call is ever an I/O call on the main thread.
- Network is a **second** thread, `segmentic-net`. A slow or absent collector cannot make a `track()` wait behind a socket.
- Both executors are single-threaded daemons at `Thread.MIN_PRIORITY`. Daemons, because our timer must never be the reason a process stays alive.
- The flush timer is a `scheduleWithFixedDelay` at `flushIntervalMs`, and `init` fires one flush immediately so anything left over from last time goes out at once. A pending device registration is retried in the same pass.
- When the queue reaches `batchSize`, the send starts at once rather than waiting out the interval.
There is exactly one place an exception is swallowed, and it is deliberate: everything that runs on the SDK's threads is inside a try/catch that logs at error level. Those threads live inside the customer's process, and an uncaught throwable on a background thread takes their whole app down. Doing that over a failed analytics write would be indefensible.
## The offline queue {#queue}
On an Iranian mobile network a device stays offline for hours at a time. Every message is written to disk **before any network attempt**, so the app being killed, losing signal and a backend outage cost no data at all.
The stored form is one line per message:
```text
\t\n
```
Two things follow, and both are the point:
- **Nothing is ever parsed.** The message was encoded once, when it was queued, and the same bytes reach the collector. A value that survived encoding cannot be mangled by a round trip through storage.
- **A write cut off halfway costs exactly the last line.** Every complete line before it still loads.
Two checks run at load: a line with no tab is skipped, and a payload that does not both start with `{` and end with `}` is skipped. The second exists because half a message, if sent, is refused by the collector as malformed and takes the whole batch behind it down with it.
**Where it lives.** One file per key, under `filesDir/segmentic/` in your app. Not the cache and not external storage: the OS may clear the cache whenever it likes, and events waiting out an outage are not cache. Losing them is losing the customer's data.
The write goes straight to the destination, with no temporary file and no rename. That looks careless and is a decision: `renameTo` cannot replace an existing file on every Android storage driver, and `java.nio.file.Files.move` needs `API 26` while this SDK's floor is 24. Rather than avoid a truncated write, a truncated write was made harmless.
**When the queue fills, it sheds from the front.** After a long outage the freshest events are the ones still worth having. The count is reported in `stats().dropped` and is never silent.
Three drop reasons, all of which also appear in the debug log:
| Reason | When |
|---|---|
| `queue_full` | `maxQueueSize` was exceeded |
| `storage_full` | the disk write failed, half the buffer was shed and the write retried |
| `storage_unavailable` | the second write also failed. Work continues in memory and what is held is at risk |
**De-duplication.** `message_id` is a UUID generated once, at enqueue time, and reused on every retry. That is the entire basis for a resend being safe: without it, a resend on a weak network doubles the customer's purchase count.
**HTTP responses**, and what the SDK does with each:
| Response | Behaviour |
|---|---|
| `2xx` | Accepted, removed from the queue, the failure counter resets |
| `4xx` except `429` | **Dropped permanently**, acked off the queue, counted in `dropped`, with one log line. A body the server refuses will never be accepted, and keeping it blocks every event behind it |
| `429` | Stays queued and is retried |
| `5xx` | Stays queued and is retried, with backoff |
| code `0` | Means there was no HTTP response at all: no signal, a DNS failure, a captive portal. Kept separate from a real status, because collapsing the two is how an SDK ends up retrying a `400` for ever |
The full list of codes and what they mean is in [errors](/en/docs/errors).
**Backoff** is full jitter: base one second, ceiling five minutes, and the exponent is capped at 20 before it is applied so a device that has been offline for months cannot overflow its way to a negative delay. The jitter matters more than the curve: when the backend recovers, thousands of devices that failed at the same moment must not retry at the same moment and knock it over again.
**`maxRetries` caps the cadence, not the data.** Once the cap is reached the messages stay queued for the next session. The user may simply be on a train.
**Two flushes never run at once.** A second flush returns `0` immediately rather than queueing behind the first, because two concurrent drains would each peek the same messages and send them twice. This also differs from the web SDK, which chains them.
**Network timeouts**: connect ten seconds, read fifteen seconds. Both are set, because a socket with no read timeout on a mobile network can hang for minutes on a half-open connection, and this runs on a thread the SDK owns: hanging it means the queue stops draining with no error anywhere. Response bodies are read to a limit of 8 KiB, because a proxy or a captive portal can answer our POST with a megabyte of HTML.
## What goes on the wire {#wire}
`POST {apiHost}/v1/batch` with `Authorization: Bearer wk_seg_...` and `Content-Type: application/json; charset=utf-8`.
`sent_at` appears both on the envelope of the batch and on every message inside it. That one field is what lets the collector correct a wrong device clock: the offset it measures against its own time is applied to the event timestamps too. A phone whose date is two years out still produces usable data.
`sent_at` is spliced into the front of the already-encoded message rather than the message being re-encoded. The message may have been encoded days ago, and re-encoding it would mean parsing it back, which this module deliberately cannot do. Splicing one known field in is exact, because our own writer always emits `{"type":` first and never puts a space after the brace.
Nulls and empty maps are left out entirely. A batch of twenty events each carrying six empty objects is a bigger body on a metered connection, and the customer pays for that data, not us.
This body is not an invented sample. These are the bytes an Android 15 device put on the wire during the SDK's offline test, kept in the repository as a golden file:
```json title="testdata/android-sdk/batch-tail.json"
{
"sent_at": "2026-08-07T11:01:47.885Z",
"batch": [
{
"sent_at": "2026-08-07T11:01:47.885Z",
"type": "track",
"message_id": "ff6447a2-6cc3-48b2-a429-f83cb07e126d",
"timestamp": "2026-08-07T11:01:10.609Z",
"anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
"event": "scripted_event",
"properties": { "index": 10, "note": "رویداد آزمایشی" },
"context": {
"library": { "name": "segmentic-android", "version": "0.1.0" },
"session_id": "4b3754ac-66cf-4ecf-a700-fc095072c8e5",
"app": { "name": "net.segmentic.sample", "version": "0.1.0" },
"os": { "name": "android", "version": "15" },
"device": {
"type": "android",
"manufacturer": "Google",
"model": "sdk_gphone64_x86_64"
},
"screen": { "width": 320, "height": 640, "density": 1 },
"locale": "en-US",
"timezone": "Asia/Tehran"
}
}
]
}
```
Five message types exist: `track`, `identify`, `screen`, `alias` and `page`. The value `page` is in the wire enum but no public method on Android ever sends it; `page` belongs to the web.
`context.library` and `context.session_id` are always present, and the platform context is merged **under** them, never over. A platform collector must not be able to rename the library that sent the message. There is a hostile test for this: a `platformContext` that deliberately returns a forged `library` and `session_id`, and the test asserts neither value reaches the wire.
What `autoContext` collects: `app` (package name and version), `os`, `device` (manufacturer and model), `screen` (pixels and density), `locale`, `timezone`, and `network` only when your app already holds `ACCESS_NETWORK_STATE`.
Timestamps are computed from the epoch rather than formatted with `SimpleDateFormat`. That class is not thread safe and this is called from whichever thread the customer happened to call `track()` on. A shared instance produces scrambled timestamps under load, which is the kind of bug that only shows up in production and looks like a server problem.
The JSON writer is hand-written, with a depth limit of 32. `NaN` and infinity become `null` rather than an invalid body. Persian text is written through unescaped, and newlines are always escaped, which is what the line-based queue format rests on.
## Your app supplies the push token {#push-token}
This is the thing that usually costs a developer an hour, so it is stated plainly: **this SDK does not fetch push tokens. You hand them in.**
The reason is a decision worth knowing about. An app that sends push already has Firebase, or Bazaar, or Myket, wired up with its own project and its own version of that library. Fetching the token ourselves would mean this SDK picking a Firebase version on your behalf and colliding with yours. So you hand us what your own `onNewToken` gave you.
The whole customer side is nine lines:
```kotlin title="MyMessagingService.kt"
package com.example.shop
import com.google.firebase.messaging.FirebaseMessagingService
import net.segmentic.sdk.PushTransport
import net.segmentic.sdk.android.Segmentic
class MyMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
Segmentic.registerDevice(mapOf(PushTransport.FCM to token))
}
}
```
Call it again every time the provider rotates the token. Registering the same device again is not a duplicate: the server upserts on `device_id`. The rotation case is the one that matters, because a token that rotates and is never re-registered is a user who silently falls out of every campaign, and the only symptom is a delivery rate that drifts down over months.
**Spell the transports exactly this way.** They come from `push.Transport` in the Go code:
```kotlin
object PushTransport {
const val FCM = "fcm"
const val BAZAAR = "bazaar"
const val MYKET = "myket"
const val MQTT = "mqtt"
}
```
Sending anything else is not a typo that gets ignored: the server warns and drops the token, and the customer then sees a campaign that reports every send as successful and delivers nothing. If you send a transport that cannot reach Android, `apns` for instance, the response carries a `transport_not_supported` warning.
Do not send `MQTT`. The constant exists on both sides and the server accepts it on an Android registration, but no provider implements it and nothing is ever delivered over it. Only `fcm`, `bazaar` and `myket` have a sender today. A registration carrying only `mqtt` is stored without a warning and never delivered to, which is the worst of the three outcomes: no error, no warning, just silence.
Several routes on one device are supported, and the server decides which one delivers. A phone sold without Play Services still has Bazaar:
```kotlin
Segmentic.registerDevice(
mapOf(
PushTransport.FCM to fcmToken,
PushTransport.BAZAAR to bazaarToken,
),
)
```
If your app already depends on `play-services-base`, answer authoritatively yourself. The SDK's own probe exists only so the SDK needs no Google dependency:
```kotlin
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
val gms = GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
Segmentic.registerDevice(mapOf(PushTransport.FCM to fcmToken), hasGms = gms)
```
## Device registration {#device-registration}
`POST {apiHost}/v1/devices`, with the same `Authorization: Bearer wk_seg_...` header.
The fields, in the order the SDK writes them:
| JSON field | Always present | Source |
|---|---|---|
| `device_id` | yes | `segmentic_install_id`, a random UUID in the app's private storage |
| `platform` | yes, always `"android"` | hardcoded |
| `user_id` | only when a user is signed in | filled in by the SDK |
| `anonymous_id` | only when known | filled in by the SDK |
| `tokens` | only when non-empty | you |
| `has_gms` | only when known | the package probe, or the value you passed |
| `push_enabled` | only when known | `NotificationManager.areNotificationsEnabled()` |
| `app_version` | when readable | `PackageManager` |
| `manufacturer` | yes | `Build.MANUFACTURER` |
| `model` | yes | `Build.MODEL` |
| `os_name` | yes, always `"android"` | hardcoded |
| `os_version` | yes | `Build.VERSION.RELEASE` |
| `locale` | yes | `Locale.getDefault().toLanguageTag()` |
| `timezone` | yes | `TimeZone.getDefault().id` |
| `sdk_name` | yes, always `"segmentic-android"` | hardcoded |
| `sdk_version` | yes, always `"0.1.0"` | hardcoded |
**Identity is filled in by the SDK, not by the caller**, so the host app cannot register a device against a user id that has since signed out.
A real body, from the same emulator run:
```json title="testdata/android-sdk/device.json"
{
"device_id": "79a1c2c3-a61a-4816-a355-f3d5a0c7ffc2",
"platform": "android",
"anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
"tokens": { "fcm": "scripted-token-not-a-real-one" },
"has_gms": true,
"push_enabled": false,
"app_version": "0.1.0",
"manufacturer": "Google",
"model": "sdk_gphone64_x86_64",
"os_name": "android",
"os_version": "15",
"locale": "en-US",
"timezone": "Asia/Tehran",
"sdk_name": "segmentic-android",
"sdk_version": "0.1.0"
}
```
And a successful response:
```json
{ "status": "ok" }
```
If a token carries the wrong transport, the response is still `200`, but it carries a warning and the usable token is still stored:
```json
{
"status": "ok",
"warnings": [
{
"code": "transport_not_supported",
"field": "apns",
"message": "transport apns cannot deliver to android"
}
]
}
```
The SDK writes those warnings to logcat even when the response is a success. Silence here is what turns a broken install into a campaign that reports 100 percent sent.
**`has_gms` and `push_enabled` are tri-state, and are omitted entirely when unknown.** Not `false`. The server reads a missing value as unknown, and unknown is not false. Sending `false` where we simply did not look would mute a user who never asked to be muted, and the only symptom would be an audience that quietly shrinks.
- `has_gms` is `true` when the Play Services package is found, `false` on `NameNotFoundException` (which is an ordinary Iranian phone, not an error), and `null` on any other exception.
- `push_enabled` is the value of `areNotificationsEnabled()`, and `null` when the `NotificationManager` cannot be reached at all.
**A registration has three outcomes**, and the SDK keeps them apart:
| Result | When | What happens on disk |
|---|---|---|
| `REGISTERED` | `2xx` | the pending record is removed |
| `REFUSED` | `4xx` except `429` | the pending record is removed. The same body would be refused identically on every launch, so retrying is a loop that never ends and never works |
| `PENDING` | `429`, `5xx`, or code `0` | the body is written to `segmentic_pending_device` and resent on **every** flush and **every** subsequent launch |
Why a registration is retried when an event is queued instead: the collector's own comment on the endpoint says the SDK has to retry, because nothing else ever will. An event that arrives late is still the event, but a token that never arrives is a person who agreed to be notified and can never be reached.
`device_id` is deliberately neither the advertising id nor `Settings.Secure.ANDROID_ID`. Both identify a person across unrelated apps, which is a privacy question the customer has to answer rather than us, and Google restricts the first one anyway. This is a random value in the app's own private storage: it lasts until the app is uninstalled or its data is cleared, and it never follows the user anywhere else.
The manual registration path, for every platform with no SDK, is in [devices and push](/en/docs/devices).
## In-app messages {#inapp}
A banner or a modal built in the panel is drawn in the app automatically. The only thing asked of you is this:
```kotlin
Segmentic.screen("cart")
```
A screen view is also the moment an in-app message is decided, because the screen name is what a campaign's rule matches against. There is no option to turn it off.
The rest happens on three threads: the fetch on the network thread, the decision, which is a small disk read, on the same thread, and the drawing on the main thread after whatever delay the campaign asked for.
**Fetching the list.** `GET {apiHost}/v1/onsite?write_key=...`, with an `Authorization` header as well. The key is in both places on purpose: the query form is what makes the response cacheable by a CDN that will not vary on `Authorization`. The list is trusted for 60 seconds, matching the `Cache-Control` the collector sets. A failure is completely silent and the previous list stays in place: this runs inside somebody else's app, and a failure of ours must degrade to "no message today", never to an error their user sees.
**Targeting is evaluated on the device, not on the server.** The alternative is one request per screen view, on the critical path of the customer's app, with our latency in front of their content and our availability in front of their business.
The rules are the web SDK's rules in the same order, with these differences on a phone:
| Server-side rule | What it matches on Android |
|---|---|
| `targeting.url_contains` | the screen name you passed to `screen()` |
| `targeting.url_not_contains` | the same |
| `targeting.devices` | only `mobile` or `tablet` |
| `targeting.delay_seconds` | the delay before drawing, clamped to between zero and 60 seconds |
| `targeting.new_visitors_only` | whether this is this install's first launch |
| `targeting.returning_only` | the inverse |
| `targeting.logged_in` | tri-state: absent means "do not care" |
| `targeting.traits` | the traits from the last `identify` whose values were scalars |
| `targeting.scroll_percent` | **not supported**, silently ignored |
| `targeting.on_exit_intent` | **not supported**, silently ignored |
Matching is always by substring and never by regular expression: a pattern written by a marketer is one that can be catastrophically slow, and this runs on every screen of somebody else's app.
**The device class comes from `smallestScreenWidthDp`, and the break point is 600.** Below it is `mobile`, at or above it is `tablet`. There is no `desktop` on Android. Note that this differs from the web SDK, whose break points are 768 and 1024 CSS pixels. 600 is Android's own break point for the same question, so a campaign behaves the way the app's own layouts do.
**The frequency cap** is applied in this order, each condition taking precedence over the next: outside the `starts_at` and `ends_at` window; then converted, which stops it for ever; then dismissed, provided the campaign is dismissible; then `max_impressions`; then `cooldown_hours`. Zero in the last two means no ceiling.
The cap is applied on the device and on the server, and it has to be both: local storage alone means clearing the app's data gives an uncapped modal, and the server alone means a request per screen view, which is what this whole design exists to avoid.
**What is actually drawn.** The renderer uses plain Views, no Compose and no XML, because a Compose dependency here would be a Compose dependency in every customer's build, including the ones still on Views, and would pick their Compose version for them.
| Campaign kind | On Android |
|---|---|
| `banner` | drawn |
| `modal` | drawn |
| `slidein` | drawn, **as a banner**. Its animation has not been specified yet |
| `survey` | **not drawn** |
A survey needs input widgets and a question flow. The renderer returns `false` for a kind it does not know, and the campaign is then **neither capped nor reported**: when the renderer learns that kind, the campaign is still owed to the user rather than having been silently burned.
The core has a `respond` method for sending a survey answer, and it is tested, but **nothing in the Android layer ever calls it**. Until a survey renderer is written, that method is reachable only by a customer who constructs an `OnsiteManager` themselves.
The message is added to the Activity's own content root rather than shown in a Dialog. A Dialog gets its own window, which on Android means it survives the Activity it belongs to in ways nobody wants, and it does not move with a keyboard or respect the app's insets.
Details you will meet in practice:
- One message at a time. `show()` calls `remove()` first.
- Body text is capped at three lines with an ellipsis. A marketer will eventually paste an essay, and a banner that grows to cover the app is worse than a truncated one.
- A banner sits at the bottom by default, unless `content.position` is `top`. A banner over the app's own toolbar hides navigation, and a user who cannot navigate closes the app.
- The close button has a 48 dp minimum tap target and a `contentDescription` of «بستن».
- Default colours: background `#1F2430`, text `#F5F7FA`, accent `#2F6FED`. An unparseable colour falls back rather than throwing: a marketer typing a colour name into a hex field must not crash the app they are advertising in.
- The modal scrim is clickable even when the campaign is not dismissible, so a tap cannot fall through to the app behind a modal that is covering it.
- The click is reported **before** the link is opened. A link that fails to open is still a click the customer should see in their report.
One real defect, found by looking at a screenshot rather than by reasoning, is worth recording: the first version put a banner flush against the bottom edge and the gesture bar took a bite out of the last line. Two attempts with an inset listener failed, because a listener on a view that is not yet attached is never called, and once attached the dispatch depends on whether every parent passes insets down, which a library cannot assume about somebody else's view hierarchy. The fix reads `rootWindowInsets` directly at draw time. It mattered before and it matters more now, because Android 15 makes every app targeting `targetSdk` 35 draw edge to edge.
**Reporting.** `POST {apiHost}/v1/onsite/event`, with a body of this shape, where `action` is one of `impression`, `dismiss`, `click` or `convert` and `user_id` is omitted when the user is anonymous:
```json
{
"campaign_id": 42,
"action": "click",
"anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
"user_id": "u_123"
}
```
The local consequence is applied **before** the request. The cap has to hold even if the request never arrives, because the alternative is a modal that reappears on every launch for somebody with no signal.
To take the message off the screen yourself:
```kotlin
Segmentic.dismissOnsite()
```
The concept, and building a campaign in the panel, are in [on-site messages](/en/docs/onsite).
## Opting out {#consent}
```kotlin
Segmentic.optOut()
Segmentic.optIn()
val stopped = Segmentic.isOptedOut()
```
`optOut()` does three things: it sets the flag and persists it to `segmentic_opt_out` on disk, it **clears the queue**, and it deletes any pending device registration.
Clearing the queue is the point. Honouring an opt-out only for future events, while quietly delivering what was already captured, is not an opt-out.
After an opt-out: no event is queued, `flush()` returns without any request, `registerDevice` returns `REFUSED` without any request, and no in-app message is drawn. The flag survives the app being closed and reopened, because it lives on disk.
There is no Do Not Track equivalent on Android, correctly: no such OS signal exists. The consent policy and what the panel does with it are in [consent](/en/docs/consent).
## What is not there today {#not-built}
The honest list, so nobody spends an afternoon hunting for something that does not exist:
- **`page()`.** It does not exist on Android. `screen()` is the equivalent.
- **Campaign attribution.** On the web an `sg_mid` click is captured, raises a `message_clicked` event, and rides on every event for seven days. **None of that exists on Android.** A mobile conversion cannot currently be credited to a message.
- **Any helper that obtains a push token.** Only `registerDevice`, and you supply the token.
- **Survey rendering**, and any caller for `OnsiteManager.respond` in the Android layer.
- **Scroll-depth and exit-intent triggers.** They exist on the web, not here.
- **Bazaar and Myket with a real token.** Their token map is accepted and the server knows the routes, but neither has ever been tried with a real token. If you push to those two stores, you are the first.
- **Publication to any repository.** The package is on no public repository; ask us for it.
- **Proof against a real customer app.** The proof that exists is against our own sample. [What is proven, and how](/en/docs/sdk-android#proof) says exactly what it covers.
## What is proven, and how {#proof}
**103 Kotlin tests**, all on a plain JVM:
| File | Count | What it covers |
|---|---|---|
| `SegmenticClientTest.kt` | 33 | wire format, de-duplication, `400` and `429` and `503` and no-signal behaviour, identity, alias, opt-out, buffer overflow, device registration and its three results, option normalisation, and the hostile platform-context test |
| `CoreTest.kt` | 22 | the JSON writer, `ISO 8601` dates, the queue, sessions, backoff |
| `OnsiteTest.kt` | 22 | targeting, the frequency cap, seen-record storage, device class |
| `OnsiteManagerTest.kt` | 20 | fetch, parse, cache, decide, report, and the JSON reader |
| `HttpIntegrationTest.kt` | 6 | against a real socket, a `com.sun.net.httpserver.HttpServer` on localhost |
**Six Go tests over real bytes.** The files under `backend/internal/collector/testdata/android-sdk/` are not fixtures somebody wrote to match the parser. They are the exact bytes an Android 15 device put on the wire: twelve events buffered while nothing was listening on the port, the app killed with force-stop, then delivered after a restart. A recording collector kept the requests verbatim.
Those six tests assert:
- The first batch holds exactly five messages, every message passes the real `model.Normalize` with **zero warnings**, and `sdk_name`, `sdk_version`, `os_name` and `os_version` are what they should be.
- `properties.index` arrives as a number rather than a string. Had the SDK sent zero as text, the numeric map would hold no entry at all and every "greater than" segment over that property would silently never match. And `properties.note` is «رویداد آزمایشی», proving Persian survived Kotlin's encoder, the disk, a process death, the socket and Go's parser.
- The gap between the first event's timestamp and the batch's `sent_at` is more than thirty seconds and less than ten minutes, so the clock correction has real work to do.
- Twelve events at a `batchSize` of five leave a tail of exactly two. A tail of one or three would mean the queue's `peek` or `ack` is wrong.
- The golden device body normalises with zero warnings and the `fcm` token arrives intact.
- `has_gms` and `push_enabled` both arrive as pointers, so the tri-state survives all the way to the server.
**A manual run on an Android 15 emulator**, which the repository does not re-run but which three `adb` commands make repeatable:
```bash
adb shell am start -n net.segmentic.sample/.MainActivity --ei fire 12
adb shell am force-stop net.segmentic.sample
adb shell am start -n net.segmentic.sample/.MainActivity --ez flush true
```
All twelve events arrived in batches of five, five and two, with twelve distinct `message_id` values and none of them twice.
To exercise an in-app message, the extra is `sgscreen` and not `screen`, because `am start` claims the plain name for itself and swallows it silently:
```bash
adb shell am start -n net.segmentic.sample/.MainActivity --es sgscreen cart
```
**Real push**, on the same emulator with a throwaway Firebase project: Firebase gave a 142-character token, `onNewToken` handed it to the SDK, the SDK registered it on `POST /v1/devices`, and a message was sent from FCM v1. With the app in the foreground it reached `onMessageReceived`; with the app backgrounded the system drew the notification itself. Persian title and body both intact.
One thing showed itself there: before notification permission was granted, `push_enabled` went as `false`, and after `pm grant` as `true`. The tri-state reports the system's reality rather than an assumption of ours.
**An in-app message**, against a collector serving one live campaign: a banner with a Persian headline and body was drawn, the impression report reached `POST /v1/onsite/event`, and on a second run the frequency cap stopped it.
---
# Registering a device for mobile push
> Register device tokens for mobile push, browser push and messengers.
> https://segmentic.net/en/docs/devices
## Two paths, and which one you need {#two-paths}
> Diagram: How web subscriptions, FCM tokens and APNs tokens become reachable delivery routes
Device registration is not an event. Events go through the queue because the platform has to absorb bursts of a hundred thousand a second. Registrations are the opposite shape: a few per install per day, and they must be readable immediately. Somebody who opens the app and enters a welcome journey two seconds later has to be reachable at that moment. So `POST /v1/devices` writes straight to the database and never touches the queue.
There are two paths to that row, and neither replaces the other.
| Path | Who it is for | What you fill in yourself |
|---|---|---|
| The Android SDK | An Android app | The token, and `has_gms` if you want to |
| `POST /v1/devices` | iOS, web, desktop, a server-side integration, and any app that will not add a dependency | Every field |
The manual path is not going away. There is no published iOS SDK, a server-side integration has no device to take a token from, and some customers add no library to their app at all. All three land on this one endpoint.
> [!note]
> Both paths talk to the ingest host: `https://in.segmentic.net` with a write key `wk_seg_...`. The write key is public and is meant to ship inside your app bundle. The management host (`https://api.segmentic.net`, with an API key `sk_seg_...`) carries no device route at all: not create, not edit, not delete, and not read. The only place a person's devices can be seen is the panel, described in [reading back a profile's devices](/en/docs/devices#reading-back).
On a local install the same endpoints come up on `http://localhost:8080`.
Device registration is not metered and not quota-checked. `overQuota` and the usage counter are called from `/v1/track` and its family and `/v1/batch`; `POST /v1/devices` calls neither. How often you re-register your devices has no effect on the bill.
## The Android SDK path {#android-sdk}
Your app supplies the token. The SDK does not fetch it. That is deliberate: an app that sends push already has Firebase, or Bazaar, or Myket, wired up with its own project and its own version of that library. Fetching the token ourselves would mean this SDK picking a Firebase version on your behalf and colliding with yours. The consequence is that the whole Android SDK has no dependencies: no JSON library, no HTTP library, no AndroidX, no Firebase. The only permission it adds to your app is `INTERNET`.
Initialise once, then register from wherever the token arrives:
```kotlin
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Segmentic.init(
this,
SegmenticOptions(
writeKey = "wk_seg_...",
apiHost = "https://in.segmentic.net",
),
)
}
}
class MyMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
Segmentic.registerDevice(mapOf(PushTransport.FCM to token))
}
}
```
Several routes on one device, with the server deciding which of them delivers:
```kotlin
Segmentic.registerDevice(
mapOf(
PushTransport.FCM to fcmToken,
PushTransport.BAZAAR to bazaarToken,
),
)
```
`PushTransport` on Android carries four constants: `FCM`, `BAZAAR`, `MYKET`, `MQTT`. There is no `APNS` constant on Android, because Android can never deliver on it.
If your app already depends on `play-services-base`, give the authoritative answer yourself. Left to itself the SDK probes for the `com.google.android.gms` package: found is `true`, absent is `false`, any other exception is `null`, meaning "I could not tell".
```kotlin
val gms = GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
Segmentic.registerDevice(tokens, hasGms = gms)
```
The SDK fills these in so you do not have to: `device_id`, `platform`, `has_gms`, `push_enabled`, `app_version`, `manufacturer`, `model`, `os_name`, `os_version`, `locale`, `timezone`, `sdk_name`, `sdk_version`. Identity (`user_id` and `anonymous_id`) is filled in by the core rather than by the caller, so the host app cannot register a device against a user id that has since signed out.
`device_id` is a random UUID kept in the app's own private storage. Deliberately not the advertising id and not `Settings.Secure.ANDROID_ID`: both identify a person across unrelated apps, which is a question the customer has to answer rather than us. It lasts until the app is uninstalled or its data is cleared.
These are the exact bytes an Android 15 device put on the wire. The fixture is not hand-written; it was captured by a recording collector and is only regenerated from a fresh run on a real device:
```json title="The real bytes from an Android install"
{"device_id":"79a1c2c3-a61a-4816-a355-f3d5a0c7ffc2","platform":"android","anonymous_id":"711faad0-317b-40aa-81d7-253a39280348","tokens":{"fcm":"scripted-token-not-a-real-one"},"has_gms":true,"push_enabled":false,"app_version":"0.1.0","manufacturer":"Google","model":"sdk_gphone64_x86_64","os_name":"android","os_version":"15","locale":"en-US","timezone":"Asia/Tehran","sdk_name":"segmentic-android","sdk_version":"0.1.0"}
```
`registerDevice` has three outcomes and the difference matters:
- `REGISTERED`: stored.
- `REFUSED`: a `4xx` came back. The body is discarded, because the same body would be refused identically next time, and keeping it means posting the same rejected request on every launch for ever. The same value comes back when the user has opted out, and that case sends no request at all.
- `PENDING`: anything else. Written to disk and retried on the next flush and the next app launch.
You do not receive these three values. `Segmentic.registerDevice` runs on the SDK's own network thread and returns nothing; the outcome appears only in logcat under the tag `segmentic`. The core is what returns the value, and the core is what retries.
Calling `registerDevice` again for the same device is not a duplicate; the server upserts on `device_id`. Call it every time the provider rotates the token.
Installation and the rest of the SDK surface are in [the Android SDK](/en/docs/sdk-android).
## Registering a device with `POST /v1/devices` {#register}
One request, one device. There is no batch endpoint.
```bash title="Registering an Android install with two routes"
curl -X POST https://in.segmentic.net/v1/devices \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"device_id": "dev-1",
"user_id": "u_123",
"platform": "android",
"tokens": { "fcm": "fcm-tok", "bazaar": "bazaar-tok" },
"model": "Xiaomi Redmi Note 12",
"timezone": "Asia/Tehran"
}'
```
The `200` response, exactly this and nothing more, because `warnings` is omitted from the body when it is empty:
```json
{"status":"ok"}
```
iPhone is the same endpoint with a different platform and a different route:
```bash title="Registering an iOS install from your own code"
curl -X POST https://in.segmentic.net/v1/devices \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"device_id": "F7A1C2C3-A61A-4816-A355-F3D5A0C7FFC2",
"user_id": "u_9137",
"platform": "ios",
"tokens": { "apns": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" },
"push_enabled": true,
"app_version": "3.4.0",
"model": "iPhone13,2",
"os_name": "ios",
"os_version": "17.4",
"locale": "fa-IR",
"timezone": "Asia/Tehran"
}'
```
```json
{"status":"ok"}
```
The write key can be supplied three ways, all of them valid:
- `Authorization: Bearer wk_seg_...`
- `X-Segmentic-Key: wk_seg_...`
- the query parameter `?write_key=wk_seg_...` (needed for calls such as `sendBeacon` that cannot set a header)
Transport notes: the body is JSON and is parsed regardless of `Content-Type`; the body limit is 5 MiB (`5 << 20` bytes); every response is `application/json; charset=utf-8`. CORS is open (`Access-Control-Allow-Origin: *`, methods `POST, OPTIONS`, headers `Content-Type, Authorization, X-Segmentic-Key`, preflight lifetime `86400`) but credentials are never allowed.
`tenant_id` and `app_id` are ignored if you send them. Both come from the write key. A test posts `"tenant_id": 999` and asserts the device is still stored under the key's real tenant.
## Every field of the body {#fields}
| Field | Type | Required | Behaviour and default |
|---|---|---|---|
| `device_id` | string | yes | Trimmed. Empty, or longer than `256` bytes, rejects the whole request |
| `platform` | string | yes | Parsed case-insensitively and through an alias table. See [platforms](/en/docs/devices#platforms) |
| `user_id` | string | one of the two is required | Trimmed, then truncated at `256` bytes |
| `anonymous_id` | string | one of the two is required | Trimmed, then truncated at `256` bytes |
| `tokens` | object, transport name to token | no, but see the "nothing to store" rule | Keys lower-cased and trimmed, values trimmed. Each token at most `4096` bytes |
| `push_provider` | string | no | The legacy single-route form, paired with `push_token` |
| `push_token` | string | no | The legacy single-route form |
| `has_gms` | boolean or `null` | no | Tri-state. Absent means unknown, which is not `false` |
| `push_enabled` | boolean or `null` | no | Tri-state. Absent means unknown, which is read as allowed |
| `app_version` | string | no | Trimmed, truncated at `256` bytes |
| `manufacturer` | string | no | Trimmed, truncated at `256` bytes |
| `model` | string | no | Trimmed, truncated at `256` bytes |
| `os_name` | string | no | Trimmed, truncated at `256` bytes |
| `os_version` | string | no | Trimmed, truncated at `256` bytes |
| `locale` | string | no | Trimmed, truncated at `256` bytes |
| `timezone` | string | no | Trimmed, truncated at `256` bytes. This is what makes "send at 9am" and quiet hours mean the recipient's own clock |
| `sdk_name` | string | no | Trimmed, truncated at `256` bytes |
| `sdk_version` | string | no | Trimmed, truncated at `256` bytes |
Truncation cuts on a character boundary, so a Persian field is never left as invalid UTF-8. The test measures it with 256 copies of «ش».
The legacy single-route form still works and is not going away, because customers pin SDK versions for years and an upgrade must never be the condition of continuing to receive:
```bash title="The legacy form, still accepted"
curl -X POST https://in.segmentic.net/v1/devices \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"device_id": "dev-old",
"user_id": "u_123",
"platform": "android",
"push_provider": "bazaar",
"push_token": "legacy"
}'
```
```json
{"status":"ok"}
```
If you send both forms, the legacy pair is applied first and `tokens` then writes over it, so the richer map wins.
### The "nothing to store" rule {#nothing-to-store}
An empty `tokens` is not an error on its own. It depends on `push_enabled`.
- No token and `push_enabled` absent or `true`: a `400` with `device: registration carries no usable token`. The call achieved nothing, so storing it would only grow the table.
- No token and `push_enabled: false`: a `200`, and the row is stored. That is a real state change: the user switched notifications off and it has to be recorded.
The second case is exactly what you send after the user turns notifications off in the OS settings:
```bash title="The user switched notifications off"
curl -X POST https://in.segmentic.net/v1/devices \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"device_id": "dev-1",
"user_id": "u_123",
"platform": "android",
"push_enabled": false
}'
```
```json
{"status":"ok"}
```
## Platforms and the transports that reach them {#platforms}
`platform` is required and has no default. Leaving it out is a `400`.
| Platform | Accepted spellings | Transports accepted at registration | Can a campaign actually deliver? |
|---|---|---|---|
| `android` | `android` | `fcm`, `bazaar`, `myket`, `huawei`, `mqtt` | Yes, over `fcm`, `bazaar`, `myket` or `huawei` |
| `ios` | `ios`, `iphone`, `ipad` | `apns`, `mqtt` | Yes, over `apns` |
| `web` | `web`, `browser`, `webapp` | `webpush` | Yes, over `webpush` |
| `windows` | `windows`, `win`, `win32`, `win64` | `webpush` | No. It has no default route order |
| `macos` | `macos`, `mac`, `mac os`, `osx`, `darwin` | `webpush` | No. It has no default route order |
| `linux` | `linux` | `webpush` | No. It has no default route order |
| `server` | `server`, `backend`, `api` | none | Registration is refused with `400` |
Names are parsed case-insensitively after trimming, so `" Android "` resolves to `android`. The same applies to `tokens` keys: `" FCM "` becomes `fcm`.
The three desktop platforms are deliberately not folded into `web`, so that "how many macOS installs do we have" stays answerable. They exist at all because a real customer had them and the import failed.
> [!warn]
> `windows`, `macos` and `linux` register but do not receive push today. The route-order table has entries only for `android`, `ios` and `web`, and the router produces no route for a platform with no entry. At send time the result is `push: no usable transport for this device`.
`server` is refused rather than stored. A backend has no device at all, and a row here that no push could reach would be counted in every "reachable" figure a customer sees. In practice that means `platform: "api"` or `platform: "backend"` gets a `400`.
An unknown platform and an unknown transport behave completely differently:
- An unknown platform (`blackberry`, `symbian`) rejects the whole request. Nothing is stored.
- An unknown transport (`pigeon`) drops only that token with a `transport_not_supported` warning, and the rest of the registration proceeds.
The `mqtt` transport is accepted and stored and never delivers. The constant is defined and it is in the default order, but there is no MQTT provider implementation anywhere in the repository, and the router drops a transport with no configured provider. Do not build on `mqtt`.
## Token shapes, per transport {#tokens}
| Transport | What to send | Server-side cleaning |
|---|---|---|
| `fcm` | The string `onNewToken` gave you | Trimmed only |
| `apns` | Lower-case hex | Trimmed, leading and trailing `<` and `>` stripped, all spaces removed, lower-cased |
| `bazaar` | The token Cafe Bazaar's push service gave you | Trimmed only |
| `myket` | The token Myket's push service gave you | Trimmed only |
| `huawei` | The token HMS Push Kit gave you | Trimmed only |
| `webpush` | The browser subscription's `endpoint` URL | Trimmed only |
| `mqtt` | Accepted, never delivered | Trimmed only |
The APNs cleaning is not cosmetic. Older iOS APIs stringify the token as ``, and sending that verbatim is refused by Apple for every message for ever, while the campaign reports a hundred percent sent. The test measures it: `" "` becomes `a1b2c3d4e5f6`. Note that this cleaning is applied to transport `apns` only.
There is no other validation of token shape. The only two checks are that it is non-empty after trimming and no longer than `4096` bytes. No prefix, no character set, no length range. A malformed FCM token is accepted here and refused by Google at send time.
If Apple's SDK hands you `Data`, convert it to hex yourself. Stringifying it with `description` produces exactly the `` shape:
```swift title="Converting an APNs token to a string correctly"
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
register(apnsToken: hex) // your own POST /v1/devices call
}
```
## Notification permission and Play Services: both tri-state {#permission}
A field being absent is not the same as it being `false`, and these two fields are where that difference costs you money and audience.
`push_enabled` is the OS-level notification permission:
| Value in JSON | Meaning | Effect on targeting |
|---|---|---|
| Field absent, or `null` | Unknown; the SDK is too old to report it | Read as allowed |
| `true` | The user granted the permission | Allowed |
| `false` | The user switched notifications off in settings | Excluded from every campaign |
Unknown is read as allowed on purpose: muting a user because their app is out of date would silently shrink every audience. The delivery query carries `AND d.push_enabled` and the two lookup indexes are partial on the same condition, so the exclusion is enforced in two places.
The storage nuance you will meet: the column is `push_enabled BOOLEAN NOT NULL DEFAULT TRUE`, so the tri-state is collapsed on write. On insert it is `COALESCE($8, TRUE)`, on update `COALESCE($8, devices.push_enabled)`. Unknown becomes `true` for a new row and leaves an existing row untouched, because the SDK staying silent must not erase what a newer build already told us.
`has_gms` is the SDK's report on whether Google Play Services looked healthy:
| Value in JSON | Meaning | Effect |
|---|---|---|
| Absent or `null` | The SDK did not look | The router assumes it is probably there and still tries FCM |
| `true` | Play Services looked healthy | FCM is first choice |
| `false` | No Play Services | FCM is skipped entirely at send time, and registration emits a `fcm_without_gms` warning |
The FCM token is stored even when `has_gms: false`, because Play Services can be installed later and throwing the token away would make that recovery impossible.
> [!danger]
> Do not send `false` when you mean "I did not check". Leave the field out. `push_enabled: false` removes the device from every campaign, and `has_gms: false` disables FCM for that device until a later registration says otherwise.
Do not send `has_gms` on iOS at all. It is meaningless there, and the iOS SDK does not send it either.
## Warnings {#warnings}
A warning means something was accepted and altered. The `warnings` array rides along on a `200` and on a `400`. There are exactly four codes.
| `code` | `field` | When | What happens to the token |
|---|---|---|---|
| `empty_token` | the transport name | The token was empty or whitespace only | Dropped |
| `token_too_long` | the transport name | The token was longer than `4096` bytes | Dropped |
| `transport_not_supported` | the transport name | The transport cannot physically reach that platform, or the transport name is unknown entirely | Dropped |
| `fcm_without_gms` | `fcm` | An FCM token on a device that reported `has_gms: false` | Kept |
Their `message` strings, in the same order: `token was empty and has been ignored`, `token exceeds the maximum length and has been ignored`, `transport cannot deliver to `, `device reports no Play Services; FCM will not be used for it`.
This example is real. An Android install that sent both an APNs token and an FCM token:
```bash title="One right token and one wrong one"
curl -X POST https://in.segmentic.net/v1/devices \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"device_id": "dev-1",
"user_id": "u",
"platform": "android",
"tokens": { "apns": "wrong", "fcm": "right" }
}'
```
The response is `200`. The usable token was stored and the other was not:
```json
{"status":"ok","warnings":[{"code":"transport_not_supported","message":"transport apns cannot deliver to android","field":"apns"}]}
```
> [!warn]
> Read the `200` body, not just its status code. A wrong token is dropped quietly and this array is the only place it shows. That is the difference between a customer debugging a zero delivery rate for a week and seeing the problem the first time they call the endpoint.
## Rejections and status codes {#errors}
The `message` on a `400` is the validation error text verbatim, and the warnings ride along on failure too. Without them, somebody sending an APNs token from an Android build sees only "no usable token" and has nothing to go on.
| Status | `message` | When |
|---|---|---|
| `400` | `device: device_id is required` | `device_id` missing, blank after trimming, or longer than `256` bytes |
| `400` | `device: platform must be one of android, ios, web, windows, macos, linux` | `platform` missing, unknown, or resolving to `server` |
| `400` | `device: user_id or anonymous_id is required` | Both are blank |
| `400` | `device: registration carries no usable token` | No usable token is left and notifications were not explicitly declared off |
| `400` | `malformed JSON` | The body is not JSON |
| `401` | `missing write key` | No key in a header or the query string |
| `401` | `invalid write key` | Unknown, revoked or suspended key |
| `413` | `request body too large` | The body exceeded 5 MiB |
| `503` | `cannot verify the write key right now; retry` | The key lookup itself failed. Carries `Retry-After: 5` |
| `503` | `temporarily unavailable, please retry` | Writing the device to the database failed |
| `405` | (no JSON body) | This deployment has no device store, so the route was never registered |
A full `400`, from the test that posts platform `ios` with an `fcm` token. The key order is this: `status`, then `warnings`, then `message`:
```json
{"status":"error","warnings":[{"code":"transport_not_supported","message":"transport fcm cannot deliver to ios","field":"fcm"}],"message":"device: registration carries no usable token"}
```
> [!danger]
> Read `401` as permanent and `503` as transient, exactly as the SDKs do. A failing key lookup answers `503`, not `401`, and the reason was measured rather than reasoned about: when this path answered `401`, with the database scaled to zero, eight of eight events came back `401`, so an infrastructure outage destroyed events at the customer's end while their own logs told them their API key was invalid.
Three traps that catch a developer on the first try:
- A `device_id` longer than `256` bytes reports `device_id is required`, even though you sent one. The message is misleading.
- `POST /v1/devices/unregister` answers a malformed body with that same `device_id is required`. Your broken JSON is reported as a missing id.
- A `405` on `/v1/devices` in staging is a configuration fact, not a problem with your payload. With no device store wired the route is never registered, and the `OPTIONS /v1/` pattern claims every path under `/v1/`, so the multiplexer knows the path but not the method.
Every response from these endpoints, successful or not, carries an `X-Segmentic-Trace` header: sixteen hex characters. It is repeated in no body, so it is lost if you do not log it, and it is the only thing that lets us trace one request.
The full error contract is in [errors](/en/docs/errors).
## The same device again, the same token elsewhere {#same-device}
A push token identifies a delivery route, not a device. Everything in this section follows from that one sentence.
**Registering the same device again.** Register is an upsert on `(tenant_id, device_id)`, and two rules are enforced in the store rather than in the caller:
- Never erase. A field the SDK did not send keeps its stored value. Every string column goes through `COALESCE(NULLIF(EXCLUDED.x, ''), devices.x)`. SDKs report device facts from several places at different times, so treating silence as "clear it" would have the FCM token wipe the Bazaar one on every app open.
- Take the token. If another install holds the same token, it lost it.
Also, `last_seen_at` always advances, and `revoked_at` is set back to `NULL` because a reinstall brings a revoked device back to life. A token that had been retired has `retired_at` cleared, because a retired token coming back means the app was reinstalled and the route is live again.
**The same token on a different device.** This is the most important dedup rule here. After a restore-from-backup or a reinstall, the provider can hand the same token to a new `device_id`. If both rows survive, every campaign delivers twice to that person, and it looks like a bug in the customer's app rather than in ours. So registration deletes any other holder of the token before inserting:
```sql
DELETE FROM device_tokens
WHERE tenant_id = $1 AND transport = $2 AND token = $3 AND device_id <> $4
```
That delete runs inside the same transaction as the upsert, and a unique index is what makes it non-negotiable rather than best-effort:
```sql
CREATE UNIQUE INDEX idx_device_tokens_unique
ON device_tokens (tenant_id, transport, token)
WHERE retired_at IS NULL
```
**Two users on one phone.** The device row is keyed by the install, not by the person. One phone sees several accounts over its life and one person has several phones; keying by user would keep pushing an old account's messages to whoever signs in next. On the next `POST /v1/devices` carrying a new `user_id`, the upsert overwrites `user_id`: the later sign-in wins. Detaching the previous account is what [sign-out](/en/docs/devices#unregister) does, and if you never call it, the previous account stays attached until the next registration.
**One user with many devices.** Bounded in two places.
- A cap on how many installs one person receives on: 5 by default, from `DELIVERY_DEVICES_PER_USER`. Someone who has upgraded their phone five times still holds five rows, and without a cap they get the same notification five times, which reads as spam and is the fastest way to lose a push permission.
- Stale installs are dropped: 180 days by default, from `DELIVERY_STALE_DEVICE`.
The cap counts devices, not token rows. A phone with three transports is one recipient. The ordering is `last_seen_at DESC`, newest install first.
**Anonymous installs.** A registration carrying only `anonymous_id` is accepted and stored, but it is targeted by no campaign today. The delivery query selects on `d.user_id = ANY($2)` only, and there is no code path in the store that reads devices by `anonymous_id`. So push to a not-yet-identified install does not exist. If you want a welcome campaign for a visitor who has not signed up, there is no answer for it today: [identification](/en/docs/identity) has to happen first.
## Sign-out and uninstall {#unregister}
Signing out is not uninstalling. They are two different things and one endpoint separates them with one flag.
It matters more than it looks. On a shared phone, leaving the previous account attached means the next person receives somebody else's order updates, the kind of defect that ends a contract.
| Field | Type | Required | Meaning |
|---|---|---|---|
| `device_id` | string | yes | The install to act on |
| `user_id` | string | no | When present, detaches only if that user is currently attached |
| `revoked` | boolean | no, default `false` | `false` is sign-out, `true` marks the install as gone |
```bash title="Sign-out"
curl -X POST https://in.segmentic.net/v1/devices/unregister \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"device_id": "dev-1", "user_id": "u_123"}'
```
```json
{"status":"ok"}
```
```bash title="The install is gone"
curl -X POST https://in.segmentic.net/v1/devices/unregister \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"device_id": "dev-1", "revoked": true}'
```
```json
{"status":"ok"}
```
This is exactly what happens in the database. Sign-out:
```sql
UPDATE devices SET user_id = '', last_seen_at = now()
WHERE tenant_id = $1 AND device_id = $2
AND ($3 = '' OR user_id = $3)
```
The row survives deliberately. The token is still valid, the person may sign back in, and deleting the row would make the next registration look like a brand new install, which corrupts install and reactivation counts. An empty `user_id` in the request means "detach whoever is attached".
And uninstall:
```sql
UPDATE devices SET
revoked_at = now(),
revoked_reason = 'unregistered',
first_uninstalled_at = COALESCE(first_uninstalled_at, now())
WHERE tenant_id = $1 AND device_id = $2 AND revoked_at IS NULL
```
The reason is `unregistered` rather than `uninstalled`, because this is the SDK calling unregister, which in practice is a sign-out far more often than a deletion. Counting the two together would make every logout look like churn on the uninstall report.
Neither call retires the tokens. A revoked device is kept out of sends by `d.revoked_at IS NULL`.
Responses: `200` with `{"status":"ok"}`; `400` with `{"status":"error","message":"device_id is required"}` when the body is not JSON or `device_id` is empty; `503` with the transient message when the write fails; and the same `401` and `503` authentication answers as the section above.
## The browser equivalent: a web push subscription {#webpush}
A browser has no token, it has a subscription. The web equivalent of `POST /v1/devices` is two separate endpoints.
```bash title="Storing a browser subscription"
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_9137",
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
"p256dh": "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
"auth": "tBHItJI5svbpez7KI4CCXg"
}
}'
```
```json
{"status":"ok"}
```
The flat shape is accepted too, with `endpoint`, `p256dh` and `auth` at the top level. The nested shape exists because it is the Push API's own shape: a page can post what the browser handed it without picking it apart first and, more to the point, without re-encoding the keys. Base64 that a well-meaning helper decoded and re-encoded is the classic way a subscription silently stops decrypting on the recipient's machine.
| Field | Type | Required | Meaning |
|---|---|---|---|
| `user_id` | string | yes | The subscription is stored against a person |
| `endpoint` | string | yes | The push service URL for this browser install. Possession of it is sufficient to send, which is why it is treated as a secret and never returned to a client |
| `p256dh` | string | yes | The browser's public key, base64url, an uncompressed P-256 point |
| `auth` | string | yes | A 16-byte shared secret the browser generated |
All four are required. Missing any of them is a `400` with `{"status":"error","message":"user_id and a complete subscription are required"}` and nothing is stored: an endpoint with no keys is unusable, the payload cannot be encrypted, and storing it would show up as a permanently failing recipient rather than as a bad integration. The request's `User-Agent` header is captured and stored alongside.
Unsubscribing needs only the `endpoint`:
```bash title="Unsubscribing"
curl -X POST https://in.segmentic.net/v1/webpush/unsubscribe \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"endpoint": "https://fcm.googleapis.com/fcm/send/abc123"}'
```
```json
{"status":"ok"}
```
No `user_id` is required and none is checked, and the reason is explicit: the endpoint is the subscription's own secret, possession of it is already sufficient to send to that browser, so demanding more before allowing somebody to stop receiving would be protecting the wrong direction.
> [!danger]
> The web push channel today needs a device row as well as a subscription. The dispatcher consults the device registry for the `push` and `webpush` channels, and when it finds nothing it suppresses the message with reason `not_reachable` before the web push sender is ever called. Browser subscriptions live in a separate table (`webpush_subscriptions`), so a visitor who called only `POST /v1/webpush/subscribe` counts as unreachable today. The check is unconditional: it runs on a deployment with no device store wired at all, and there every push and every web push is suppressed here without exception.
The working instruction for today is to register the same browser as a device as well, with `platform: "web"` and the endpoint as the `webpush` token:
```bash title="The same browser, as a device row"
curl -X POST https://in.segmentic.net/v1/devices \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"device_id": "browser-9f31",
"user_id": "u_9137",
"platform": "web",
"tokens": { "webpush": "https://fcm.googleapis.com/fcm/send/abc123" }
}'
```
```json
{"status":"ok"}
```
Write this with your eyes open: no test covers the web push channel through the dispatcher. The web push sender is tested directly, the full campaign path is not.
The rest of the web surface, including the service worker, why it has to be served from the origin root, and where the VAPID public key comes from, is in [the web SDK](/en/docs/sdk-web).
## Messengers: Bale, Eitaa, Rubika {#messengers}
A chat id is not an address, it is a consent signal. None of these three platforms lets a bot message somebody who has not started the conversation themselves. That makes the id closer to a double opt-in than to a phone number taken off an order form.
They matter for a simple reason: a large part of the Iranian audience is reachable on them and nowhere else. Push needs working Play Services or an app install, SMS costs money per message and is capped by the operator, and email is barely used by Iranian consumers.
```bash title="Linking a chat id"
curl -X POST https://in.segmentic.net/v1/messenger/link \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_9137",
"platform": "bale",
"chat_id": "44120099",
"source": "bot_start"
}'
```
```json
{"status":"ok"}
```
| Field | Type | Required | Default |
|---|---|---|---|
| `user_id` | string | yes | |
| `platform` | string | yes, one of `bale`, `eitaa`, `rubika` | |
| `chat_id` | string | yes | |
| `username` | string | no | Stored, and never erased by a later link |
| `source` | string | no | `bot_start` |
`source` records how the id was obtained. It defaults to `bot_start`, the only route that carries real consent; anything else is worth being able to find later. A user who typed `/start` into the bot is a different consent story from one whose id arrived in a CSV.
Unlinking needs only `user_id` and `platform`:
```bash title="Unlinking"
curl -X POST https://in.segmentic.net/v1/messenger/unlink \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"user_id": "u_9137", "platform": "eitaa"}'
```
```json
{"status":"ok"}
```
Rejections: `400` with `{"status":"error","message":"user_id, chat_id and a known platform are required"}` on link, and `{"status":"error","message":"user_id and a known platform are required"}` on unlink. A platform such as `telegram` is refused here; the column carries a `CHECK`, and an unrecognised value would fail in Postgres with an error nobody can act on.
Linking again is the only way a block is cleared: `blocked_at` and `blocked_reason` are set to `NULL` and nothing else clears them. An automatic retry that decided a block had lapsed would be messaging somebody who left.
> [!warn]
> There is no bot-update webhook. The webhook intake accepts only the integration sources (Digikala, Basalam, Torob, ZarinPal, WooCommerce, Shopify, Segment) and none of them is a messenger. So you run the bot yourself: your bot receives `/start`, your backend maps the chat id to your own user id, and your backend calls `POST /v1/messenger/link`.
## What happens when a campaign targets push {#send-time}
The order of the steps is itself a decision.
**Governance first, devices second.** Consent, frequency caps, quiet hours and holdout assignment all run before the device registry is touched. A person who opted out must not have their profile read, their devices listed, or their content rendered.
**The registry is queried only for channels that address an install** rather than a person, which is `push` and `webpush`. SMS and email find their recipient from the profile, so having no device is meaningless for them.
The delivery query is this:
```sql
SELECT d.user_id, d.device_id, d.platform, d.has_gms,
d.app_version, d.locale, d.timezone, d.last_seen_at,
t.transport, t.token
FROM devices d
JOIN device_tokens t
ON t.tenant_id = d.tenant_id AND t.device_id = d.device_id AND t.retired_at IS NULL
WHERE d.tenant_id = $1
AND d.user_id = ANY($2)
AND d.revoked_at IS NULL
AND d.push_enabled
AND d.last_seen_at >= $3
ORDER BY d.user_id, d.last_seen_at DESC, d.device_id
```
So a device is invisible to a campaign if it holds no live token, it was revoked, its `push_enabled` is `false`, it has not been seen for 180 days, it falls beyond the fifth-newest install, or it has no `user_id` at all.
**Having no device is not a failure.** The outcome is `suppressed` with reason `not_reachable`. The person exists and is willing; we simply have no way to reach them, and saying so is what makes a reach report actionable rather than mysterious.
**One person receives one notification.** The devices are walked newest first, stopping at the first that accepts. Sending to every install would mean a user who has upgraded their phone twice gets the same message three times, which reads as spam whatever the content says.
For each device the router picks a transport. The preference order:
| Platform | Tried in this order |
|---|---|
| `android` | `fcm`, then `bazaar`, then `myket`, then `huawei`, then `mqtt` |
| `ios` | `apns`, then `mqtt` |
| `web` | `webpush` |
FCM is first because when Play Services works it is the fastest and cheapest route. Bazaar and Myket come next because they cover precisely the devices FCM cannot. `mqtt` is last and has no provider today, so it is always skipped.
That list is filtered by three things: the device actually holds a token for that transport, a provider is configured for it, and, for FCM, the device did not report `has_gms: false`. Zero routes gives a `rejected` result with the text `push: no usable transport for this device`.
Each route's answer decides what happens next:
| Provider result | What happens |
|---|---|
| `sent` | Returns, and success is recorded on the breaker |
| `invalid_token` | The next route is tried, and that token is retired |
| `unavailable` or `rate_limited` | The next route is tried, and the failure counts against the breaker |
| `rejected` | Returns immediately. A rejected payload will be rejected everywhere, and trying another transport only wastes quota |
A dead token on one transport says nothing about the others, and falling through to the next route is the case that recovers the audience FCM cannot reach.
**A dead token is retired, and the last one revokes the install.** When a provider says the token is invalid, that token gets `retired_at`. If the device then has no live token left, the device itself is revoked with `revoked_reason = 'uninstall_detected'`. This is the only realistic uninstall signal there is: an app cannot call unregister while it is being removed, so without it every tenant's install base only ever grew. Retired rather than deleted, so that the same token coming back on the next registration is recognised as a reinstall.
**The circuit breaker.** 10 consecutive failures open a transport for 30 seconds. If every route was skipped as unavailable, the result is `unavailable` with the text `every transport was unavailable`.
Every push carries the attribution identifiers, and where they ride differs per transport:
| Transport | Where the identifiers ride |
|---|---|
| `fcm` | In `message.data`: `sg_mid`, `sg_cid`, `sg_jid`, `sg_link`, `sg_t` |
| `bazaar`, `myket` and `huawei` | In `data`: `sg_mid`, `sg_link`, `sg_t`. No campaign or journey id. Huawei takes this map as a JSON string rather than an object |
| `apns` | At the top level of the payload, next to `aps`: `sg_mid`, `sg_link`, `sg_t` |
| `webpush` | Inside the encrypted JSON: `mid`, `tkn`, `url` |
The identifiers also ride on the link itself, which is why push click tracking needs no redirect service of ours: the person lands on the customer's own site, where the SDK is already present. The customer's own UTM parameters go on the same link.
## Reading back a profile's devices {#reading-back}
This is a card on the panel's profile screen, not an endpoint your API key can reach. The route is `GET /v1/profiles/{user_id}/devices` and it is registered only on the dashboard control plane, a listener that is deliberately not addressable from outside the internal network. The same path on `https://api.segmentic.net` falls to the catch-all and answers `404 unknown_endpoint`.
The panel calls it with your session cookie, through its own proxy:
```http
GET /api/proxy/v1/profiles/u_9137/devices
```
```json
{
"devices": [
{
"platform": "android",
"transports": "bazaar,fcm",
"app_version": "3.4.0",
"model": "Xiaomi Redmi Note 12",
"timezone": "Asia/Tehran",
"push_enabled": true,
"revoked": false,
"last_seen": "2026-08-05T09:14:22Z"
}
]
}
```
The permission is `profile.read`. Somebody who may look at a person's traits and event history is already reading the more sensitive half.
| Field | Type | Notes |
|---|---|---|
| `platform` | string | |
| `transports` | string | Comma separated. One install with two tokens is one phone, not two. Retired tokens are listed here too, so a transport in this string is not necessarily a live route |
| `app_version` | string | Omitted when empty |
| `model` | string | Omitted when empty |
| `timezone` | string | Omitted when empty |
| `push_enabled` | boolean | |
| `revoked` | boolean | |
| `last_seen` | string | RFC3339 in UTC |
The ordering is `last_seen_at DESC` with a limit of `50` rows. Revoked installs are included on purpose: "you uninstalled the app on the 3rd" is the answer to «چرا پوش نمیگیرم؟», and hiding the row leaves the question unanswerable.
Errors: `400` with `user_id is required`, and `503` when the query fails.
A right-to-be-forgotten request deletes `device_tokens` first and then the `devices` row, because the tokens hang off `device_id` and would otherwise be pushed to for ever. The detail is in [privacy](/en/docs/privacy).
## What does not exist {#absent}
Written out plainly, because an honest gap is cheaper than a plausible sentence that costs you an afternoon.
- Any management-host (`sk_seg_...`) device route at all, reads included. `GET /v1/profiles/{user_id}/devices` is registered only on the dashboard control plane and is not routed publicly, so an API key cannot list one person's devices.
- Any MCP tool relating to devices or push.
- An MQTT provider. The constant exists, registration is accepted, nothing is ever sent.
- A batch device-registration endpoint. One request, one device.
- Any `GET` or `DELETE` form of these endpoints. They are all `POST` with a JSON body.
- A default route order for `windows`, `macos` and `linux`. They register and they do not receive.
- Any lookup of devices by `anonymous_id`. An anonymous install is stored and targeted by no campaign.
- A bot-update webhook that would link a Bale, Eitaa or Rubika chat id automatically.
- Any validation of FCM, Bazaar or Myket token shape beyond non-empty and no longer than `4096` bytes.
- The `apns-topic` header on APNs requests, and with it `apns-push-type`, `apns-expiration`, `apns-priority` and `apns-collapse-id`. Apple requires `apns-topic` for token-based authentication, so this is a real gap rather than a documentation nuance. In practice it means `ttl`, `collapse_key` and `priority` are honoured on FCM and ignored on APNs.
- Per-tenant APNs credentials. The channel catalogue carries only `fcm`, `bazaar`, `myket` and `huawei` under `push`, so iOS push runs on the deployment's own APNs key for every tenant on it.
- A verified Bazaar or Myket send with a real store-issued token. The token mapping is accepted but was never exercised with a real one, because both need a developer account and a published app.
- A dispatcher-level test of the web push channel.
---
# Sending events server to server
> For what only your backend is certain of: a captured payment, a shipped order, a cancelled subscription.
> https://segmentic.net/en/docs/server
Two endpoints take events. They are on different hosts, they take different credentials, and only one of them de-duplicates. Choosing the wrong one is not a style question: it decides whether a retried purchase is counted once or twice.
## Two doors, and they are not the same door {#two-doors}
| | Collector | Management API |
|---|---|---|
| URL | `https://in.segmentic.net/v1/batch` | `https://api.segmentic.net/v1/events` |
| Credential | write key, `wk_seg_...` | API key, `sk_seg_...` |
| Permission | none. The write key is the authorisation | `profile.write` |
| Array key in the body | `batch` | `events` |
| Success | `200` | `202` |
| De-duplicates on `message_id` | yes | no |
| Returns warnings | yes | no, they are discarded |
| Backdating limit | the tenant's own events retention | fixed 30 days |
| Batch-level `context` and `sent_at` | yes | no such fields |
| Maximum items | 500 | 500 |
| Maximum body | 5 MiB | 8 MiB |
| Costs request budget | no | yes, 5 units of 600 per minute |
The write key is public by design. It ships inside the customer's own JavaScript and inside their Android app, and the platform is built on the assumption that anyone can read it. A `wk_` key in a public bundle is working as designed; an `sk_` key in the same place is an incident. Using the write key from your backend is therefore not a downgrade in security: it is the same credential doing the same job, from a machine instead of a phone.
The management key is the opposite. It carries a role, it can create segments and send campaigns, and it must never leave your servers. Presenting one to the collector does nothing useful, and presenting a write key to the management API is refused with its own code so the mistake is obvious:
```json
{
"error": {
"code": "write_key_rejected",
"message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
}
}
```
## Which one your backend should use {#which-one}
Use the collector's `POST /v1/batch`.
The reason is de-duplication. Your order pipeline will retry. A timeout at the load balancer, a redeploy mid-request, a worker that crashes after the POST and before it marks the row as sent: each of these ends with the same batch being posted twice. The collector records `message_id` in Redis with `SET NX` and a 48 hour window, so the second delivery is answered `200` and never reaches the warehouse. `POST /v1/events` does not do this. It hands the envelopes straight to the queue. A retried batch of a hundred orders becomes two hundred orders, and the first sign of it is a revenue figure nobody can reconcile.
The second reason is the backdating window. The collector reads the tenant's own events retention and allows a timestamp that far back. `POST /v1/events` does not set that option, so the fixed 30 day default applies, and anything older is silently moved to exactly 30 days ago. See [Timestamps](/en/docs/server#timestamps).
Use `POST /v1/events` when your integration already holds a management key and adding a second credential to your deployment is the harder problem, or when the caller is an agent that is already talking to `api.segmentic.net` for segments and reports. Accept that you are then responsible for not sending the same event twice.
> [!note]
> Both doors reach the same pipeline and the same warehouse tables. Nothing downstream can tell which one an event came through, except that events sent through `POST /v1/events` carry `app_id` of `0` and have no IP or user agent attached.
## The collector: POST /v1/batch {#batch}
The key goes in `Authorization: Bearer`. Two other forms are accepted, because a browser beacon cannot set headers: `X-Segmentic-Key: wk_seg_...` and `?write_key=wk_seg_...`. From a server, use the header. Only the exact string `Bearer ` (capital B, one space) is stripped from `Authorization`; any other scheme falls through to the other two forms and then fails as a missing key.
Every item carries its own `type`. On the single-event endpoints (`/v1/track`, `/v1/identify` and the rest) the path decides the type and a `type` in the body is ignored, but on `/v1/batch` there is no path to read it from, so an item with a missing or unrecognised `type` is rejected. The five accepted values are `track`, `identify`, `alias`, `page`, `screen`.
```bash title="A batch of two events"
curl -sS https://in.segmentic.net/v1/batch \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"context": { "locale": "fa-IR" },
"batch": [
{
"type": "track",
"message_id": "order-8821-completed",
"event": "order_completed",
"user_id": "u_44120",
"timestamp": "2026-08-07T09:12:41Z",
"properties": {
"order_id": "8821",
"revenue": 4800000,
"currency": "IRR",
"city": "تهران"
}
},
{
"type": "identify",
"message_id": "profile-44120-v7",
"user_id": "u_44120",
"traits": { "phone": "09123456789", "city": "تهران" }
}
]
}'
```
```json title="200 OK"
{ "status": "ok", "accepted": 2 }
```
Batch-level `context` and `sent_at` are copied onto any item that did not carry its own, and never over one that did. This exists so a mobile SDK can send the device model once for fifty events, and it is equally useful from a server for a shared `context.campaign`.
The fields of an item:
| Field | Type | Required | Notes |
|---|---|---|---|
| `type` | string | yes on batch | `track`, `identify`, `alias`, `page`, `screen` |
| `message_id` | string | no, but send one | at most 256 bytes. Generated with a warning when absent |
| `event` | string | required when `type` is `track` | at most 128 bytes after normalisation |
| `user_id` | string | one of `user_id` or `anonymous_id` | at most 256 bytes |
| `anonymous_id` | string | one of `user_id` or `anonymous_id` | at most 256 bytes. No format rule, it need not be a UUID |
| `previous_id` | string | required when `type` is `alias` | the id being merged from. Unlike every other id it has no length bound at all, only the body cap |
| `timestamp` | RFC 3339 | no | defaults to the server's receive time |
| `sent_at` | RFC 3339 | no | enables clock skew correction. See below before you set it |
| `properties` | object | no | at most 256 keys, key at most 128 bytes, string value at most 8192 bytes |
| `traits` | object | no | at most 256 keys, same bounds |
| `context` | object | no | shape documented on [Events](/en/docs/events) |
`timestamp` and `sent_at` are decoded by Go's JSON library into a time, which accepts RFC 3339 and nothing else. Epoch seconds, epoch milliseconds and a bare `2026-08-07` all fail to decode, and the whole request is answered `400 malformed JSON` rather than the one item being rejected.
Revenue is read from the properties, in this order: the first non-zero of `revenue`, `total`, `value`; failing that, `price` multiplied by `quantity`, where `quantity` defaults to 1. `currency` defaults to `IRR` and is uppercased, so `"irt"` is stored as `IRT`. No conversion is ever applied.
Every status this endpoint can return:
| Situation | Status | Body |
|---|---|---|
| Accepted, in whole or in part | `200` | `{"status":"ok","accepted":N,...}` |
| No key on the request | `401` | `{"status":"error","message":"missing write key"}` |
| Unknown, revoked, or a suspended account | `401` | `{"status":"error","message":"invalid write key"}` |
| Our key lookup failed | `503` with `Retry-After: 5` | `{"status":"error","message":"cannot verify the write key right now; retry"}` |
| Body over 5 MiB | `413` | `{"status":"error","message":"request body too large"}` |
| Body is not JSON | `400` | `{"status":"error","message":"malformed JSON"}` |
| `batch` is empty | `400` | `{"status":"error","message":"batch_empty"}` |
| More than 500 items | `400` | `{"status":"error","message":"batch_too_large: 501 items, limit 500"}` |
| Account over its monthly ceiling | `402` | `{"status":"error","message":""}` |
| The bus and the disk buffer both failed | `503`, no `Retry-After` | `{"status":"error","message":"temporarily unavailable, please retry"}` |
Unknown, revoked and suspended collapse into one `401` on purpose, so the endpoint cannot be used to find out which keys exist.
The `402` message is always Persian. The collector has no locale middleware, so `Accept-Language: en` has no effect on it. Branch on the status code, not on the text.
## The management API: POST /v1/events {#events}
```bash title="Two events through the management API"
curl -sS https://api.segmentic.net/v1/events \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"type": "track",
"message_id": "order-8821-completed",
"event": "order_completed",
"user_id": "u_44120",
"timestamp": "2026-08-07T09:12:41Z",
"properties": { "order_id": "8821", "revenue": 4800000, "currency": "IRR" }
},
{
"type": "track",
"event": "order_completed",
"properties": { "order_id": "8822" }
}
]
}'
```
```json title="202 Accepted"
{
"accepted": 1,
"rejected": [ { "index": 1, "reason": "missing_identity" } ]
}
```
`202` and not `200`, because the events are queued rather than stored. They become queryable seconds later. A `200` would invite you to read them back immediately and conclude they were lost.
The item shape is the same envelope the collector takes. What differs is the wrapper: the key is `events`, and there are no batch-level `context` or `sent_at` fields. Anything you would have put there has to be repeated on every item.
This route is registered only when the deployment has an importer configured. When it is not, the path falls to the catch-all and answers `404 unknown_endpoint`. `GET /v1/capabilities` reports it as `features.ingest`.
The permission is `profile.write`, which the `owner`, `admin` and `marketer` roles carry and `analyst` does not. A refusal names the permission it wanted, so you do not have to open a support ticket to find out:
```json
{
"error": {
"code": "forbidden",
"message": "this key does not carry profile.write, see GET /v1/whoami for what it does carry",
"need": "profile.write"
}
}
```
Every status this endpoint can return:
| Situation | Status | `error.code` |
|---|---|---|
| Accepted, in whole or in part | `202` | none |
| No credential, or one that did not resolve | `401` | `unauthenticated` |
| A `wk_` write key was sent | `401` | `write_key_rejected` |
| The key has passed its expiry date | `401` | `key_expired` |
| The key lacks `profile.write` | `403` | `forbidden` |
| `events` is empty | `400` | `batch_empty` |
| Body is not JSON | `400` | `malformed_json` |
| More than 500 events | `413` | `batch_too_large`, with `details` of `{"limit":500,"sent":N}` |
| Every event failed validation | `422` | `all_events_rejected`, with the per-item array as `details` |
| Account over its monthly ceiling | `402` | `quota_cancelled`, `quota_trial_over`, `quota_event_cap` or `quota_message_cap` |
| The request budget for this key is spent | `429` with `Retry-After: 60` | `budget_exhausted` |
| The budget backend errored | `503` | `budget_unavailable` |
| The queue was unavailable | `503` | `ingest_unavailable` |
> [!warn]
> `error` is not always an object on this API. Ten of its routes reuse a dashboard handler and answer `{"error":"some string"}` instead: the two `GET /v1/schema/*` reads, `POST /v1/audiences/count`, the segment list and the segment read, the campaign list and the campaign read, the two `POST /v1/reports/*` routes, and `POST /v1/messages`. `POST /v1/events` itself always uses the object form, but a client that shares one error parser across the whole API must branch on the type of `error` before reading `error.code`.
## Batching and the maximum {#size}
500 items per request, on both doors. It is a hard bound: 501 items is refused whole, and nothing in the batch is stored. The refusal on the management API publishes the limit in its `details`, so a client sizing its loop does not have to find the number by bisection.
The body cap is separate and is hit first by wide events rather than by many of them. 5 MiB on the collector, 8 MiB on the management API. 500 events with 256 properties each will exceed 5 MiB long before it exceeds 500 items.
There is no compression. Neither endpoint reads `Content-Encoding`, so a gzipped body arrives as bytes that are not JSON and is answered `400`. If your events are large, send more requests rather than bigger ones.
There is no `Content-Type` enforcement either. The body is read and parsed as JSON whatever you declare. Send `application/json` anyway, so that a proxy in between does not decide otherwise.
Below the limit, batch size is a throughput decision and nothing else. A 500 item batch on the collector costs exactly one Redis round trip and one produce round trip, the same as a 2 item batch, which is why batching is worth doing at all.
## Partial failure, and reading the per-item errors {#partial-failure}
One bad item does not sink a batch. Both endpoints validate every item, keep the good ones, and name the bad ones by their index in the array you sent.
The collector returns counts plus an `errors` array:
```json title="200 OK, one item rejected"
{
"status": "ok",
"accepted": 2,
"rejected": 1,
"errors": [ { "index": 1, "reason": "missing_identity" } ]
}
```
The management API returns the array itself under `rejected`:
```json title="202 Accepted, one item rejected"
{
"accepted": 2,
"rejected": [ { "index": 1, "reason": "missing_identity" } ]
}
```
The same word means two different things. On the collector `rejected` is a count and the detail is in `errors`; on the management API `rejected` is the detail. A parser written for one will read the other as zero failures.
`index` is the index into the array you sent, not into the accepted subset. That is deliberate: the events being rejected may have no id yet, which is often half the reason they are being rejected, so the index is the only way to find them again.
`reason` starts with a stable code. The full list:
| Code | Meaning |
|---|---|
| `unknown_type` | `type` was absent or is not one of the five |
| `missing_identity` | neither `user_id` nor `anonymous_id` was set |
| `missing_event_name` | `type` is `track` and `event` was empty |
| `event_name_too_long` | over 128 bytes after normalisation |
| `event_name_invalid_chars` | the name contains a control character |
| `id_too_long` | `user_id`, `anonymous_id` or `message_id` is over 256 bytes |
| `missing_previous_id` | `type` is `alias` and `previous_id` was empty |
One of these arrives with the offending value appended, `unknown_type`, because knowing the code without the value sends you back to your own logs. An item with `"type": "trak"` produces the reason `unknown_type: "trak"`, not `unknown_type`. The other six always arrive bare, and none of them names the field or the value that was wrong.
> [!danger]
> Match `reason` by prefix, or split on the first `": "`. An equality test against `"unknown_type"` will not fire on `unknown_type: "trak"`, which is precisely the case you wrote the alert for.
When every item fails, the two doors part company again. The collector answers `200` with `accepted` absent from the body altogether, because the field is omitted when zero. The management API answers `422 all_events_rejected` and puts the whole array in `error.details`, on the reasoning that an integration's error handling branches on status and a wholly rejected batch is a caller bug that needs to surface.
Warnings are different from errors: the item was accepted and something about it was changed. Only the collector returns them, capped at roughly 50 per response. `POST /v1/events` computes them and throws them away, so a missing `message_id` there is silently generated and you are never told.
| Warning code | What happened |
|---|---|
| `generated_message_id` | no `message_id` was sent; retries of this event cannot be de-duplicated |
| `timestamp_in_future` | more than an hour ahead of the server; clamped to the receive time |
| `timestamp_too_old` | older than the ingest window; clamped to the edge of it |
| `too_many_properties` | over 256 properties; the extras were dropped |
| `too_many_traits` | over 256 traits; the extras were dropped |
| `unserialisable_property` | one property could not be encoded and was dropped. `field` names it |
| `invalid_phone` | the `phone` trait is not a valid Iranian mobile number; it is stored exactly as sent and no `phone_operator` is derived |
| `invalid_national_id` | the `national_id` trait failed its check digit and was not stored at all |
## message_id, and why a server has to set it {#idempotency}
`message_id` is the only thing that makes a retry safe.
The collector keys de-duplication on the pair of your account and the `message_id`, with `SET NX` in Redis and a 48 hour window. The first delivery publishes. Every repeat inside that window is answered `200 {"status":"ok","accepted":1}`, identical to the first, and nothing new reaches the warehouse. It is also not billed: a retrying client costs a Redis lookup, not an invoice line. Duplicates inside a single batch are caught too, so a batch that accidentally carries the same order twice stores it once.
The response deliberately does not tell you that an event was a duplicate. A client told "duplicate" would treat it as an error and keep retrying, which is the exact loop the window exists to end.
If you send no `message_id`, one is generated for you and you get a `generated_message_id` warning. That warning is not decoration. It means this event has no retry protection at all, and the count it feeds will drift upward every time your network has a bad afternoon.
Derive the id from something your own database already guarantees is unique, and make it deterministic, so that the retry computes the same string as the first attempt:
| Event | A good `message_id` |
|---|---|
| an order was paid | `order-8821-completed` |
| an order was refunded | `order-8821-refunded` |
| a shipment status changed | `shipment-4471-delivered` |
| a nightly profile sync | `profile-44120-2026-08-07` |
Never use a random UUID generated at send time. A retry generates a different one and the de-duplication has nothing to work with. Never use a timestamp for the same reason.
The rules on the value: it is trimmed, it must be at most 256 bytes, and there is no other constraint. It is scoped to your account, so it cannot collide with another customer's.
`POST /v1/events` does not de-duplicate at all. It reads `message_id`, validates its length, stores it on the event, and never checks whether it has seen it before. Send one anyway, so that a duplicate can be found and removed later, but do not expect the platform to stop it.
## Timestamps, backdating, and the clock skew rule {#timestamps}
Omit `timestamp` and the event is stamped with the server's receive time. For an event your backend is emitting as it happens, that is correct and is one less field to get wrong.
Set `timestamp` when the event happened at a different moment from the one you are sending it: a batch job that drains an outbox table every ten minutes, a payment confirmed by a gateway callback that took an hour to arrive, a migration of last year's orders.
Three rules apply, in this order.
More than one hour in the future is clamped to the receive time, with a `timestamp_in_future` warning. A timestamp ahead of now can only come from a wrong clock, and letting it through would put events into reporting periods a customer has already read and closed.
Older than the ingest window is clamped to exactly the edge of the window, with a `timestamp_too_old` warning. It is never refused. On live ingest a phone with a broken clock should not lose its events. The consequence for a backfill is severe and worth stating plainly: send two years of history through a 30 day window and every event lands on the same instant, accepted, with an HTTP success, and the first sign of it is a funnel that makes no sense months later.
The window is not a global constant. On the collector it is the tenant's own events retention: an account that keeps events for ever gets 3650 days, an account that has set 90 days gets 90, and an account on the 30 day floor still gets the full 30 day default. On `POST /v1/events` the window is not set at all, so the fixed 30 day default always applies, whatever your retention says.
> [!danger]
> If you are migrating history, use the collector's `POST /v1/batch` and check your account's events retention first. `POST /v1/events` will clamp everything older than 30 days without refusing anything.
Then there is `sent_at`, and it is the field most likely to ruin a backfill.
`sent_at` exists for a phone whose clock is wrong. The SDK reports when it thinks it sent the batch; the server compares that with when it actually arrived; the difference is applied to the event time. A device two hours slow that claims an event at 08:00 and a send at 10:00, arriving at 12:00, has its event stored at 10:00. The correction only fires when the difference exceeds one minute, and only if the corrected time still lands inside the window. No warning is emitted.
Your server's clock is right. So either omit `sent_at`, or set it to the moment you actually send. What you must not do is copy your `timestamp` into `sent_at`, which is a natural thing to write and is catastrophic: for a three day old event, the difference between "sent" and "arrived" is computed as three days, that difference is added to the event time, and the event lands at now. Your entire backfill collapses onto today.
```json title="Backdating done correctly: no sent_at at all"
{
"batch": [
{
"type": "track",
"message_id": "order-7702-completed",
"event": "order_completed",
"user_id": "u_39900",
"timestamp": "2026-05-14T11:02:00Z",
"properties": { "order_id": "7702", "revenue": 1250000 }
}
]
}
```
## Which status codes are safe to retry {#retries}
| Status | Retry? | Why |
|---|---|---|
| `200` / `202` | no | it worked. Read the per-item errors before moving on |
| `400` | no | the payload is malformed and will be malformed again |
| `401` | no | the credential is wrong. Retrying makes a log entry, not a fix |
| `402` | no | the account is over its ceiling. Nothing changes until somebody pays |
| `403` | no | the key lacks `profile.write`. Someone must issue a different key |
| `404` | no | on the management API this means the ingest route is not served here |
| `413` | no | the batch is too big. Split it; retrying the same body cannot pass |
| `422` | no | every event failed validation. Read `error.details` |
| `429` | yes, after `Retry-After: 60` | management API only, the budget for this key is spent |
| `503` | yes | ours, not yours. See below |
| a transport error with no response | yes | the request may never have arrived |
`402` deserves its own note because it looks retryable and is not. The account is past a ceiling it asked for. Retrying achieves nothing until an invoice is paid or the billing period rolls over. It is also not your integration's bug and should not be logged as one.
On neither door does the `402` carry numbers. The amount used and the ceiling are computed on our side and then thrown away, so "how far over am I" can only be asked of the billing screen, never of this response.
The whole batch is refused on `402`, never a prefix. A partial accept would leave you unable to tell which items to resend.
For everything retryable, back off exponentially and cap the wait. There is no rate limiting on the collector at all, which means a retry storm will be accepted rather than throttled, which in turn means the only thing protecting you from your own loop is your own loop.
## The 503, and why dropping it costs you the data {#unavailable}
`503` is the one status that means the failure is on our side.
This was not always so, and the reason it is now is worth reading, because the same mistake is easy to make in a client. The collector used to answer `401` when its own key lookup failed. An SDK reads `401` as "this key will never work", stops, and throws the events away; it reads `503` as "try again later" and keeps them. So a database outage silently destroyed events at the customer's end, while their own logs told them their API key was invalid, which is the wrong thing to go and debug. Measured rather than reasoned about: with the database scaled to zero, eight of eight events came back `401`.
Treat `503` the same way. Keep the batch, wait, send it again. Do not log it as a validation failure and do not discard it.
Log the `X-Segmentic-Trace` header alongside it: sixteen hex characters, set by both doors on every response and repeated in no error body. It is the only thing that lets us trace one specific request. Set the header yourself and, as long as the value is hex of a sane length, that value comes back instead.
There are two `503`s on the collector and you can tell them apart from the response alone:
| | `Retry-After` | Message | What it means |
|---|---|---|---|
| Key lookup failed | `5` | `cannot verify the write key right now; retry` | The request never reached the pipeline. Retrying the identical body is exactly right |
| Publish failed | absent | `temporarily unavailable, please retry` | Both the event bus and the local disk buffer failed |
The second one is rare, because a bus outage on its own does not produce it: the collector writes to a local write-ahead log and replays it when the bus returns. It takes a failure of both to get here.
> [!danger]
> On a publish-failure `503` the `message_id` has already been recorded in the de-duplication window. A retry carrying the same `message_id` inside the next 48 hours is answered `200 {"status":"ok","accepted":1}` and is not stored: the collector cannot tell that retry apart from a genuine duplicate. To get that event in, retry it with a different `message_id`, and accept that this one event has no retry protection. This is the only case where reusing the id is wrong.
On the management API, `503 ingest_unavailable` means the queue was unavailable. Retry the batch. Because that endpoint does not de-duplicate, a `503` after a partial publish will double-count whatever did get through, which is one more reason to prefer the collector for anything with money in it.
`503 budget_unavailable` is a different failure with the same status: the budget backend errored and the API fails closed rather than letting an unmetered client loop. Retry with backoff. It affects every route on `api.segmentic.net`, including `GET /v1/whoami`.
## Ordering {#ordering}
One guarantee, and it is worth knowing exactly how far it goes.
Every event is placed on the bus with a partition key of its `user_id`, or its `anonymous_id` when there is no `user_id`. All events for one person therefore land on one partition, and the consumers that hold state per person (profile updates, journey state, session stitching) see them in the order the bus accepted them, without any cross-partition coordination.
What follows from that, and what does not:
- Within one batch, items are produced in array order, so two events for the same person in one batch keep their order.
- Across two requests, order is the order the requests were accepted. Two concurrent requests from two workers have no order between them.
- Changing the identity changes the partition. An event sent with `anonymous_id` and a later one sent with `user_id` are on different partitions and have no order relative to each other. This is what `alias` and `identify` are for; see [Identity](/en/docs/identity).
- Ordering is not preserved through a bus outage. Events buffered into the local write-ahead log are replayed on a timer, roughly every five seconds, so an event accepted during the outage can reach the bus after events accepted afterwards.
If the order of two events matters to a report, do not rely on the order they were sent. Set `timestamp` on both.
## The monthly ceiling and the request budget {#quota}
Two different limits, on two different doors, refusing with two different status codes.
The monthly event ceiling applies to both. It is checked once for the whole batch, before any per-item work, and refuses with `402`. It fails open: if the check itself errors the batch is accepted, because losing a customer's data when a database blinks is a far larger incident than a runaway bill.
The request budget applies only to `api.segmentic.net`. It is weighted, not counted, because one call reads a struct and the next scans a warehouse. `POST /v1/events` costs 5 units. The allowance is 600 units a minute, scoped per API key rather than per account, so a runaway agent cannot exhaust the budget your order pipeline depends on. That is 120 ingest calls a minute per key, or 60000 events a minute at the maximum batch size.
The window is a fixed calendar minute, not a sliding one, and there are no `X-RateLimit-*` headers on the budget. You cannot ask how much of it you have left, and `GET /v1/whoami` does not report it. When it is spent you get `429` with `Retry-After: 60`, which is conservative: the minute may roll sooner.
The collector has no request budget and no rate limiting of any kind. The only volume control on it is the monthly ceiling.
## Your User-Agent, and the bot flag {#user-agent}
The collector takes the caller's IP and `User-Agent` from the connection, never from the body, so that a client cannot fake its own geo or device. From a browser this is the only way to know what the visitor is on. From your backend it means the event is stamped with your data centre and your HTTP client.
That is mostly harmless. `browser_name` will read `python-requests` or `Go-http-client`, which is untidy and tells you nothing you did not know.
One case is not harmless. The user agent parser flags a request as a bot when the `User-Agent` string contains a URL, and separately when the parsed name contains the substring `bot`. Every report, every dashboard tile and the segment compiler filter on `is_bot = 0`. So a well-behaved integration that politely identifies itself the way an HTTP client is supposed to:
```text
User-Agent: myshop-orders/1.0 (+https://myshop.ir)
```
produces events that are stored, are billed, are visible in the raw user timeline, and are invisible in every report and in every segment. Nothing errors. The events are not there when the marketing team looks.
Send a plain token with no URL in it, and avoid the substring `bot` in the name:
```text
User-Agent: myshop-orders/1.0
```
Omitting the header entirely also works: with no `User-Agent` the parser is never run and none of the device fields are touched. `POST /v1/events` never reads the header at all, so this whole section does not apply to it.
## What does not exist {#gaps}
Written down because finding out by experiment costs an afternoon.
- No compression. Neither endpoint reads `Content-Encoding`, so a gzipped body is answered `400 malformed JSON`.
- No de-duplication on `POST /v1/events`, and no idempotency key either. `Idempotency-Key` is honoured on exactly one endpoint on the whole API, `POST /v1/messages`, and nowhere else.
- No warnings from `POST /v1/events`. They are computed and discarded.
- No way to read an event back, edit one, or delete one. There is no `GET /v1/events`. Corrections are made by sending a compensating event.
- No `Retry-After` on the publish-failure `503` from the collector. Only the key-lookup `503` carries one.
- No rate limiting on the collector: no per-second cap, no burst control, no per-account concurrency limit.
- No backfill endpoint reachable from `api.segmentic.net`. `POST /v1/import/events`, which refuses an out-of-window timestamp instead of clamping it, is registered only on the internal dashboard API and is not routed publicly. The collector's `/v1/batch` and the tenant's own retention window are the only route for old data.
- No IP geolocation in the deployed build. `country`, `region` and `city` are populated only from what you send in `context.location`.
- No CORS preflight on `api.segmentic.net`. A browser cannot call the management API; that is what the write key and the collector are for.
- No `X-RateLimit-Limit` or `X-RateLimit-Remaining` on the request budget.
- No English on the collector's `402`. It is always Persian.
- No live event debugger for batched traffic. The dashboard's debugger records single-event posts and webhook events; `/v1/batch` does not feed it, so a server integration that batches will see nothing there.
## A complete Go program {#example-go}
Posts a batch to the collector, retries the failures that are ours, and reads the per-item errors. Standard library only.
```go title="main.go"
// Sends completed orders to Segmentic from a Go backend.
//
// export SEGMENTIC_WRITE_KEY=wk_seg_...
// go run main.go
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
const (
ingestURL = "https://in.segmentic.net/v1/batch"
maxBatchSize = 500
)
type envelope struct {
Type string `json:"type"`
MessageID string `json:"message_id"`
Event string `json:"event,omitempty"`
UserID string `json:"user_id"`
Timestamp *time.Time `json:"timestamp,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
Traits map[string]any `json:"traits,omitempty"`
}
// No sent_at field. It is for correcting a wrong device clock, and setting it
// from a server whose clock is right can only move timestamps that were
// already correct.
type batch struct {
Batch []envelope `json:"batch"`
Context map[string]any `json:"context,omitempty"`
}
type itemError struct {
Index int `json:"index"`
Reason string `json:"reason"`
}
type reply struct {
Status string `json:"status"`
Accepted int `json:"accepted"`
Rejected int `json:"rejected"`
Errors []itemError `json:"errors"`
Message string `json:"message"`
}
// errRetryable marks a failure that a second attempt can fix.
var errRetryable = errors.New("segmentic: temporarily unavailable")
// errBurnt marks the one 503 where the message_id has already been consumed:
// the publish failed after de-duplication recorded the ids, so an identical
// retry is answered 200 and stored nowhere.
var errBurnt = errors.New("segmentic: publish failed; message ids are spent")
func post(client *http.Client, key string, events []envelope) (reply, error) {
if len(events) > maxBatchSize {
return reply{}, fmt.Errorf("segmentic: %d events, limit %d", len(events), maxBatchSize)
}
body, err := json.Marshal(batch{
Batch: events,
Context: map[string]any{"locale": "fa-IR"},
})
if err != nil {
return reply{}, err
}
req, err := http.NewRequest(http.MethodPost, ingestURL, bytes.NewReader(body))
if err != nil {
return reply{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
// A plain token. A User-Agent containing a URL makes the parser flag the
// event as a bot, and every report filters bots out.
req.Header.Set("User-Agent", "myshop-orders/1.0")
res, err := client.Do(req)
if err != nil {
// The request may never have arrived, so the ids are still free.
return reply{}, fmt.Errorf("%w: %v", errRetryable, err)
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return reply{}, fmt.Errorf("%w: %v", errRetryable, err)
}
var out reply
if err := json.Unmarshal(raw, &out); err != nil {
return reply{}, fmt.Errorf("segmentic: unreadable reply, status %d: %s", res.StatusCode, raw)
}
switch res.StatusCode {
case http.StatusOK:
return out, nil
case http.StatusServiceUnavailable:
if res.Header.Get("Retry-After") != "" {
// The key lookup failed. Nothing reached the pipeline.
return out, fmt.Errorf("%w: %s", errRetryable, out.Message)
}
return out, fmt.Errorf("%w: %s", errBurnt, out.Message)
default:
// 400, 401, 402 and 413 all say the same thing on a second attempt.
return out, fmt.Errorf("segmentic: %d %s", res.StatusCode, out.Message)
}
}
func main() {
key := os.Getenv("SEGMENTIC_WRITE_KEY")
if key == "" {
log.Fatal("SEGMENTIC_WRITE_KEY is not set")
}
paidAt := time.Now().UTC().Add(-45 * time.Minute)
events := []envelope{
{
Type: "track",
// Derived from the order, so a retry computes the same string.
MessageID: "order-8821-completed",
Event: "order_completed",
UserID: "u_44120",
Timestamp: &paidAt,
Properties: map[string]any{
"order_id": "8821",
"revenue": 4800000,
"currency": "IRR",
"city": "تهران",
},
},
{
Type: "identify",
MessageID: "profile-44120-v7",
UserID: "u_44120",
Traits: map[string]any{
"phone": "09123456789",
"first_name": "سارا",
"city": "تهران",
},
},
}
client := &http.Client{Timeout: 15 * time.Second}
var out reply
var err error
for attempt := 1; attempt <= 5; attempt++ {
out, err = post(client, key, events)
if err == nil || !errors.Is(err, errRetryable) {
break
}
wait := time.Duration(1< str:
"""The only timestamp format the ingest endpoint decodes."""
return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def chunks(items, size):
for start in range(0, len(items), size):
yield items[start:start + size]
def post_batch(session: requests.Session, key: str, events: list) -> dict:
"""POST one batch. Returns the decoded reply or raises."""
if len(events) > MAX_BATCH:
raise ValueError(f"{len(events)} events, limit {MAX_BATCH}")
response = session.post(
INGEST_URL,
# No sent_at anywhere: these events are backdated, and sent_at would
# be read as clock skew and move every one of them to now.
json={"batch": events, "context": {"locale": "fa-IR"}},
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
# No URL in the token: a User-Agent containing one makes the
# event count as bot traffic, which every report filters out.
"User-Agent": "myshop-outbox/1.0",
},
timeout=20,
)
try:
body = response.json()
except ValueError:
body = {"message": response.text[:500]}
if response.status_code == 200:
return body
if response.status_code in RETRYABLE:
raise ConnectionError(f"{response.status_code}: {body.get('message')}")
raise RuntimeError(f"{response.status_code}: {body.get('message')}")
def send_with_retries(session, key, events, attempts=5):
for attempt in range(1, attempts + 1):
try:
return post_batch(session, key, events)
except (ConnectionError, requests.RequestException) as exc:
if attempt == attempts:
raise
wait = 2 ** attempt
print(f"attempt {attempt} failed ({exc}); waiting {wait}s", file=sys.stderr)
time.sleep(wait)
def main() -> int:
key = os.environ.get("SEGMENTIC_WRITE_KEY")
if not key:
print("SEGMENTIC_WRITE_KEY is not set", file=sys.stderr)
return 1
# Stand-in for the rows your own outbox query returns.
now = datetime.now(timezone.utc)
orders = [
{"id": 8821, "user": "u_44120", "rial": 4800000, "paid": now - timedelta(hours=3)},
{"id": 8822, "user": "u_39900", "rial": 1250000, "paid": now - timedelta(hours=2)},
{"id": 8823, "user": "", "rial": 990000, "paid": now - timedelta(hours=1)},
]
events = [
{
"type": "track",
# Deterministic, so a retry produces the same id and the second
# delivery is de-duplicated instead of counted again.
"message_id": f"order-{order['id']}-completed",
"event": "order_completed",
"user_id": order["user"],
"timestamp": rfc3339(order["paid"]),
"properties": {
"order_id": str(order["id"]),
"revenue": order["rial"],
"currency": "IRR",
},
}
for order in orders
]
session = requests.Session()
failures = 0
for part in chunks(events, MAX_BATCH):
reply = send_with_retries(session, key, part)
print(f"accepted {reply.get('accepted', 0)}, rejected {reply.get('rejected', 0)}")
for problem in reply.get("errors", []):
# Prefix match: unknown_type arrives as 'unknown_type: "trak"'.
code = problem["reason"].split(": ", 1)[0]
bad = part[problem["index"]]
print(f" {bad['message_id']}: {code}", file=sys.stderr)
failures += 1
for note in reply.get("warnings", []):
print(f" warning {note['code']}: {note.get('note', '')}", file=sys.stderr)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
```
## A complete PHP program {#example-php}
The management API path, for a backend that already holds an `sk_seg_` key. Needs only `ext-curl` and `ext-json`.
```php title="send_events.php"
MAX_BATCH) {
throw new RuntimeException(count($events) . ' events, limit ' . MAX_BATCH, 413);
}
$payload = json_encode(
['events' => $events],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
$curl = curl_init(EVENTS_URL);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
],
]);
$raw = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($raw === false) {
// No response at all. Treated as retryable: the request may never
// have arrived.
throw new RuntimeException('transport: ' . $error, 503);
}
$body = json_decode($raw, true);
if (!is_array($body)) {
throw new RuntimeException('unreadable reply: ' . substr($raw, 0, 300), $status);
}
if ($status === 202) {
return $body;
}
// error.code is the contract. error.message is prose and will be reworded.
// On ten other routes of this API, error is a plain string instead, so
// read its type before reaching into it.
$code = is_array($body['error'] ?? null)
? ($body['error']['code'] ?? 'unknown')
: (string) ($body['error'] ?? 'unknown');
throw new RuntimeException($code, $status);
}
$key = getenv('SEGMENTIC_API_KEY');
if ($key === false || $key === '') {
fwrite(STDERR, "SEGMENTIC_API_KEY is not set\n");
exit(1);
}
// No batch-level context or sent_at on this endpoint: those fields exist only
// on the collector, so anything shared has to be repeated per item.
$events = [
[
'type' => 'track',
'message_id' => 'order-8821-completed',
'event' => 'order_completed',
'user_id' => 'u_44120',
'timestamp' => gmdate('Y-m-d\TH:i:s\Z', time() - 1800),
'properties' => [
'order_id' => '8821',
'revenue' => 4800000,
'currency' => 'IRR',
'city' => 'تهران',
],
'context' => ['locale' => 'fa-IR'],
],
[
'type' => 'identify',
'message_id' => 'profile-44120-v7',
'user_id' => 'u_44120',
'traits' => ['phone' => '09123456789', 'city' => 'تهران'],
'context' => ['locale' => 'fa-IR'],
],
];
$attempt = 0;
while (true) {
$attempt++;
try {
$reply = segmenticSend($key, $events);
break;
} catch (RuntimeException $e) {
// 429 carries Retry-After: 60. 503 is ours. Everything else is fixed
// by changing the request, not by repeating it.
$retryable = in_array($e->getCode(), [429, 503], true);
if (!$retryable || $attempt >= 5) {
fwrite(STDERR, 'segmentic refused: ' . $e->getCode() . ' ' . $e->getMessage() . "\n");
exit(1);
}
$wait = $e->getCode() === 429 ? 60 : 2 ** $attempt;
fwrite(STDERR, "attempt {$attempt}: {$e->getMessage()}; waiting {$wait}s\n");
sleep($wait);
}
}
printf("accepted %d\n", $reply['accepted'] ?? 0);
foreach ($reply['rejected'] ?? [] as $item) {
// Prefix match: unknown_type arrives as 'unknown_type: "trak"'.
$code = explode(': ', $item['reason'], 2)[0];
$bad = $events[$item['index']]['message_id'] ?? '(no message_id)';
fwrite(STDERR, "rejected {$bad}: {$code}\n");
}
```
## Local development {#local}
The collector listens on `http://localhost:8080` and serves `/v1/batch` there with no change to the payload.
The management API is a different matter. It is served on the address in `PUBLIC_API_ADDR`, which is empty by default, so on a fresh install the public API is not served at all. Nothing listens, and the first symptom is a connection refused that looks like a networking problem. Set it, restart, and check with `GET /v1/status` before assuming anything else is wrong.
```bash title="Is the public API up?"
curl -sS http://localhost:8082/v1/status
```
```json
{ "status": "ok", "service": "api", "version": "dev" }
```
`GET /v1/status` is the only route on the management API that needs no credential and that survives a Redis outage. Everything else, including `GET /v1/whoami`, goes through the request budget, and the budget fails closed.
## Where to go next {#next}
- [Events](/en/docs/events) for the envelope in full, including the whole `context` object.
- [The event dictionary](/en/docs/event-dictionary) for the standard names and properties that make the built-in funnels work without configuration.
- [Identity](/en/docs/identity) for `user_id`, `anonymous_id` and `alias`.
- [Errors](/en/docs/errors) for every code on both surfaces.
- [Limits](/en/docs/limits) for every number the platform holds you to.
- [The management API](/en/docs/api/management) for segments, campaigns and reports.
---
# The product catalogue: filling it and keeping it true
> A scheduled feed, a file upload, and products learned from orders. Three feed formats, the price unit, and the id that produces no recommendations at all if it does not match your events.
> https://segmentic.net/en/docs/catalog
The catalogue is your product list as Segmentic sees it: an id, a name, a price, an image, a link and whether it is in stock. While that list is empty the product recommendation block in an email renders nothing at all, with no error anywhere, just a gap. This page is how to fill it and which decisions quietly break it.
## What the catalogue is for {#what-it-is}
> Diagram: How product feeds, files and commerce events become recommendations and personalized content
Three things, and none of them works without it:
- The product block in an email, which draws four products in a two-by-two grid.
- The abandoned cart journey, which has to know what was in the basket before it can show it.
- The "similar" and "people also bought" recommendations, which stand on the affinity matrix, and that matrix only knows products the catalogue holds.
That last point matters more than it looks. Behaviour comes from your events, but any id that is not in the catalogue is **dropped** from the recommendation list. So an account with millions of `product_viewed` events and an empty catalogue produces zero recommendations.
## The id that has to match {#the-id}
This is the most important sentence on the page.
**The product id in the catalogue has to be byte for byte what your events send as `product_id`.**
If the site sends `SKU-1024` and the feed carries `1024`, both halves look correct: the catalogue is full, the events arrive, nothing is wrong in any log, and no recommendation is ever produced. It is the only mistake on this page with no symptom at all.
Before you fill the catalogue, open the Data screen and [look at what your events actually send](/en/docs/instrument#verify-each-one). Then build the feed to match it.
## Three ways to fill it {#three-ways}
| Way | Who it is for | How often |
| --- | --- | --- |
| A scheduled feed | A shop with a feed URL, or that can produce one | Every six hours, automatically |
| A file upload in the panel | A small or stable catalogue, or a quick start | By hand |
| Your shop platform's webhooks | Any account with Shopify, WooCommerce or Digikala connected | With every order |
They do not conflict and you can have all three. Which one wins is in [from your shop platform](/en/docs/catalog#webhooks).
## The scheduled feed {#feed}
In the panel: Catalogue, then Product feed. Give it the URL and the format, and turn it on.
From then on it is read every six hours. The result of the last run sits at the top of that screen: how many products it read, when, and if it failed, why. If nothing has succeeded for more than two days the catalogue page raises an alarm, because a feed that stopped quietly is what puts last season's prices in the next campaign.
Three limits worth knowing: each run has two minutes, reads at most 64 megabytes, and takes at most two hundred thousand products from one document.
> [!warn]
> The feed URL has to be a public one. An address that resolves to your internal network, to `localhost` or into a private range is refused, and so is a redirect into one. That is deliberate rather than a shortcoming: a server that fetches an address you choose is, without that check, an attack tool pointed at our own network.
The format is never sniffed. Whatever you pick in the form is what gets read, because a guess that comes out wrong once writes a catalogue of garbage over a working one, and it comes out wrong exactly when your platform changes something and nobody is watching.
## The CSV format {#csv}
The first row has to be column names. These are recognised and anything else is ignored:
| Field | Accepted names | Required |
| --- | --- | --- |
| Id | `sku`, `id`, `product_id`, `code` | Yes |
| Name | `title`, `name`, `product_name` | Yes |
| Price | `price`, `price_rial`, `amount` | To be shown at all, yes |
| Price before discount | `compare_at_price`, `old_price`, `list_price` | No |
| Category | `category`, `product_type`, `categories` | No |
| Brand | `brand`, `manufacturer`, `vendor` | No |
| Image | `image`, `image_url`, `image_link` | No |
| Product page | `url`, `link`, `product_url`, `permalink` | No |
| Stock | `in_stock`, `stock`, `availability`, `quantity` | No |
| Description | `description`, `desc` | No |
One malformed row does not cost you the file: it is counted and the rest import. If the id column or the name column is missing entirely the whole file is refused, because a file without those two is the wrong file rather than an empty shop.
## The JSON format {#json}
Either an array, or an object with a `products` key:
```json
{
"products": [
{
"sku": "SHOE-1024",
"title": "Runner sports shoe",
"price_rial": 24000000,
"compare_at_rial": 30000000,
"category": "Shoes",
"brand": "Nike",
"image_url": "https://shop.example.ir/img/1024.jpg",
"url": "https://shop.example.ir/p/1024",
"in_stock": true
}
]
}
```
`name` for `title`, `id` or `product_id` for `sku`, `image` for `image_url` and `link` for `url` are all accepted too.
Leave `in_stock` out and the product counts as in stock. That is deliberate: a shop exporting only what it sells is the common case, and reading the absence of the field as unavailable would import a catalogue in which nothing can ever be recommended.
## The XML format, meaning Google Merchant {#xml}
If your shop publishes a feed for the price comparison sites, it is probably already this one and you have nothing new to build.
```xml
-
SHOE-1024
Runner sports shoe
24000000 IRR
19000000 IRR
in stock
https://shop.example.ir/img/1024.jpg
https://shop.example.ir/p/1024
Nike
Shoes
```
When `sale_price` is present and lower than `price`, it is the price and `price` becomes the struck-through "was" line. `availability` takes the specification's own values, and `out of stock` is the only one that makes a product unavailable.
XML is the one format that carries its own currency, which makes it the lowest-risk of the three.
## Prices, and their unit {#prices}
Prices are stored in **rial** and rendered in messages in **toman**.
This is the one place where a silent mistake puts a wrong number in front of your customers, so the rule is strict:
| Format | How it is read |
| --- | --- |
| CSV and JSON | The number is assumed to be **rial**. If your file is in toman, multiply by ten in the file. |
| XML with `IRR` | Rial |
| XML with `IRT` or `TOMAN` | Multiplied by ten |
| XML with any other currency | Stored **with no price** |
That last row is on purpose. A product with no price is visible in the panel and absent from every message, which is a gap somebody finds. A price ten times wrong is a number nobody questions and somebody orders against.
Separators do not matter: `24000000`, `24,000,000` and `24.000.000` all read as the same number.
> [!note]
> A product priced at zero is never recommended. If the catalogue is full and the recommendations are empty, look at the price column first.
## Stock, and a product that leaves the feed {#stock}
A product that is out of stock never appears in a message. Recommending something that is not for sale is, at the scale of a campaign, a click into a dead end multiplied by the whole send.
A product that is absent from the next run is **marked unavailable, not deleted**. Deleting it would lose the affinity history it appears in and it would come back with tomorrow's first order anyway. Marking it keeps the row in the panel, which is where somebody notices that half a catalogue went quiet.
There is also a guard worth knowing about. If one run reads less than half of what the last successful run read, the products it did read are written and **nothing is marked unavailable**. A truncated file, a login page where the feed should be, or an export that stopped half way must not be able to empty a working catalogue at three in the morning.
## Uploading a file in the panel {#import}
On the Product feed screen, below the feed settings. Give it a CSV, correct the columns it guessed, and see how many products were built and what price the first one got before anything is sent.
Two things this screen has that the feed does not: you choose the price unit right there (toman or rial), and a duplicate id inside one file is reported with its row number.
A large file is chunked for you and you watch the progress. If it fails part way it stops and tells you how many landed, because an import somebody believes is complete is worse than one that visibly failed.
## Previewing a recommendation for one user {#preview}
At the bottom of the Catalogue screen, the preview is always available. After two characters, the same query searches user ids, mobile numbers and email addresses together. For example, `09` finds phones containing it as well as ids or emails containing `09`. Matching users appear immediately below the search field. Select one person, leave the recommendation priority on automatic or choose a specific priority, then inspect the result before sending.
If a phrase matches several users, the panel shows each result as a card. Selecting one removes the other cards and keeps the chosen person visible. That choice matters because recommendations come from that user's own behaviour history, and silently taking the first match could preview somebody else's result.
## From your shop platform {#webhooks}
If you have connected Shopify, WooCommerce or Digikala, every order that arrives already carries the ids, names and prices of what was bought. Those go into the catalogue on their own, with nothing to configure.
With one rule that matters: **a webhook only fills a gap and never overwrites anything.** An order line knows the least about a product of any source: no image, no category, no compare-at price. So if a feed or a file wrote that product already, the order leaves it alone. The other way round, a shop with a correct feed would watch its catalogue get slightly worse with every order placed.
The currency is read from the platform's own field. If it names one we do not recognise, the product is stored with no price, exactly as with a feed.
Cancellations and returns teach nothing. A returned item is still a real product, but a payload that exists to undo something is a poor first sighting of one, and the next real order carries the same lines.
## Syncing from your backend {#no-public-api}
For an automated sync, publish the catalogue as a JSON feed at an HTTPS URL and add that URL in the panel. Segmentic fetches the feed on schedule and shows the latest run status on the same screen.
## Quick troubleshooting {#when-it-goes-wrong}
| Symptom | Most likely cause |
| --- | --- |
| Catalogue full, recommendations empty | The ids do not match the events' `product_id`. See [the id](/en/docs/catalog#the-id) |
| Some products are never recommended | Their price is zero, or they are out of stock |
| Every price is ten times too high | A toman file read as rial. See [prices](/en/docs/catalog#prices) |
| The feed saves but I see no runs | It is not enabled, or its URL is private and was refused. The error is at the top of that screen |
| The feed health card says "Needs attention" | Nothing has succeeded for more than two days. The reason is in `last_error` on the Feed screen |
| Products went unavailable yesterday | They left the feed. If that was not intended, look at the feed |
## What to read next {#next}
- [Placing events](/en/docs/instrument) if you do not send `product_viewed` and `order_completed` yet. Without them a full catalogue still has no history to recommend from.
- [The event dictionary](/en/docs/event-dictionary#online-retail) for an online shop's event list.
- [Journeys](/en/docs/journeys) for building the abandoned cart.
---
# 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.
> https://segmentic.net/en/docs/segments
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](/en/docs/segments#trap) before you write your first filter.
## The shape of a definition {#shape}
> Diagram: 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": [] }
}
```
| 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`:
```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 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](/en/docs/segments#trait-vs-trait) |
| `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:
```sql
SELECT user_id FROM segmentic.profiles FINAL
WHERE tenant_id = {tenant:UInt32} AND ()
```
`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 {#group}
```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 {#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 {#trait-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 {#trait-custom}
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 {#trait-birthday}
`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:
```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 {#trait-vs-trait}
`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:
```text
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:
```text
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 {#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.
> [!warn]
> 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 {#properties}
```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 {#count}
```json
{"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() {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}
```json
{"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 {#segment-membership}
```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}
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 {#churn}
```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 {#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 {#values}
```json
{"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.
> [!danger]
> `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 {#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.
| `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} ` |
| `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.
```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:
```text
پس از ۱ فروردین ۱۴۰۵
```
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 {#trap}
> [!warn]
> 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.
```bash
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:
```bash
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 {#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:
```bash
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 {#example-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"}
}
]
}
}
}
```
```text
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 {#example-tehran}
```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}
]
}
}
}
```
```text
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 {#example-spend}
```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}
}
}
}
```
```text
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 {#example-reachable}
```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}}
]
}
]
}
}
}
```
```text
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 {#example-membership}
```json
{
"definition": {
"version": 1,
"root": {"kind": "segment", "segment_id": 1234, "in_segment": true}
}
}
```
```text
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 {#example-property}
```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"}}
]
}
}
}
```
```text
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 {#example-churn}
```json
{"definition": {"version": 1, "root": {"kind": "churn", "band": "high"}}}
```
```text
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 {#example-engagement}
```json
{
"definition": {
"version": 1,
"root": {
"kind": "engagement",
"metric": "ignored_streak",
"operator": "gte",
"value": {"type": "number", "num": 10}
}
}
}
```
```text
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 {#sizing}
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:
```json
{"definition": { }, "limit": 10}
```
Only `preview` reads `limit`.
### validate {#validate-endpoint}
```bash
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 {#count-endpoint}
```bash
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 {#estimate-endpoint}
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 {#preview-endpoint}
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 {#describe-endpoint}
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 {#saved}
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 {#saved-public}
| 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:
```bash
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:
| 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 {#saved-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 {#static-lists}
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:
```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 {#refresh}
> [!note]
> 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](/en/docs/journeys) 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 {#limits}
| 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](/en/docs/limits).
## The compiler's exact error text {#errors}
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:
```text
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](/en/docs/errors).
## What does not exist {#not-possible}
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](/en/docs/segments#trait-vs-trait).
- **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](/en/docs/segments#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](/en/docs/mcp).
---
# Building a journey
> The graph each user walks alone: entry, waits, branches and exit.
> https://segmentic.net/en/docs/journeys
A campaign sends one message to a list. A journey is a graph that each person
walks alone, at their own pace, from the moment something about them became
true. Two people who entered the same journey an hour apart are at different
nodes, waiting on different timers, and one of them may already have left.
The engine that walks the graph performs no I/O and mutates nothing. Given a
graph, a node and a snapshot of the person, it returns what should happen next
as data. That is what makes every branching rule testable without a database,
which matters because a bug in it sends the wrong message to millions of people.
## What a journey is {#what-a-journey-is}
> Diagram: A Segmentic journey from entry triggers through a condition to messages, API calls or exit
A journey has one entry node, some number of nodes after it, and no other way
in. Each person who enters gets an **instance**: their position in one version
of the graph, plus the bookkeeping that goes with it.
```json
{
"tenant_id": 7,
"journey_id": 12,
"version": 3,
"user_id": "u_9137",
"current_node": "wait",
"status": "waiting",
"entered_at": "2026-08-01T09:00:00Z",
"updated_at": "2026-08-01T09:00:00Z",
"wake_at": "2026-08-01T10:00:00Z",
"entry_count": 1,
"variant": "",
"is_holdout": false
}
```
Instance statuses are `active`, `waiting`, `completed` and `exited`. Exit
reasons are `completed`, `exit_criteria`, `max_duration`, `goal_reached` and
`no_next_node`.
## The endpoints {#endpoints}
Every journey route is on the control plane, which is the API the panel talks
to. There is no journey route on the management host.
> [!danger]
> **The control plane is not routed from the internet.** In the reference
> deployment, `api.segmentic.net` serves the management API on its own listener
> and the control plane's listener is deliberately not published. The only
> public path to the routes in this table is the panel's own server-side proxy
> at `https://app.segmentic.net/api/proxy/v1/...`, which authenticates with the
> signed-in user's session cookie and answers `401` without one. An `sk_seg_`
> key does not reach it. Everything on this page is therefore something a
> person does in the panel, including
> [entering people from an API](/en/docs/journeys#enter-api).
| Route | Permission | What it does |
|---|---|---|
| `GET /v1/journeys` | `journey.read` | list, with live counts |
| `GET /v1/journeys/{id}` | `journey.read` | the published graph plus per-node statistics. `?version=N` for an older one. |
| `POST /v1/journeys` | `journey.write` | save a draft |
| `GET /v1/journeys/{id}/draft` | `journey.read` | the working copy, its problems and its warnings |
| `POST /v1/journeys/validate` | `journey.read` | compile a graph without storing it |
| `POST /v1/journeys/{id}/simulate` | `journey.read` | dry run against one real person |
| `POST /v1/journeys/{id}/publish` | `journey.publish` | freeze a version and activate it |
| `POST /v1/journeys/{id}/enter` | `journey.publish` | push named people in |
| `POST /v1/journeys/{id}/{action}` | `journey.write` | `pause`, `resume` or `archive` |
| `DELETE /v1/journeys/{id}` | `journey.write` | move it to the recycle bin |
`journey.publish` is carried by owner, admin and marketer only. It is separate
from `journey.write` because editing a canvas and starting real messages to real
people are different acts.
The read routes exist only when the journey reader is configured; the write
routes only when the editor is; `enter` additionally needs the ingest path.
> [!danger]
> **`GET /v1/capabilities` on the management host reports `"journeys": true`
> when journeys are wired.** No journey route is registered on that host. Do not
> read that flag as "journeys are reachable over the management API". They are
> not, in any form, including read.
`GET /v1/journeys` returns `{"journeys": [...]}`, always an array and never
`null`, each row being `{id, name, status, version, active, waiting}`.
`GET /v1/journeys/{id}` returns the graph and the counts together:
```json
{
"graph": { "journey_id": 12, "version": 3, "entry_id": "trigger", "nodes": [] },
"stats": {
"trigger": { "entered": 4210, "exited": 0, "suppressed": 0, "waiting": 0 },
"send": { "entered": 3902, "exited": 0, "suppressed": 391, "waiting": 0 }
}
}
```
They travel together deliberately: fetching statistics separately would show a
graph with empty nodes for a beat, and seeing where the funnel leaks without
leaving the page is the whole point. A statistics failure yields an empty map
rather than an error page. A journey that exists but has never been published
answers `404` with `this journey has no published version yet`, which is a
different fact from "no such journey" and is worded differently on purpose.
## The graph {#graph}
| Field | JSON | Notes |
|---|---|---|
| Journey id | `journey_id` | |
| Version | `version` | immutable once published |
| Nodes | `nodes` | |
| Entry | `entry_id` | the id of the one node people enter at |
| Re-entry rule | `entry_rule` | `once`, `every_time` or `max_n`. Empty means `once`. |
| Entry ceiling | `max_entries` | with `max_n` |
| Minimum gap | `cooldown_hours` | in hours, from the last entry |
| Re-entry window | `reentry_window` | `day` means once a day in the account's own day. Empty means `cooldown_hours`. |
| Exit condition | `exit_criteria` | a segment definition. Removes a person the moment it matches, wherever they are. |
| Maximum stay | `max_duration_days` | bounds how long anyone may remain |
The tenant id is never part of the stored document. The loader sets it.
## Node types {#nodes}
Every node carries `id`, `kind`, an optional `label`, an optional `next`, and
exactly one configuration object matching its kind.
```json
{
"id": "wait",
"kind": "wait",
"label": "one hour",
"next": "send",
"wait": { "kind": "duration", "amount": 1, "unit": "hour" }
}
```
`x` and `y` may also be present. They are where the editor drew the node and the
engine never reads them. A graph without them is not less valid; the canvas
computes a layout.
An **empty** `next` is legal and ends the journey there. A `next` naming a node
that does not exist is a dangling edge and refuses to compile.
The eight kinds are `trigger`, `wait`, `condition`, `switch`, `split`, `action`,
`goal` and `exit`.
### trigger {#node-trigger}
The way in. `TriggerConfig`:
| Field | Meaning |
|---|---|
| `kind` | `event`, `segment_enter`, `segment_exit`, `segment_periodic`, `attribute_changed`, `date`, `api` |
| `event` | the event name, for `event` |
| `definition` | an inline segment definition, for the segment kinds |
| `segment_id` | a saved segment id |
| `trait`, `value` | for `attribute_changed` and `date`. An empty `value` means any non-empty write to that trait. |
| `filter` | property comparisons on an `event` trigger: `gt`, `gte`, `lt`, `lte`, `eq`, `neq` |
| `offset_days` | for `date`: how many days ahead of the date it fires. 0 to 365. |
| `hours` | for `segment_periodic` and `date`: which hours of the day it sweeps. 1 to 6 of them. |
The segment rule is stored **inline, not as a reference to a saved segment**.
A published version is immutable, and a reference would break that: editing the
saved segment would silently change who enters a journey that was reviewed and
approved with different rules.
`offset_days` only ever runs *before* the date, never after. "Three days after
their birthday" is a wait node, and offering both here would put the same delay
in two places that disagree about quiet hours.
`hours` are hours of the day rather than an interval, because "every six hours"
drifts against the clock the audience lives by: a sweep started at 09:00 is a
03:00 sweep a week later, and the messages it produces are then held by quiet
hours until morning for reasons nobody can see from the schedule.
### wait {#node-wait}
| `kind` | Fields | Behaviour |
|---|---|---|
| `duration` | `amount`, `unit` (`minute`, `hour`, `day`, `week`) | wakes at now plus that span |
| `until_time` | `hour` 0 to 23, `minute` | the next occurrence of that wall-clock time **in the recipient's own timezone** |
| `until_best_time` | `fallback_hour` 0 to 23 | the hour this person has historically engaged at, or the fallback when there is not enough history |
| `for_event` | `event`, `timeout_amount`, `timeout_unit`, `on_timeout` | parks until the event arrives or the timeout fires |
`fallback_hour` is required on a best-time wait rather than defaulted, because a
best time computed from three opens is a coin flip wearing a statistic's
clothes, and silently defaulting to midnight would send at 3am to exactly the
people the platform knows least about.
A `for_event` wait resumes down `next` when the event arrives, and down
`on_timeout` when the timer fires first. An empty `on_timeout` falls back to
`next`, so both paths converge.
### condition and switch {#node-condition}
A condition is yes or no:
```json
{
"id": "bought",
"kind": "condition",
"condition": {
"definition": { "root": {} },
"on_true": "thanks",
"on_false": "remind"
}
}
```
A switch is many ways, evaluated in order, first match wins:
```json
{
"id": "plan",
"kind": "switch",
"switch": {
"cases": [
{ "label": "premium", "definition": { "root": {} }, "next": "vip" },
{ "label": "paid", "definition": { "root": {} }, "next": "standard" }
],
"default": "free"
}
}
```
`default` is required. Without it anybody who matches no case leaves the journey
at the switch, which looks identical to a bug and is invisible until an audience
report comes back short.
Ordered rather than collecting every match, because overlapping rules are the
normal case: people on the premium plan are also people who have bought, and an
ordered list is the only reading in which the author decides which one counts.
Both use the same segment definition language as a saved audience. See
[Segments](/en/docs/segments).
**When no segment evaluator is available, a condition takes `on_false` and a
switch takes `default`.** That is the conservative reading, and it is what
happens rather than an error.
### split {#node-split}
```json
{
"id": "test",
"kind": "split",
"split": {
"branches": [
{ "weight": 1, "next": "arm_a", "variant": "A" },
{ "weight": 1, "next": "arm_b", "variant": "B" }
],
"holdout_percent": 10,
"on_holdout": "end"
}
}
```
`weight` is a share, not a percentage. The engine normalises, so a marketer
editing one arm cannot leave the total at 97. `holdout_percent` is 0 to 100.
Allocation is a hash, not a draw: the holdout uses `(user_id, node_id)` and the
arm uses `(user_id, node_id + ":branch")`, so the two are independent and both
are stable across a retry. A user who flipped between arms on a retry would make
the result meaningless.
A holdout user with no `on_holdout` path follows the first branch with the sends
suppressed. See [Holdouts](/en/docs/journeys#holdout).
### action {#node-action}
Five kinds:
| `kind` | Required | What it does |
|---|---|---|
| `send` | `channel` or `channels`, and a template for each | delivers a message |
| `update_trait` | `trait`, `value` | writes to the person's profile |
| `webhook` | `url` | calls your endpoint |
| `add_to_segment` | `segment_id` | adds the person to a list |
| `enter_journey` | `target_journey_id` | hands them to another journey |
A send node may name one channel (`channel` and `template_id`) or several
(`channels`, an array of `{channel, template_id}`), with `channel_mode` empty
for fallback or `"all"` for broadcast. Fallback tries each in order and stops at
the first that reaches the person. **Only a channel-shaped refusal moves on**:
no device, no phone number, this medium switched off. A refusal about the person
rather than the medium, an unsubscribe, a frequency cap, quiet hours, stops the
node for them entirely, because trying the next channel would be looking for a
way around the answer.
`priority` and `budget` sit on the node rather than on the journey, because one
flow contains both kinds of message: "your order is out for delivery" and "you
might also like" are the same journey and are not the same claim on somebody's
attention.
`enter_journey` may not target its own journey.
### goal and exit {#node-goal}
A goal node carries `{"event": "...", "window_days": N}`. It is a conversion
checkpoint; `window_days` bounds attribution, because a purchase six months
later is not this journey's doing.
An exit node is terminal and carries no configuration.
## A complete definition {#definition}
The abandoned-cart flow, in the JSON `POST /v1/journeys` accepts:
```json
{
"name": "سبد رها شده",
"graph": {
"journey_id": 1,
"entry_id": "trigger",
"nodes": [
{
"id": "trigger", "kind": "trigger", "label": "added to cart", "next": "wait",
"trigger": { "kind": "event", "event": "product_added_to_cart" }
},
{
"id": "wait", "kind": "wait", "label": "one hour", "next": "send",
"wait": { "kind": "duration", "amount": 1, "unit": "hour" }
},
{
"id": "send", "kind": "action", "label": "reminder", "next": "end",
"action": { "kind": "send", "channel": "push", "template_id": 3 }
},
{ "id": "end", "kind": "exit" }
]
}
}
```
A periodic sweep with pacing and an exit rule. This combination, sweep twice a
day, message no one person more than weekly, and stop chasing anybody who signs
up, is the shape that produces no warnings at all:
```json
{
"journey_id": 1,
"entry_id": "trigger",
"entry_rule": "every_time",
"cooldown_hours": 168,
"exit_criteria": { "root": {} },
"nodes": [
{
"id": "trigger", "kind": "trigger", "label": "sweep", "next": "send",
"trigger": {
"kind": "segment_periodic",
"definition": { "root": {} },
"hours": [11, 18]
}
},
{
"id": "send", "kind": "action", "label": "reminder", "next": "end",
"action": { "kind": "send", "channel": "push", "template_id": 3 }
},
{ "id": "end", "kind": "exit" }
]
}
```
`POST /v1/journeys` answers `{"id": 12, "version": 4}`. **A draft is allowed to
be incomplete.** A single node with no edges saves without complaint, because a
marketer builds a flow over several sittings and refusing to save half a graph
loses their work. Publishing is where the graph has to be sound.
Failures: `400 malformed JSON`, `400 name is required`, `400 graph is required`,
`503 could not save journey`.
## How people get in {#entry}
The re-entry rule decides who may start again:
| `entry_rule` | Behaviour |
|---|---|
| `once` (and empty) | never again, whatever happened the first time |
| `every_time` | again, subject to the minimum gap |
| `max_n` | until `entry_count` reaches `max_entries` |
Somebody currently `active` or `waiting` is **never** re-entered, under any
rule. Starting them again would send them the whole flow twice in parallel.
`cooldown_hours` is measured from the prior instance's last update, not from
when it started.
### The gap has two readings {#reentry-window}
`cooldown_hours` is a duration: nobody enters again until N hours have passed
since their last entry. `reentry_window: "day"` is a calendar period: at most
one entry per person per day, and the counter resets at midnight rather than 24
hours after the previous entry. The calendar window is measured from the prior
instance's **entry** rather than its last update, so the gap does not depend on
how long the flow itself runs.
They are not the same rule. A sweep at 10:00 and 18:00 with a `cooldown_hours`
of 24 enrolled 2,110 people at 19:00 one evening, and both of the next day's
slots then fell inside those 24 hours, so 15 people entered against a baseline
of 2,195. The cohort locks to the hour it first entered and misses whole days
from then on. A daily reminder means "once a day", and no duration expresses
that.
The day is the account's, in Tehran, the same day a `day` frequency cap counts
over. The two fields are alternatives rather than a pair: a graph carrying both
has two answers, so `POST /v1/journeys/validate` reports it as a problem and
the panel will not publish it. If one is somehow stored anyway, the window is
what the engine obeys and the hours beside it do nothing.
How the trigger actually enrols somebody differs by kind, and the differences
are the part that catches people out.
**`event`** is immediate. The event arrives on the bus, the worker checks every
published graph, and a match enters the person. `filter` comparisons apply here.
A journey listening for an ordinary event can never be entered by the reserved
enrolment event described below.
**`attribute_changed`** matches only an `identify` call that actually wrote the
trait, with an optional exact value match. It is evaluated from the identify
event itself, so it reacts immediately and never mistakes an old profile value
for a change.
**`segment_enter`, `segment_exit`, `segment_periodic` and `date`** are found by
a scanner, not by an event. Nobody emits "left the lapsed-customer segment"; it
becomes true because a purchase three weeks ago aged past a window, and the only
way to notice is to look twice and compare.
> [!warn]
> **A segment trigger is not instant and is not advertised as such.** The
> scanner re-reads membership every **5 minutes** by default. The delay is
> bounded by that interval. A marketer who needs "the moment they buy" wants an
> event trigger.
Three properties of the scanner are load-bearing:
- **The first scan enrols nobody.** It records who is in the segment and stops.
Without that interlock, publishing a win-back aimed at four hundred thousand
lapsed customers messages all of them inside one scan interval, and the graph
looks entirely correct afterwards.
- Enrolment goes through the same path an `enter_journey` jump uses, so the
entry rule, the cooldown and the exit criteria are applied by the code that
already applies them.
- **Truncation is recorded, never silent.** A diff is bounded at **250000**
members and a sweep at **5000000**, both read one page of **5000** at a time.
A segment larger than the scan may read is a journey that has quietly stopped
noticing anybody past the limit.
Sweep hours are read in **Tehran**, because "sweep at 11 and 18" is a sentence
about the audience's day.
A `date` trigger compiles "three days before their birthday" into "days until
their birthday is exactly three", ANDed with the trigger's own definition when
it has one.
Every non-event enrolment, including the API one, travels as the reserved event
`journey_enter_requested` carrying a numeric property `journey_id`. That is why
an API entry inherits de-duplication, the re-entry rules, the exit criteria and
the instance bookkeeping instead of getting a second enrolment path that agrees
with the first until it does not.
The published-graph set is reloaded every **30 seconds**, so a publish, a pause
or a resume reaches the entry path within half a minute.
## Waits, and how durable they are {#waits}
**Timers are durable rows in Postgres, not in-memory schedules.** A worker
restart, a deploy, a crash: the timer is still there and still fires.
- Timers are bucketed by the minute, which is what makes the poller cheap. With
ten million people parked on a "wait 3 days" node it reads one small partition
per tick instead of scanning an index over every future timer.
- The poll runs every **1 second** with a batch of **1000**, even though the
buckets are minutes, because somebody who set "wait 5 minutes" expects roughly
five minutes and a minute of quantisation on top would be visible.
- Scheduling replaces any existing timer for the same person in the same
journey. **A person cannot be waiting in two places at once.**
- Claiming is atomic. A claimed timer is not handed to a second worker.
- A failure while running one timer leaves it claimed and unprocessed, and the
next pass picks it up. One person failing does not abort the batch: the rest
are unrelated people whose messages are also due right now.
- **State is persisted before effects are dispatched.** A crash between the two
re-sends a message the delivery layer de-duplicates, rather than losing it.
Losing it is the failure that has no evidence afterwards.
A wall-clock wait uses the recipient's own timezone, so "wait until 9am" means
their 9am. A best-time wait reads the person's engaged hour at the moment it is
needed; a lookup failure falls back to `fallback_hour` silently.
> [!warn]
> **Pausing a journey does not stop the people already inside it.** Pause
> removes the journey from the published set, so nobody new enters. Timers are
> resolved against the stored version by id, without checking the journey's
> status, so somebody parked on a three-day wait still wakes up and still
> receives the message. If you need the sends to stop, switch off the channel or
> the account, which is what the kill switches are for. See
> [Consent and caps](/en/docs/consent#failure-direction).
## Leaving {#exit}
Three ways out, checked in this order every time an instance advances:
1. **`exit_criteria`** wins over everything, wherever the person is. This is
what stops an abandoned-cart flow nagging somebody who has already bought.
2. **`max_duration_days`**, which bounds how long anyone may stay. Somebody
parked behind a for-event wait would otherwise sit in the journey
indefinitely.
3. running out of graph: an exit node, an empty `next`, or the natural end.
Then the engine walks nodes, at most **100 steps** per call. Beyond that it
returns a step-limit error. The compiler already refuses a loop with no wait in
it; this is the runtime backstop, because an engine that looped here would send
the same message thousands of times before anyone noticed.
The exit reason is recorded on the instance, so a marketer can see whether a
journey converted people or merely timed them out.
## Versions {#versions}
**A published version is immutable, and a person is pinned to the version they
entered on.** Somebody who entered on version 3 finishes on version 3 even after
version 4 is published, because re-pointing a half-finished person at a changed
graph would drop them onto a node that no longer means what it did when they
arrived.
The timer carries the version too, so a wake-up resolves the same graph the
person entered on.
`GET /v1/journeys/{id}?version=N` reads an old version. Version 0, or no
parameter, means whatever is published now.
## Validating before you publish {#validate}
`POST /v1/journeys/validate` takes `{"graph": {...}}` and answers `200` with:
```json
{ "valid": true, "problems": [], "warnings": [] }
```
`400` only when the body will not parse or the graph is null. Everything else is
a `200` with a list.
**`problems` block a publish. `warnings` do not.** They are kept apart rather
than folded together because they ask for opposite responses. A problem is "this
cannot go out". A warning is "this will go out, and here is what it will do".
Refusing to publish a warned graph would block the shapes that are occasionally
right, and mixing them into one list would teach a marketer that the list is
advisory, which is the reading that gets a dangling edge published.
Every problem is listed, not only the first, because a marketer fixing one
dangling edge at a time with a round trip between each gives up.
| Condition | Message |
|---|---|
| no nodes | This journey has no nodes at all |
| a node with no id | One of the nodes has no identifier |
| a duplicated node id | The node "%s" is duplicated |
| no entry set | No starting point is set |
| entry points at nothing | The starting point points at a node that does not exist |
| an edge to a deleted node | The output "%s" of the node "%s" goes to a node that has been deleted |
| no action node anywhere | This journey does nothing: add a send node |
| event trigger with no event | The start node "%s" has no entry event |
| segment trigger with no definition | The start node "%s" has no segment to check |
| sweep with no hours | The start node "%s" has no sweep hour; without one it never runs |
| more than six sweep hours | The start node "%s" sweeps more than %s times a day |
| date trigger with no date trait | The start node "%s" does not say which date it runs on |
| date offset outside 0 to 365 | The start node "%s" has an invalid offset from that date |
| switch with no cases | The multi-way node "%s" has no paths |
| switch with no default | The multi-way node "%s" has no default path: anyone who matches none of the conditions drops out of the journey right here |
| action not configured | The node "%s" is not configured |
| `enter_journey` with no target | The node "%s" has nowhere to jump to |
| send with no channel | The send node "%s" has no channel |
| send with an empty channel entry | The send node "%s" has an empty channel |
| send with no template | The send node "%s" has no text to send |
| multi-channel send missing one template | The send node "%s" has no text for channel %s |
| wait not configured | The wait node "%s" has no duration |
| for-event wait with no timeout | The wait node "%s" has no timeout: anyone who never performs that event stays in the journey forever |
| split with no branches | The split node "%s" has no branches |
| condition not configured | The condition node "%s" has no condition |
Anything the list misses is caught by the compiler, above all a **cycle with no
wait node in it**, which is refused. A wait breaks a cycle because time has to
pass.
The compiler also enforces, beyond the list above: `duration` needs a positive
`amount` and a recognised `unit`; `until_time` needs an hour in 0 to 23;
`until_best_time` needs a `fallback_hour` in 0 to 23, refused rather than
clamped; every switch case definition has to be a valid segment; a split needs
at least one branch, no negative weights, a positive total, and
`holdout_percent` in 0 to 100; `update_trait` needs a `trait`; `webhook` needs a
`url`; `add_to_segment` needs a `segment_id`; a goal needs an `event`.
Note one asymmetry: the compiler does **not** require a send node to have a
template. The editor's `problems` list does. So a graph can compile and still be
unpublishable.
Warnings apply only to `segment_periodic` and `date` triggers:
| Condition | Warning |
|---|---|
| date trigger with entry rule `once` | This journey admits each person once, so it runs for one year per person. For a yearly repeat, set the re-entry rule to "every time" |
| date trigger, `every_time`, gap under 90 days | The minimum gap is short for a yearly occasion; something around 90 days or more is safer |
| sweep with entry rule `once` | This journey admits each person once, so a periodic sweep only enrols people who have not entered before |
| sweep, `every_time`, no cooldown | With the re-entry rule set to "every time" and no minimum gap, every sweep messages every member of the segment |
| sweep with no `exit_criteria` | This journey has no exit condition; somebody who does the thing you wanted stays a target of the sweep until they leave the segment |
| another running journey has the same entry signature | The journey "%s" has exactly this entry condition; one person gets a message from both |
The overlap warning compares a fingerprint of **what the trigger asks at the
door** and deliberately ignores everything after it. A journey never warns about
itself, and a lookup failure is silent rather than fatal.
## Publishing, pausing, deleting {#publish}
`POST /v1/journeys/{id}/publish` **re-validates the stored draft** rather than
trusting the client's last check. The browser's opinion is a convenience; this
is the gate that decides whether messages start going out.
```json title="400 when the draft is not ready"
{
"error": "This journey is not ready to publish yet",
"problems": ["The send node \"send\" has no text to send"]
}
```
Success is `200 {"version": 4}`. `404` when the draft cannot be read, `503` when
publishing itself fails.
`POST /v1/journeys/{id}/pause`, `/resume` and `/archive` set the status to
`paused`, `active` and `archived`, and answer `{"status": "paused"}`. Any other
action is `400 unknown action`.
`DELETE /v1/journeys/{id}` is a soft delete into the recycle bin, kept for **30
days**, restorable with `POST /v1/recycle/journey/{id}/restore`. A journey that
is running or scheduled answers `409` and tells you to stop it first. An already
deleted or non-existent journey answers `404`, because from here "already
deleted" and "never existed" are the same answer: the caller is looking at a
stale screen.
## Entering people from your own backend {#enter-api}
There is an endpoint built for exactly this, and in the reference deployment
your backend cannot reach it. Both halves are true and the second one is the
one that costs an afternoon, so here it is first.
`POST /v1/journeys/{id}/enter` lives on the control plane. The control plane's
listener is not published to the internet: `api.segmentic.net` carries the
management API only, and no journey route is registered there. The only public
path is the panel's server-side proxy, which requires the signed-in user's
session cookie and refuses an `sk_seg_` key. So today this is a panel action and
an on-network integration, not a public API.
The endpoint itself:
```http
POST /v1/journeys/12/enter
Content-Type: application/json
{ "user_ids": ["u_9137", "u_4410"] }
```
```json title="202"
{
"accepted": 2,
"status": "queued",
"note": "Entry happens once the event has been processed; the re-entry rules and the exit condition still apply."
}
```
- **The journey's entry node must be an `api` trigger.** Anything else is `409`.
Without that check the call succeeds, the event is published, the worker
declines to match it, and the caller is told "accepted" about something that
will never happen.
- Permission `journey.publish`, not `journey.write`. This does not edit a graph;
it causes real messages to be sent to a named person.
- Accepts `{"user_id": "u1"}` or `{"user_ids": ["u1","u2"]}`, and both together.
Blanks and repeats are dropped, and a repeat inside one call is one person.
- **At most 500 people per call.** More is `400 at most 500 people per call`.
Enrolling is a per-person decision, and a request that could name a hundred
thousand people is a campaign wearing an API's clothes, without a campaign's
audience preview or coverage report.
- Body cap 1 MiB, and unknown fields are refused.
**`accepted` counts what the event bus took, not how many people started a
journey.** The worker still applies the re-entry rule, the cooldown and the exit
criteria, so some of those people will not start one. The field is named for
what this endpoint can actually promise.
Each person becomes one `track` envelope with a deterministic message id of the
form `jenter-{journeyID}-{userID}-{unixSeconds}`, so a retry inside the same
second de-duplicates at the collector.
Failures: `400` bad journey id, malformed JSON, unknown field, or an empty user
list; `404` when the draft cannot be read; `409` when the entry is not an API
trigger; `503 could not queue the entry`.
## Simulating {#simulate}
`POST /v1/journeys/{id}/simulate` runs the draft against one real person and
reports every decision. **Nothing is sent and nothing is stored.**
```json title="request"
{ "user_id": "u_9137" }
```
An empty `user_id` makes the server pick a recent profile. Supplying a `graph`
tests what is on the canvas rather than what was last saved.
```json title="response"
{
"steps": [
{ "node_id": "trigger", "label": "added to cart", "kind": "visited", "detail": "..." },
{ "node_id": "wait", "label": "one hour", "kind": "wait", "detail": "..." },
{ "node_id": "send", "label": "reminder", "kind": "effect", "detail": "...",
"effect": { "kind": "send", "channel": "push", "template_id": 3, "node_id": "send" } }
],
"reached": true,
"outcome": "...",
"sends": 1,
"simulated_waits": 1,
"problems": [],
"user_id": "u_9137"
}
```
Step kinds are `visited`, `branch`, `effect`, `wait`, `end` and `error`. A step
may also carry a `suggestion` when something is wrong with it.
What it does that a structural check cannot:
- **Waits are accelerated**, and `simulated_waits` says how many. A "wait two
days" node would otherwise make the test button unusable on exactly the
journeys that most need checking. A `for_event` wait takes the event-arrived
path.
- **Exit criteria are checked first**, exactly as the engine does, because a
journey whose exit rule already matches every entrant is the most common "why
did nobody receive this": the flow is correct and every entrant leaves at the
door.
- Conditions are evaluated against the real person, through the same matcher the
worker uses. A matcher failure is reported as "branch not taken" rather than
aborting the trace.
- `problems` is the same list the publish button uses, so a clean walk down one
branch does not read as publishable.
- Bounded at 100 steps.
Permission is `journey.read`. Gating a dry run behind publish would mean the
person who cannot publish also cannot check their own work.
Failures: `400` bad id or malformed JSON; `409 no profile is available for a
test run yet` when the account has no profile to pick; `503` when the test-user
lookup fails; `404` when neither a supplied graph nor a stored draft exists.
## Holdouts inside a journey {#holdout}
A holdout person walks the whole graph and receives nothing. That is what makes
the journey's effect measurable, and it is also where the interesting rule is:
**the two arms must differ only in what was sent.**
| Effect | For a holdout |
|---|---|
| `send` | recorded and suppressed, at exactly the same point a real send would be |
| `update_trait` | **applied** |
| `add_to_segment` | **applied** |
| `webhook` | **skipped** |
| `enter_journey` | **skipped** |
A trait and a segment membership are state. Skipping them would make the control
group differ from the treatment group in a second way, and the uplift figure
would then be measuring both differences at once. A webhook and a jump are
outbound effects: firing them enrols the person in a loyalty tier, opens a
ticket, or starts the next journey, which is exactly the treatment the holdout
exists to withhold.
## Sends from a journey {#sends}
A send node hands the message to the same delivery path a campaign uses, so
every rule on [Consent and caps](/en/docs/consent) applies.
- **The default category for a journey send is `marketing`.** The template's own
category overrides it, and that is the field to set for an order-shaped
message inside a journey.
- The message id is
`j{journey_id}.v{version}.{node_id}.{user_id}.e{entry_count}`, with the
channel appended for the second and later channels of a multi-channel node.
Two channels reaching one person are two messages, and sharing an id would
have the second taken for a retry of the first and silently dropped.
- **A send that governance defers is queued as a deferred send, not re-armed on
the journey timer.** The journey moves on; the message is released later.
- **Without a deferral store configured, a deferred journey message is dropped
with a warning.** That is worth checking on an installation you did not set up
yourself.
> [!danger]
> **A send node authored on the `webhook` channel fails for every person who
> reaches it.** The compiler checks only that the channel string is non-empty,
> so `"channel": "webhook"` validates, publishes and runs. There is no webhook
> sender anywhere in the delivery layer: the value is in the authorable channel
> vocabulary and in nothing that delivers. Each arrival comes back `failed` with
> "this channel is not configured", one per person, and the graph looks correct.
> The `webhook` **action** kind in the table above is a different thing and it
> works: it calls your endpoint instead of sending a message. Only `push`,
> `sms`, `email`, `webpush`, `inapp`, `bale`, `eitaa` and `rubika` have senders.
## Personalisation from the trigger {#trigger-vars}
A journey send renders from the three sources
[a transactional send does](/en/docs/transactional#vars), and from one more:
the properties of the event that caused the step, under the `event.` namespace.
```
{{event.rival}} از تو جلو زد، الان رتبه {{event.rank}} هستی
```
The event is the one that matched the trigger when the person entered, or the
one that ended a wait-for-event when it woke them. Whichever caused this step.
**The namespace never merges with anything, and that is the point.** A stored
trait `rank` and a property `rank` are two different facts and a message often
wants both: `{{rank}}` is the trait and `{{event.rank}}` is the property, and
neither can shadow the other. `event.` is also the one prefix the renderer never
strips, so an unfilled `{{event.rank}}` is a missing variable rather than the
trait quietly answering for it.
**A step with no event has none of these variables.** A wait that elapses, a
date trigger and a segment sweep all resume with no event, which includes every
send that sits after a wait node. A template naming one of these then has a
variable with no value, so the send is suppressed with
`missing_personalisation`, and the missing keys are named in the worker log. The
alternative is a push that reads " از تو جلو زد", which cannot be recalled. Give
the placeholder a fallback if the sentence survives without the value, and put
the send before the wait if it does not.
**The event is not written to the instance.** It is read at the step and thrown
away, so a journey cannot present Monday's rank as today's. The one place these
values are stored is a send that quiet hours held until morning: that message
keeps the values it was built with, because the event it came from cannot be
read again.
**At most 32 properties travel, and only those of at most 512 bytes.** The 32
are the first in sort order, which is the same 32 on a retry. A longer value is
dropped rather than cut short, so it becomes a missing variable instead of a
sentence that stops halfway.
Only properties travel. The event's name, its timestamp and its device context
are not variables.
## What journeys do not do {#not-built}
- **No journey routes on the management host at all**, including read, despite
the capabilities flag. And the control plane that does carry them is not
routed from the internet. See
[The endpoints](/en/docs/journeys#endpoints).
- **No route answers "where is this person in this journey".** The graph
endpoint returns per-node aggregates. There is no per-instance read.
- **No route removes one person from a journey.** Exit criteria and
`max_duration_days` are the mechanisms; there is no "eject this user" call.
- **No pause that stops people already inside.** See
[Waits](/en/docs/journeys#waits).
- **No approval flow for a journey you built yourself.** One you wrote by hand
publishes on `journey.publish` and nothing else. A journey that came from the
library or from a suggestion is different: it carries `review_required`, and
publishing it without a review returns an error. Review takes a fingerprint of
the behaviour, so editing the journey after it was reviewed and publishing
the edit is refused too, the same way a campaign's approval works.
- **No recurring schedule of its own.** A sweep trigger with hours is the
closest thing, and it reads its hours in Tehran.
- **No check that a send node's channel can be delivered on.** See
[Sends from a journey](/en/docs/journeys#sends).
- **No `offset_days` after a date.** Use a wait node.
---
# Sending a transactional message
> One message to one person, now: a login code, an order update, a payment reminder, with the idempotency contract that makes a retry safe.
> https://segmentic.net/en/docs/transactional
A transactional message is one message to one person, now: a login code, an
order confirmation, a delivery update, a payment reminder. Your backend decides
it should happen. Segmentic sends it and answers with what happened.
It is the opposite direction from everything else in this product. A campaign is
a message we decided to send. A journey is one the person's own behaviour
triggered. This one is your checkout handler saying "send this, now", and the
differences all follow from that: the call is synchronous, it is idempotent on a
key you choose, and it bypasses frequency caps and quiet hours.
## The endpoint {#endpoint}
> Diagram: How transactional requests, campaigns and journeys reach the correct delivery channel
```http
POST https://api.segmentic.net/v1/messages
Authorization: Bearer sk_seg_...
Content-Type: application/json
```
The key is a management key (`sk_seg_...`), it is secret, and it belongs on your
server. A write key (`wk_seg_...`) ships inside your app and is refused here with
its own error code, `write_key_rejected`, so that somebody who copied the wrong
value out of a page is told which value they need rather than sent looking for a
typo.
The permission is `campaign.send`, not `campaign.write`. Owner, admin and
marketer carry it. Approver, analyst, viewer and finance do not. Drafting a
campaign and making messages leave the building are different acts, and this
route performs the second one.
The route exists only when the deployment has a send path wired. Without one it
is never registered and the mux's catch-all answers `404` with
`{"error":{"code":"unknown_endpoint","message":"no such endpoint: POST /v1/messages, see GET /v1/capabilities"}}`.
Check `GET /v1/capabilities` first: it reports `"transactional": false` on a
deployment that cannot send. On a local machine the management API is served
only when `PUBLIC_API_ADDR` is set, and it is unset by default, so there is
nothing on that host until you set it.
**The response is always in Persian, and `Accept-Language` has no effect on this
host.** The language middleware is mounted on the panel's own listener and not
on this one, so the locale lookup falls through to its default, which is
Persian. That is why `reason_fa` is named the way it is. Send the header if you
like; nothing reads it.
## The request {#request}
| Field | Type | Required | Default |
|---|---|---|---|
| `user_id` | string | yes | none |
| `channel` | string | yes | none |
| `template_id` | number | yes | none |
| `idempotency_key` | string | yes, in the body or the `Idempotency-Key` header | none |
| `category` | string | no | `transactional` |
| `vars` | object of string to string | no | none |
There is no field for the message text. A `template_id` is required and the
content lives in the template. The reason is in the code: accepting a body
inline "would put message text on the request path of a system that is often
called from a checkout handler, and make every send unreviewable after the
fact." See [Templates](/en/docs/transactional#templates).
**Unknown fields are refused with `400`.** A caller who wrote `idempotencyKey`
instead of `idempotency_key` would otherwise get a fresh key on every retry and
send one message per attempt, which is the exact failure this endpoint exists to
prevent, arriving through a typo nobody would ever see in a log.
The body is capped at 262144 bytes (256 KiB).
Validation runs in this order, and the first failure is the one you are told
about:
1. `user_id` is present
2. `template_id` is not zero
3. `idempotency_key` is present
4. `idempotency_key` matches its pattern
5. `vars` holds at most 40 entries
6. `category` is `transactional`, `critical`, or absent
7. `channel` is one of the accepted values
Channel is checked last. A request with both a bad channel and a six-character
key is told about the key.
## The idempotency key {#idempotency}
**The key is not a request id. It is the name of the thing that happened in your
own system.** `order-8821-shipped`, `otp:2026-08-01:u_9137`,
`invoice-5512-reminder-1`.
This matters because of what a generated key does. A checkout handler that calls
`uuid()` on each attempt, times out at four seconds, and retries, has produced
two different keys for one event. Both are new. Both send. The customer gets two
SMS for one shipment and phones support about it. A key derived from the order
and the step is the same string on both attempts, so the second attempt returns
the first attempt's answer and sends nothing.
Shape: `^[A-Za-z0-9._:-]{8,200}$`. Letters, digits, dot, dash, underscore and
colon, at least 8 characters and at most 200.
| Accepted | Refused |
|---|---|
| `order-8821-shipped` | `short` (under 8 characters) |
| `otp:2026-08-01:u_9137` | `has space` |
| `a1b2c3d4` | `quote'inside` |
| `x.y_z-1:2` | `semi;colon` |
The key is trimmed of surrounding whitespace and nothing else. **Case is
preserved**, so `Order-1` and `order-1` are two different keys and two different
messages. Folding them would merge two keys a strict caller may be using for two
different things.
The key may arrive in the body as `idempotency_key` or as the HTTP header
`Idempotency-Key`. The body wins; the header is read only when the body field is
empty. The header form exists because most HTTP clients already have a retry
wrapper that sets it.
### What the key does {#idempotency-mechanics}
The message id is **derived** from the key, not generated:
`t{tenant_id}.{key}`. For tenant 7 and key `order-8821`, the id is
`t7.order-8821`. The `t` prefix distinguishes a transactional send from a
campaign (`c`) and a journey (`j`) in the message log and in attribution.
Because the id is derived, the de-duplication below the ledger recognises a
replay too: the delivery path's own de-duplication, the frequency counter and
the message log all see the same id even if the ledger row were lost.
Scope is `(tenant_id, idempotency_key)`. The same key in two accounts is two
messages.
The ledger claim is one round trip: an insert with an `ON CONFLICT` update that
returns whether this caller won. It is not a read followed by a write, because
two retries of the same key arriving together is the ordinary case and a
check-then-insert leaves a window both of them pass through.
| Situation | What you get |
|---|---|
| First call | The send runs. The result is stored against the key. |
| Repeat of a completed key | The stored result, with `"replayed": true`. No provider is contacted. |
| Repeat of a key whose send was suppressed | The same refusal, replayed. It is not retried. |
| Repeat while the first attempt is still running | `409` with `Retry-After: 1` |
| Repeat after the first attempt errored | The reservation was released, so this attempt runs |
A replay returns the wording of the first request. `reason_fa` was rendered then
and stored, so a later change to the sentence does not reach a key that has
already been answered.
### How long a key is remembered {#idempotency-retention}
Ledger rows are swept after `API_IDEMPOTENCY_RETENTION`, **7 days** by default.
After that window the same key sends again. A key is a retry guard, not a
permanent record of what you have sent.
Abandoned reservations, the ones left behind by a process that died mid-send,
are swept after `API_STALE_RESERVATION`, **1 minute** by default. Without that
sweep a crashed worker would answer every retry of one key with `409` for ever,
and it would be the customer's order receipts that stopped.
## Categories {#category}
| Category | What it skips |
|---|---|
| `marketing` | nothing. Every rule applies. |
| `transactional` | quiet hours always, and a frequency cap unless that cap names `transactional` |
| `critical` | everything transactional skips, plus a channel opt-out and a topic opt-out. Never governed by any cap, whatever a policy says. |
Neither `transactional` nor `critical` skips an operator suppression. A person
under a fraud hold or a legal hold receives nothing, including a security alert.
See [Consent and caps](/en/docs/consent).
**`marketing` is refused here with `400`.** The message is
`transactional: this endpoint does not send marketing; use a campaign`. This
endpoint bypasses caps and quiet hours, so accepting marketing on it would hand
every customer a documented way around their own sending rules, and the first
time it mattered would be a 3am promotional SMS to a whole list.
**An unknown category value returns `503 message not sent`, not `400`.** That is
a real inconsistency in the current build and it is worth knowing about: a typo
in `category` looks like an outage on our side, and a caller who retries it will
keep getting `503`. Check the field before you check us.
An absent `category` defaults to `transactional`, never to `critical`. Critical
bypasses a channel opt-out, so defaulting to it would let a caller who omitted
the field reach somebody who explicitly turned that channel off.
> [!warn]
> **The template's own category overrides the one in your request.** A template
> saved as `marketing` and sent through this endpoint with
> `"category": "transactional"` is delivered as marketing, which means quiet
> hours and frequency caps apply to it. The validator's refusal above is about
> the request field. The template is the final authority, because that is the
> field that stops an order confirmation being held until 9am and equally stops
> a promotion being tagged transactional to get around a cap.
> [!warn]
> **A login code is `critical`, not `transactional`.** Both reach the service
> line, so delivery looks identical, and the difference only shows up under an
> account rate ceiling: a ceiling that names `transactional` counts your OTPs,
> so a large campaign that fills the account's hour can hold one back. Somebody
> locked out of their own account by your marketing is not a rate limit working
> correctly. `critical` is the one category no stored policy can reach, whatever
> it says.
>
> An absent `category` defaults to `transactional`, so a login code with the
> field omitted is exposed to exactly this. Set it explicitly.
## Channels {#channels}
`channel` must be exactly one of:
```text
push sms email webpush inapp bale eitaa rubika
```
Three values that look plausible and are refused:
- **`web`** is refused. The value is `webpush`. Elsewhere in the platform `web`
is accepted as a historical alias, and the MCP tool schema still advertises
`web` in its description, but this endpoint compares literally and the MCP
description is wrong. Send `webpush`.
- **`messenger`** is refused. It is an authoring umbrella for campaigns, resolved
per recipient. A transactional send must name `bale`, `eitaa` or `rubika`
directly.
- **`webhook`** is refused here, and it has no delivery sender anywhere in the
platform.
Which of these an installation can actually deliver on depends on what is
configured. Sending on a channel with no configured transport produces a
`failed` outcome, not a validation error.
## Templates {#templates}
The template must exist before you can send. It carries the title, the body, the
image, the deep link, the buttons, the TTL, the priority, the SMS pattern
binding, and the category that overrides yours.
Templates are created and edited on the control plane, which is the API the
panel talks to:
```text
GET /v1/templates template.read
POST /v1/templates template.write
POST /v1/templates/preview template.read
```
> [!danger]
> **There is no template route on the management host, and the control plane is
> not routed from the internet.** In the reference deployment
> `api.segmentic.net` publishes the management listener only. So an integration
> holding an `sk_seg_` key cannot create, list, read or delete a template. It
> can only reference one by id, and the id has to come from a person who opened
> the panel. There is also no `GET /v1/templates/{id}` and no delete route on
> any surface.
An unknown, archived or foreign `template_id` returns `503 message not sent`.
It is not a `400` and not a `404`, because the failure is raised by the store
rather than by the request validator. The reservation is released, so your retry
runs, and your retry fails the same way. When a send returns `503` immediately
and repeatedly, check the template id first.
A template is cached on the send path for **30 seconds**. An edit in the panel
takes up to half a minute to reach a send.
## Personalisation {#vars}
`vars` is a flat map of string to string, at most **40 entries**. Values are
substituted into the template wherever it writes `{{ key }}`, with an optional
fallback after a pipe: `{{ first_name | مشتری عزیز }}`.
Three sources are merged, weakest first, and each overrides the one before it:
1. the account's shared dictionary (`GET/PUT /v1/settings/content-vars` on the
panel API)
2. the person's profile and their first device: `user_id`, `first_name`,
`last_name`, `email`, `phone`, `city`, `region`, `country`, `language`,
`full_name`, `order_count`, `total_revenue`, `last_order_date`, `birthday`,
every stored trait, plus `device_platform` and `app_version`
3. the `vars` on this request
Your values win, which is the only safe order: a shared value must not beat the
person it is being sent to, and nothing must beat a value your system produced a
millisecond ago.
**A variable with no value and no fallback stops the send.** The outcome is
`suppressed` with reason `missing_personalisation`, and the list of missing keys
is in the log rather than in the response. An empty string counts as missing. If
the render produces no title and no body at all, the reason is `empty_content`
instead. Use `POST /v1/templates/preview` on the panel API to see exactly what a
set of values renders to, including which keys are missing; it calls the same
renderer the send path calls.
Latin digits in a substituted value are converted to Persian digits in the title
and the body, with a thousands separator. They are **not** converted in
`deep_link`, `icon`, `image` or anything in the template's `data` map, because
`myapp://order/۱۲۳۴۵` is a link an app cannot parse. A value that contains any
Latin letter keeps its ASCII digits, because the recipient is going to type an
order code like `AB-1234567` back into a search box.
## The response {#response}
`200` with this body:
| Field | Type | Present |
|---|---|---|
| `message_id` | string | always |
| `status` | string | always |
| `sent_at` | RFC3339 timestamp | always |
| `reason` | string | only when the message was deliberately not sent |
| `reason_fa` | string | only when `reason` is set. The rendered sentence, always in Persian on this host. |
| `error` | string | only when something failed |
| `replayed` | boolean | only when true |
`status` is one of:
| Status | Meaning |
|---|---|
| `sent` | at least one transport accepted it |
| `suppressed` | we deliberately did not send. `reason` says why. |
| `deferred` | held to be tried later |
| `failed` | every transport rejected it. `error` carries the gateway's words. |
A send:
```json
{
"message_id": "t7.order-8821-shipped",
"status": "sent",
"sent_at": "2026-08-01T12:00:00Z"
}
```
A replay of a key whose original send was refused:
```json
{
"message_id": "t7.order-8821-shipped",
"status": "suppressed",
"reason": "channel_opt_out",
"reason_fa": "کاربر این کانال را خاموش کرده است",
"replayed": true,
"sent_at": "2026-08-01T12:00:00Z"
}
```
> [!warn]
> **`200` does not mean the message went.** `suppressed`, `deferred` and
> `failed` all arrive as `200`, because the request was answered correctly and
> the send was not. The reason a transactional SMS with no approved pattern
> comes back as `200` with `"status": "failed"` is that the request was fine and
> the account is not ready to send. Branch on `status`, never on the HTTP code
> alone.
## Every failure {#failures}
| Code | Body | Cause | What to do |
|---|---|---|---|
| `400` | `malformed JSON: ...` | unparseable body, an unknown field, or a body over 256 KiB | fix the payload. Retrying will not help. |
| `400` | `transactional: user_id is required` | no `user_id` | fix the payload |
| `400` | `transactional: template_id is required` | `template_id` absent or zero | fix the payload |
| `400` | `transactional: idempotency_key is required` | no key in the body and none in the header | fix the payload |
| `400` | the key-shape message | the key fails `^[A-Za-z0-9._:-]{8,200}$` | fix the key. Do not generate a new one at random. |
| `400` | `transactional: unknown channel` | not one of the eight values | see [Channels](/en/docs/transactional#channels) |
| `400` | `transactional: this endpoint does not send marketing; use a campaign` | `"category": "marketing"` | build a campaign |
| `400` | `transactional: too many variables` | more than 40 entries in `vars` | send fewer |
| `401` | `{"error":{"code":"unauthenticated"}}` | no key, or a bad one | check the key |
| `401` | `{"error":{"code":"write_key_rejected"}}` | you sent a `wk_seg_` key | use the `sk_seg_` key |
| `401` | `{"error":{"code":"key_expired"}}` | the key has expired | issue a new one |
| `403` | `{"error":{"code":"forbidden","need":"campaign.send"}}` | the key lacks the permission | grant `campaign.send` |
| `404` | `{"error":{"code":"unknown_endpoint"}}` | this deployment has no send path configured, so the route was never registered | ask your operator, and read `GET /v1/capabilities` |
| `409` | `transactional: a message with this idempotency key is already in flight` plus `Retry-After: 1` | your own earlier attempt is still running | wait a second and repeat the same key |
| `429` | `rate limit exceeded: N requests per minute` plus `Retry-After: 60` | the account's per-minute limit | back off |
| `429` | `{"error":{"code":"budget_exhausted"}}` plus `Retry-After: 60` | the management host's weighted budget | back off |
| `503` | `budget_unavailable` | the budget could not be read, and this check fails closed | retry |
| `503` | `message not sent` | the template could not be loaded, or `category` held an unknown value, or the send path errored | check `template_id` and `category`, then retry |
| `200` | a full result | the message was sent and the ledger write failed | treat it as sent. Do not retry. |
That last row is deliberate. When the message really did go and only the record
of it failed, a caller told otherwise would retry and send a second one, so the
result is returned even though something went wrong on our side.
## Rate limits and the two envelopes {#limits}
Two separate mechanisms apply, and they fail in opposite directions on purpose.
**The per-tenant rate limit.** Read from the account on every request, falling
back to the install default `API_RATE_PER_MINUTE`, which is **0**, and zero
disables metering entirely. When a limit is in force, every response carries
`X-RateLimit-Limit` and `X-RateLimit-Remaining`, not only the refusals, because a
caller who cannot see how close it is has no way to slow down before being
refused. Over the limit is `429` with `Retry-After: 60`.
The window is a fixed minute bucket, not a sliding one. A caller can send close
to two windows' worth of traffic across a boundary. That trade is deliberate and
documented in the code.
**This limiter fails open.** If the limit cannot be read, the send is allowed.
The reasoning is on the endpoint: it carries order receipts and login codes, and
an outage in a counter must not be the reason somebody cannot sign in.
**The management host's weighted budget.** `PUBLIC_API_BUDGET_PER_MINUTE`
defaults to **600** cost units per minute per key. `POST /v1/messages` costs 1
unit. Over budget is `429 budget_exhausted` with `Retry-After: 60`. **This one
fails closed:** a budget that cannot be verified returns
`503 budget_unavailable`.
> [!note]
> **The error envelope on this route is not uniform.** On the management host,
> the authentication and permission refusals use the public envelope,
> `{"error":{"code":"...","message":"...","need":"..."}}`. Everything the
> handler itself produces, which is every `400`, the `409`, the rate-limit `429`
> and the `503`, uses the flat shape `{"error":"some text"}`. An error handler
> for this one route has to read both.
## Iranian SMS: the pattern requirement {#sms-patterns}
**A transactional SMS with no approved pattern does not fail at the gateway. It
never reaches one.** It is refused inside Segmentic and comes back as
`"status": "failed"` with
`"error": "sms: a service line requires an approved pattern"`.
Iran has two kinds of SMS line and choosing wrong fails in two different,
expensive ways.
| Line | Persian | Behaviour |
|---|---|---|
| Advertising | خط تبلیغاتی | Cheap, and invisible to every subscriber who has blocked advertising SMS at their operator, which is a very large share of Iranian numbers. The gateway still reports success. |
| Service | خط خدماتی | Reaches everyone, including those subscribers. Which is exactly why it may carry only transactional content, and only text the operator approved in advance. |
The line is chosen from the category, not by whoever writes the message:
`marketing` goes out on the advertising line, everything else on the service
line. It is deliberately not configurable per campaign, because the one place a
marketer would reach for an override is exactly the place that costs the
customer their service line.
So: every message sent through `POST /v1/messages` is routed to a service line,
and a service line requires a pattern. A **pattern** is the message text as the
operator approved it, with named placeholders, registered under a code. The
gateway rejects any text on a service line that does not match a registered
pattern.
This has happened in production. A migration in this repository records that the
template's `pattern_code` column was never populated, "so every order code and
every login code failed".
### Registering a pattern {#pattern-registering}
Registration happens in two places and only one of them is Segmentic.
1. **With your operator or gateway.** You submit the text and they approve it.
Segmentic has no integration that does this and no integration that asks
Kavenegar or SMS.ir whether a code is approved.
2. **In Segmentic**, so the send path knows the answer without asking anyone:
```text
GET /v1/sms-patterns template.read
POST /v1/sms-patterns template.write
POST /v1/sms-patterns/{id}/status template.write
```
These are on the panel API. **There is no SMS-pattern route on the management
host**, so pattern registration and approval state cannot be automated from an
integration.
`POST /v1/sms-patterns` takes `provider_id` (required, non-zero), `pattern_code`
(required), `body` (required) and an optional `variables` array. It returns the
stored pattern: `id`, `provider_id`, `pattern_code`, `body`, `variables`,
`status`, `status_note`, `created_at`, `updated_at`.
`POST /v1/sms-patterns/{id}/status` takes `{"status": "...", "note": "..."}`.
The legal moves are:
```text
pending -> approved | rejected the operator answered
approved -> revoked the operator withdrew it
any -> pending the registered body was edited
```
Those transitions are enforced in SQL, in the `WHERE` clause, so two people
answering at once cannot both win. Editing the `body` of an approved pattern
drops it back to `pending` and clears the note; editing only the variables, or
saving an unchanged body, leaves the state alone. A revoked pattern is not
re-approved by a status call, it is registered again, and it lands on `pending`.
Uniqueness is `(tenant, provider, pattern_code)`.
### Binding a pattern to a template {#pattern-binding}
Two fields on the template do this:
- `pattern_code`: the operator's code
- `pattern_tokens`: a map from **the gateway's token names** to **your own
variable names**
The two namespaces are genuinely different. Yours is `first_name` and
`order_id`; theirs is often `token`, `token2`, `token3`.
> [!warn]
> **A pattern token is filled from the template's `data` map, not from `vars`
> directly.** The send path reads the value out of the rendered `data`, so a
> variable you pass in `vars` reaches the gateway only if the template's `data`
> map has a key that renders it.
The working shape, for a pattern whose approved text is
«کد ورود شما: %token%» and whose token is named `token`:
```json title="the template"
{
"name": "کد ورود",
"channel": "sms",
"category": "transactional",
"body": "کد ورود شما: {{code}}",
"data": { "code": "{{code}}" },
"pattern_code": "verify-login",
"pattern_tokens": { "token": "code" }
}
```
```json title="the send"
{
"user_id": "u_9137",
"channel": "sms",
"template_id": 42,
"idempotency_key": "otp:2026-08-01:u_9137",
"vars": { "code": "8391" }
}
```
Values that travel through `data` keep their Latin digits, because `data` is
machine-read.
Approval is resolved with the template in one query and cached for the same 30
seconds, using `EXISTS (... status = 'approved')` against the pattern code. The
provider is deliberately not part of that check, because which gateway carries
the message is decided by routing at send time.
### What happens without one {#pattern-missing}
| Condition | Result |
|---|---|
| service line, no `pattern_code` on the template | refused before the gateway: `failed`, `sms: a service line requires an approved pattern` |
| service line, code present but not currently approved | refused before the gateway: `failed`, `sms: this pattern is not approved by the operator` |
| the number does not parse as an Iranian mobile | `failed`, `sms: not a valid Iranian mobile number` |
| no service line configured at all | `failed`, no provider. It does not silently fall back to the advertising line. |
Refusing here rather than letting the gateway punish it is deliberate: sending
under a withdrawn pattern is the offence that costs the customer the line, and
with the line goes every order code and every login code they send.
Two related rules follow from the same fact:
- **Link shortening is never applied on a service line.** The test is whether a
pattern code is present. Rewriting any part of an approved pattern makes it
stop matching.
- **The opt-out footer is never appended to transactional text.** A marketing SMS
gets `لغو۱۱` added to its body; transactional text is left exactly as approved,
for the same reason.
## Complete examples {#examples}
### curl {#example-curl}
```bash
curl -sS -X POST https://api.segmentic.net/v1/messages \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_9137",
"channel": "sms",
"template_id": 42,
"category": "transactional",
"idempotency_key": "order-8821-shipped",
"vars": { "code": "8391" }
}'
```
```json title="200"
{
"message_id": "t7.order-8821-shipped",
"status": "sent",
"sent_at": "2026-08-01T12:00:00Z"
}
```
### Node {#example-node}
The retry loop is the part worth copying. It repeats the same key, so a repeat
that lands on a completed send returns the stored answer instead of sending
again.
```js title="send.js"
const KEY = process.env.SEGMENTIC_API_KEY; // sk_seg_...
async function sendTransactional(payload) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch("https://api.segmentic.net/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (res.status === 409 || res.status === 429 || res.status === 503) {
const wait = Number(res.headers.get("Retry-After") || 1);
await new Promise((r) => setTimeout(r, wait * 1000));
continue; // the SAME key, never a new one
}
const out = await res.json();
if (!res.ok) throw new Error(`segmentic ${res.status}: ${JSON.stringify(out)}`);
return out;
}
throw new Error("segmentic: gave up after 4 attempts");
}
// One shipment, one key, derived from the shipment itself.
const result = await sendTransactional({
user_id: "u_9137",
channel: "sms",
template_id: 42,
category: "transactional",
idempotency_key: "order-8821-shipped",
vars: { code: "8391" },
});
if (result.status !== "sent") {
// suppressed, deferred or failed. All of these arrive as HTTP 200.
console.warn("not delivered:", result.status, result.reason, result.error);
}
```
## What this endpoint does not do {#not-built}
Each of these is absent from the code, not merely undocumented.
- **No lookup by idempotency key.** Once you have lost the response, there is
no route on the management host that answers "what did key X produce". If
you have the message id, the panel API finds its exact row through
`GET /v1/messages?message_id=...`. The same route also searches by user,
recipient and date range.
- **No message-log route on the management host.** `POST /v1/messages` is there;
`GET /v1/messages` is not.
- **No template routes on the management host.** See
[Templates](/en/docs/transactional#templates).
- **No SMS-pattern routes on the management host.** See
[Registering a pattern](/en/docs/transactional#pattern-registering).
- **No scheduling.** There is no send-at field. The call sends now or it does
not send.
- **No cancel and no recall.** A recall applies to a message that waited in a
queue. This one does not wait.
- **No attachments and no inline content of any kind.**
- **No delivery-receipt callback for one message.** The SMS operator receipt is
collected, but it is exposed only aggregated, in a campaign's report. What you
can subscribe to is the ordinary event stream: `message_sent` and
`message_failed` are events like any other, so an outbound relay can forward
them. See [Webhooks](/en/docs/webhooks).
---
# Consent, unsubscribe and sending caps
> What can stop a send, and why that is a feature rather than an obstacle.
> https://segmentic.net/en/docs/consent
This page is a list of everything that can stop a message. Read it as the
feature it is.
A marketing tool with no limits produces a person who receives fifteen pushes on
a Tuesday, turns notifications off, and is unreachable for ever. The campaign
that felt successful this week removed the audience for every campaign after it.
Everything below exists to make that impossible, including the parts a marketer
under pressure would like to switch off.
## Everything that can stop a send {#what-stops-a-send}
> Diagram: How consent, topic preferences and quiet hours produce a delivery or suppression decision
Each of these is recorded on the message with its own reason code, so a report
can answer "the segment said sixty thousand, why did forty-one thousand receive
it". That question is the single most common one a platform like this gets, and
the one it usually cannot answer.
| Stopped by | Applies to | Reason code |
|---|---|---|
| an operator suppression | everything, including a security alert | `suppressed` |
| the account-wide control group | marketing | `global_holdout` |
| a channel the person switched off | everything except `critical` | `channel_opt_out` |
| a topic they unsubscribed from | everything except `critical` | `topic_opt_out` |
| a global unsubscribe | marketing | `unsubscribed` |
| a self-service pause | marketing | `paused` |
| the message's own expiry | everything | `expired` |
| a frequency cap | marketing by default | `frequency_cap` |
| the account's rate ceiling | whatever it names, never `critical` | `rate_limited` |
| a reduced cap for somebody who stopped engaging | marketing by default | `fatigued` |
| a campaign's own pacing rule | that campaign | `too_soon` |
| quiet hours | marketing by default | `quiet_hours` |
| the Iranian weekend rule | marketing by default | `weekend` |
| a campaign or journey control group | that campaign or journey | `holdout` |
| a kill switch | whatever the switch covers | `switched_off` |
| a recall after the message was queued | that message | `recalled` |
| losing a slot to a more important message | that message | `arbitrated` |
| no device, no number, no chat | everything | `not_reachable` |
| a template variable with no value and no fallback | that message | `missing_personalisation` |
| a render that produced nothing at all | that message | `empty_content` |
| a rehearsal (account in test mode) | everything | `dry_run` |
| a lookup this decision depends on being unreadable | everything | `*_unavailable` |
Three categories decide which of these apply.
| Category | Skips |
|---|---|
| `marketing` | nothing |
| `transactional` | quiet hours always, and a cap unless that cap names `transactional` in its `applies` |
| `critical` | everything above, plus a channel opt-out and a topic opt-out, and nothing a policy says can change it |
**Critical is never governed by a cap, whatever a policy says.** It is enforced
in the code rather than validated on write, so no stored policy, however it got
there, can express it. A fraud alert or a password-change notice held back by a
frequency cap is the one failure this whole layer exists to prevent, and "the
customer configured it that way" is not a defence anybody would accept
afterwards.
The category comes from the template, not from the caller. That is the field
that stops an order confirmation being held until 9am by quiet hours, and
equally stops a promotion being tagged transactional to bypass a frequency cap.
## The order the rules run in {#order}
The order is not an implementation detail. It decides which reason a report
shows, and one step of it decides whether an uplift number means anything.
| Step | Rule | Applies to |
|---|---|---|
| 1 | Suppression. Nothing below this line can re-authorise the message. | everything |
| 2 | The account-wide control group | marketing |
| 3 | Channel opt-out | everything except `critical` |
| 4 | Topic opt-out, when the message names a topic | everything except `critical` |
| 5 | Global unsubscribe | categories that do not bypass limits, which is marketing |
| 6 | Pause | marketing |
| 7 | Expiry: `valid_until` has passed | everything |
| 8 | Critical is allowed here, subject only to the last step | `critical` |
| 9 | Frequency caps and the account rate ceiling | marketing, plus any category a cap names in `applies` |
| 10 | Quiet hours and the weekend rule | marketing only |
| 11 | The control group | the campaign or journey concerned |
Two of those positions are load-bearing.
**Expiry is checked before anything that defers.** Quiet hours push a message to
the morning and the weekend rule pushes it past Friday, so "tonight's match"
arrives on Saturday unless somebody says when it stops mattering. Checking it
after consent and before everything else also means an expired message is not
reported as capped or as quiet-houred, which would have somebody adjusting a
rule that had nothing to do with it.
**The control group is checked last, after caps and quiet hours.** A control
group that still contains people who were over their daily limit, while the
treated group had exactly those filtered out, is not a baseline. Fatigued people
convert less, so leaving them in the control arm and out of the treated one makes
every campaign look better than it was, by an amount that varies with how heavily
the account sends. Checking consent first and the holdout last is what makes both
arms mean "people who would have received this message".
## Consent: three states, three different promises {#consent-states}
They are deliberately not equivalent.
| State | Promise | Blocks |
|---|---|---|
| `suppressed` | an operator excluded this person: a fraud investigation, a legal hold | everything, including a security alert |
| channel opt-out | "do not contact me on SMS" | everything on that channel except `critical` |
| `unsubscribed` | "stop marketing to me" | marketing only |
An earlier version of this platform treated all three as absolute. It was
safer-looking and wrong in the one direction that matters: **a person who cannot
receive an OTP cannot sign in**, and nothing on the page would explain why.
Reading a one-click unsubscribe as "send me nothing" locks out of their own
account everybody who ever unsubscribed from a newsletter.
A channel opt-out is honoured for everything except a critical message, because
the sender has other channels and the recipient named this one specifically.
**A pause drops rather than defers.** Deferring is the tempting reading: the hold
has an end date, so why not re-arm the timer? Because a thirty-day defer delivers
a thirty-day-old offer, and because every message paused across a busy month
would arrive at once on the day it lifts. That burst is the thing the recipient
was escaping. **What returns after a pause is the next campaign, not the
backlog.**
A pause resumes at **09:00 local on the target day**, not at the clock time it
was set. "Pause for 30 days" set at 23:50 that resumed at 23:50 would put the
first message back at midnight, which is precisely the experience the pause was
reached for.
Two more properties matter for anybody reading the code around this:
- **Consent is not carried on a queued message.** A message that waited overnight
re-reads it before it goes. Serialising it would deliver an answer somebody
revoked eight hours ago.
- **The campaign runner prefetches consent one page at a time**, one query per
page rather than one per recipient. A prefetch failure returns no map at all,
which the decision layer reads as "not prefetched" and looks up per recipient.
It never reads as "allow". Failing open there would be the one bug in this area
that actually messages somebody who opted out.
## Topics: making "stop" divisible {#topics}
A platform that offers only a global unsubscribe converts every complaint, too
frequent, wrong channel, not interested in this one thing, into the same
irreversible answer, and the list only ever shrinks.
A **topic** is one kind of message you send, named in your own words because the
recipient reads it.
| Field | Meaning |
|---|---|
| `key` | machine name, `^[a-z0-9][a-z0-9_-]{0,63}$`. **Not editable after creation**, because campaigns hold it. |
| `name` | what the recipient sees. Required, at most 120 characters. |
| `description` | at most 500 characters |
| `channels` | the media this topic is ever sent on. Empty means all of them. |
| `default_on` | `true` means subscribed unless the recipient says otherwise; `false` means they have to opt in. Absent means `true`. |
| `position`, `archived` | ordering and retirement |
Whether one send is allowed is answered in this order:
1. **A topic id of zero is always subscribed.** Every transactional message,
every campaign written before topics existed. "No topic" must never read as
"blocked": that would have silenced every OTP on the platform the day topics
shipped.
2. An explicit choice wins, in either direction. Somebody who ticked a box that
is off by default stays subscribed, and a later change to the default must not
quietly remove them.
3. An **archived** topic fails closed. The recipient can no longer see it on
their preference page, so they have no way to stop it.
4. A topic not sent on this channel fails closed.
5. Otherwise the topic's own `default_on`.
6. A topic id that resolves to nothing fails closed: it is either a deleted row
or a campaign pointing at another account's topic, and neither is a thing to
send on.
Managing topics and changing one person's subscriptions are separate
permissions, deliberately:
```text
GET /v1/preferences/topics settings.read
POST /v1/preferences/topics settings.write
PATCH /v1/preferences/topics/{id} settings.write
DELETE /v1/preferences/topics/{id} settings.write (archives, never deletes)
POST /v1/preferences/topics/{id}/restore settings.write
POST /v1/preferences/topics/reorder settings.write
GET /v1/profiles/{user_id}/preferences profile.read
PUT /v1/profiles/{user_id}/preferences profile.write
```
Managing topics is a settings screen and nothing on it names a person. Reading or
changing one person's subscriptions is that person's own record, and the API that
writes it can silence a customer or resubscribe somebody who opted out.
A duplicate key is `409`; every other validation failure is `400`.
`PUT /v1/profiles/{user_id}/preferences` takes:
```json
{
"choices": [ { "topic_id": 4, "channel": "email", "subscribed": false } ],
"unsubscribe": true,
"pause_days": 30
}
```
Each field is optional and distinguishable from false, so "not mentioned" differs
from "set to false". A partial update that silently resubscribed somebody because
the caller omitted a field would be the worst bug this area could have.
`pause_days` is at most **365**; the panel and the preference centre offer 30, 60
and 90.
**When an operator changes somebody's preferences, the source is recorded as
`agent:`, never `preference_center`.** A compliance request that cannot tell
those two apart is one this platform cannot answer.
> [!danger]
> These routes are on the control plane, which is not routed from the internet
> in the reference deployment. **There is no consent or preference route on the
> management host at all.** An integration holding an `sk_seg_` key cannot read
> or set anybody's subscriptions.
## Frequency caps {#caps}
A cap is one object with six knobs, and the same primitive answers several
different questions depending on how they are set.
| Field | Meaning |
|---|---|
| `channel` | empty counts every channel together, which is how you stop one person being hit five times by five different teams |
| `window` | see below |
| `max` | the ceiling |
| `applies` | which categories this cap governs. Empty means marketing only. |
| `scope` | what the counter is keyed by |
| `budget` | which pool this cap counts. Empty governs every send. |
Windows:
| Window | Resets |
|---|---|
| `day` | local midnight |
| `week` | **Saturday**, the start of the Persian week |
| `month` | the first of the **Jalali** month |
| `10m`, `1h`, `24h` | sliding |
| `interval:` | a sliding window of any length |
The week and month windows are on the Persian calendar on purpose. An ISO week
would reset the count mid-week from the marketer's point of view, letting a
person receive twice the cap inside one week they recognise. A monthly cap on the
wrong calendar is a number that means nothing to the person who set it.
An unrecognised window name is treated as a calendar day, the tightest common
period, rather than rejected.
Scopes:
| `scope` | Counts |
|---|---|
| empty | one person. The default, and what "frequency cap" means without qualification. |
| `tenant` | the whole account. Stops one runaway journey saturating an audience while every per-person cap is still satisfied. |
| `campaign` | one campaign's total output. A blast radius: a mistake caught at fifty thousand instead of at four hundred thousand. |
| `recipient_campaign` | one person's history with one campaign, which is what "not more than once a week" is asking about |
The last one is separate from the per-person scope and the distinction is the
whole point: a per-person cap is a budget shared by everything that wants to
reach somebody, and spending it on the pacing of one recurring campaign would
silence every other campaign for the rest of the day.
A new account starts with quiet hours 23:00 to 09:00, timezone `Asia/Tehran`,
and caps of **3 per day** and **10 per week**. That is deliberately restrictive:
somebody who never opens this screen should still not be able to burn their
audience down.
**The claim is atomic.** "Is there room, and if so it is now mine" is one round
trip. Counting and then incrementing is a read and a write with a gap in between,
and two workers sending to the same person land in that gap together: both read
two of three, both send, and the person gets four. The claim happens immediately
before the transport and is released when nothing was delivered, so a provider
outage does not spend somebody's daily budget.
**Fatigue** lowers a cap for somebody who has stopped engaging, and can only ever
lower it. The default is: after **10** consecutive ignored messages, multiply the
cap by **0.5**, with a floor of **1**. Never zero, because silence is what an
unsubscribe is for and a cap that reaches zero on its own makes somebody
unreachable without them ever having asked. Somebody who has never engaged and
has barely received anything is not fatigued: the rule waits for twice the
threshold before it applies to them.
A **budget** names which pool a cap counts, and it answers the question every
account with a paid SMS bundle asks: "this campaign should not come out of the
newsletter's quota". It is impossible to use as an escape hatch. A send carrying
no budget is governed by every unbudgeted cap, and the floor is unbudgeted by
construction, so naming a budget can only ever subject a send to **more** rules
than it had.
`PUT /v1/settings/governance` accepts every window in the table above except
`interval:`, which is built by the campaign runner from a campaign's
own minimum interval rather than written into a policy. A cap with `max` below 1
is refused: zero reads as "no messages", which is what switching governance off
is for, and accepting it would leave two ways to express one state and no way to
tell which one was meant.
Two caps are the same cap only if their `scope`, `channel`, `window` and
`budget` all match. A per-person daily cap and an account-wide daily ceiling are
two different counters and may both be set.
## The account rate ceiling {#rate-ceiling}
A cap with `scope: "tenant"` and a short window is a rate ceiling: not how many
messages one person receives, but how fast the whole account sends.
```json
{
"scope": "tenant",
"window": "1h",
"max": 10000,
"applies": ["marketing", "transactional"]
}
```
**It defers rather than drops, and it is the only cap that does.** Every other
cap discards the send: somebody already over their daily limit must not be held
until morning, because that piles the night onto 09:00 and delivers exactly the
burst the cap prevents. A rate ceiling is asking the opposite question, and
spreading the send over time is the whole thing it was asked to do. A two
million person campaign against a ten thousand an hour ceiling reaches everybody,
over roughly eight days, rather than reaching the first ten thousand.
A held message reports `rate_limited` and carries the instant it will be retried:
the next boundary for a calendar window, a full window from now for a sliding
one. The release is spread across up to a tenth of the window, capped at five
minutes, so a window's worth of held messages does not resume on a single
instant. The offset is derived from the recipient rather than drawn at random,
so a message that is retried lands on the same instant instead of drifting later
with every attempt.
Campaigns pace themselves against the ceiling rather than sending into it: the
runner reads the account's remaining allowance once per page of recipients and
waits when a page will not fit. This is why a ceiling does not turn a large
campaign into millions of queued rows. It is an optimisation and not the
enforcement, which stays with the atomic claim, so two campaigns pacing
independently against one ceiling still cannot exceed it.
> [!warn]
> **`applies` is empty by default, and empty means marketing only.** A rate
> ceiling written without it counts a marketing blast and lets every
> transactional send past uncounted, while reporting the number you set. Write
> the categories out. The panel does.
Whatever `applies` says, `critical` is never governed. See
[Categories](/en/docs/transactional#category) for what belongs there, and note
that a **login code is critical, not transactional**, precisely so that a
marketing campaign filling the account's hour cannot hold back the code somebody
is waiting on to sign in.
## Quiet hours {#quiet-hours}
| Field | Meaning |
|---|---|
| `start_hour`, `end_hour` | in the **recipient's own local time**, wrapping midnight when start is after end |
| `skip_weekend` | also silences marketing on the Iranian weekend, which is **Friday** |
| `policy` | `defer` holds until the window ends, `drop` discards |
| `release_spread_minutes` | how wide a window the held messages are released across. Zero means 15 minutes. |
**The default is 23:00 to 09:00, not the 22:00 to 08:00 most platforms ship.**
Tehran runs late: shops are open at 22:00 and the working day starts at nine.
Silencing at ten would throw away the most responsive hour of the evening.
`drop` is correct only for content whose value is gone by morning, like a
two-hour flash sale. Everything else defers, because the offer is still good at
9am.
**Deferred messages are released across a spread window**, 15 minutes by default,
offset by a stable hash of the user id. Without it, every message held between
23:00 and 09:00 carries the same release instant, so the whole night's traffic is
handed to the gateways in the same second: your own API takes a spike it never
sees during the day, the provider rate-limits, and the retries land together too.
The offset is hashed rather than drawn at random so a message deferred to 09:04
and then retried does not drift later on every attempt.
**Per-channel windows** override the default for one channel, and a
channel-specific window wins outright rather than merging. An SMS at 23:30 wakes
the phone up on the bedside table; an in-app message waits inside an app nobody
has open, and silencing it costs a send for no benefit to anybody. **A zero-width
window, where start equals end, is how you say "this channel is never quiet".**
Deleting the row instead falls back to the default, which is the opposite of what
you meant.
**Caps are checked before quiet hours**, so somebody already over their limit is
dropped rather than deferred. Deferring them would pile the whole night onto the
morning.
Timezone resolution never falls back to UTC: the recipient's own zone, then the
account default, then Tehran. Defaulting to UTC would put quiet hours three and a
half hours out for the entire country.
## The floor you cannot switch off {#floor}
Everything in a policy is yours to set. This is not.
```text
quiet hours 22:00 to 08:00
caps 10 per day, 30 per week
timezone Asia/Tehran
```
**The floor is a Go constant, not a row in your account.** A floor stored beside
the policy it constrains is a floor somebody can edit with the same screen, the
same API token and the same mistake. Raising it is a deploy, which is the point:
it should take a decision by us, not a slider in a dashboard.
The numbers are the widest defensible reading of the Iranian rules plus what an
audience tolerates, not what we would recommend. A customer who wants tighter
gets tighter; a customer who wants looser gets this.
Clamping happens **on write**, not on read, so the screen shows what will
actually happen. Somebody who types a cap of four hundred and is silently
enforced at ten has been lied to by the interface and will find out from a report
weeks later. A cap the floor requires and the policy omits is added.
**Switching governance off does not remove the floor.** `enabled: false` strips
your own rules and leaves the floor standing. The switch reads as "pause my own
rules", which is a reasonable thing for an operator to want. What it cannot be
allowed to mean is that the person on the other end can now be messaged four
hundred times at three in the morning.
Separately from the floor, the platform holds the **legal commercial-messaging
window** as data: quiet from 22:00 to 08:00, the complement of 08:00 to 22:00.
That is a conservative reading. The authoritative source for the commercial
window was not something we could pin down, and published guidance says 8 to 22
or 8 to 23; the tighter of the two is the one to be wrong in the safe direction
with. It is held as data rather than inferred from the quiet-hours floor so that
when somebody does pin it down, what changes is a number and not the shape of
anything.
## The preference centre {#preference-centre}
The recipient's own screen. It is served by the ingest host as two plain HTTP
endpoints and one self-contained HTML page. **No JSON API, no bundle, no
JavaScript.**
The person opening it arrived from a link in an email. They have no account here,
no session, and quite possibly a connection that will not fetch a second asset. A
screen whose whole job is to be an easier option than the unsubscribe button
cannot be the screen that sometimes fails to load. It is also why the form is a
plain POST: every checkbox the recipient touches has to survive being submitted
by a mail client's embedded browser, and those are the browsers that break first.
```text
GET https://in.segmentic.net/e/p render the page
POST https://in.segmentic.net/e/p apply the form
GET https://in.segmentic.net/e/u the unsubscribe confirmation page
POST https://in.segmentic.net/e/u the actual one-click unsubscribe
```
Both carry `sg_mid` (the message id) and `sg_t` (a signed token). Identity is
proven by verifying the token against the message id. **A bad signature and an
unknown message get exactly the same response**, so nobody can probe which
message ids exist.
The form offers: `save`, `unsubscribe_all`, `resubscribe`, `pause` (with
`pause_days`), `resume`. `unsubscribe_all` explicitly clears any pause, because
somebody who unsubscribes while paused has made the stronger statement and
leaving a pause underneath it would silently expire into "resubscribed" later.
Two details that are easy to get wrong and are the whole correctness argument:
- The parser iterates **the boxes that were on the page**, not the fields that
arrived, using a hidden companion field for each checkbox. An unticked checkbox
is absent from a POST body entirely, so reading only what arrived would make
"I unticked everything" indistinguishable from "I changed nothing", and the
recipient would press save and watch nothing happen.
- After saving, the state is **re-read rather than echoed**. The two differ
whenever anything was rejected, an unknown topic or a channel a topic is not
sent on, and showing the submitted form back would tell the recipient a change
took effect when it did not. This screen's only value is that it is believed.
A topic with no channel list shows three boxes, email, SMS and push, rather than
one per channel. **A channel the person has switched off is shown disabled and
labelled, not hidden**, because somebody wondering why the newsletter stopped
needs to see that email itself is off.
If the preference state cannot be loaded at all, the page still renders a
fallback that offers the unsubscribe. Sending somebody who wanted to reduce their
mail to an error page is how a preference centre produces a spam complaint.
The pages are served `no-store`, `noindex, nofollow` and `Referrer-Policy:
no-referrer`. The page holds one person's subscription settings and is reached by
a bearer link, so the referrer policy stops that link, signature and all, leaking
to any host an image or a click reaches.
## Unsubscribing over GET does not unsubscribe {#unsubscribe}
`GET /e/u` renders a page with a button. It does not opt anybody out. This is the
single most important paragraph on this page.
Security scanners at most Iranian banks and large retailers follow every link in
every incoming message before the recipient ever sees it. A GET that opted people
out would unsubscribe an entire company list the moment the campaign arrived,
silently, and the customer's first sign of it would be a reach report that
collapsed.
`POST /e/u` is the RFC 8058 one-click path. It is what Gmail and the rest call
when the recipient uses the Unsubscribe button next to the sender's name.
**There is no confirmation step there, by design:** the mailbox providers that
now demand this header treat a landing page as non-compliance. So the two verbs
on the same path are two different products, and each is wrong in the other's
place.
**One-click is a global opt-out, not a per-channel one**, even though the token
names the channel the message went out on. What the person pressing that button
means is "stop". Reading it as "stop emailing me, but the SMS and the Bale
messages continue" is the interpretation that is both legally weaker and, to the
recipient, indistinguishable from ignoring them. Under-unsubscribing is a
compliance risk; over-unsubscribing is a marketing one, and only one of those
ends a contract.
**The POST answers `200` even when the failure is ours.** A mailbox provider that
sees an error here may present the message as unsubscribe-broken, and retries
from a provider's infrastructure are not something we can shape. The body says
what happened; the status code says the provider should stop worrying.
On email, the links are added by the layout, not by your template:
- `List-Unsubscribe: <...>` and `List-Unsubscribe-Post:
List-Unsubscribe=One-Click` are set on every message that has an unsubscribe
URL. Since 2024 Gmail and Yahoo require a working one on bulk mail, and a
sender without one has their whole domain's delivery degraded, not just the
message that omitted it.
- The visible unsubscribe link goes in the footer. The preference-centre link
goes **beside it, never instead of it.** Offering only the settings page to
somebody who has decided to leave is the pattern every mailbox provider treats
as a dark one, and the recipient's answer to it is the spam button.
- Your own links are rewritten for click tracking **before** the shell is wrapped
around the message, so the unsubscribe link the layout adds afterwards is
untouched. A recipient must always be one click from leaving with nothing of
ours in the way.
## Replying to an SMS {#sms-reply}
An Iranian marketing SMS carries the opt-out instruction `لغو۱۱` appended to its
body. It is added once: the footer is skipped when the body already contains
`لغو`. **Transactional text never gets it**, because a service line carries a
pattern the operator approved and appending to it makes it stop matching. See
[Transactional](/en/docs/transactional#sms-patterns).
Billing counts the body **with** the footer. The composer's part count is taken
**without** it, so a marketing SMS can be quoted at one part and invoiced at two.
Inbound replies are matched **exactly after normalisation, never as a
substring**, so a message that happens to contain a stop word inside a sentence
does not opt anybody out.
| Meaning | Words |
|---|---|
| opt out | `لغو`, `لغو11`, `لغو ۱۱`, `لغو۱۱`, `11`, `۱۱`, `1`, `۱`, `off`, `stop`, `cancel`, `unsubscribe`, `end`, `قطع`, `توقف`, `نمیخوام`, `نمیخوام` |
| opt in | `شروع`, `عضویت`, `بله`, `start`, `on`, `yes`, `subscribe`, `22`, `۲۲` |
**SMS keeps a second opt-out list keyed by phone number**, separate from the
profile-keyed consent above, because the reply arrives from a handset that may
match no profile at all. It is checked for marketing only, and **the lookup fails
closed**: if it cannot be read, the marketing SMS does not go.
## Every reason code {#reasons}
Each one is stored on the message and rendered into a sentence server-side, so
the API, the CSV export and the campaign's reach report say the same words.
| Code | Meaning |
|---|---|
| `unsubscribed` | global opt-out. Marketing only. |
| `suppressed` | operator-side exclusion. Blocks everything. |
| `channel_opt_out` | this medium switched off |
| `topic_opt_out` | the preference centre's verdict. Kept distinct from an unsubscribe because in every report that follows they are opposite facts: one is a list that is still working, the other is one that is not. |
| `paused` | a self-service hold with an end date |
| `frequency_cap` | over a cap |
| `rate_limited` | over the account's rate ceiling. The one cap reason that defers rather than drops: the message carries the instant it will be retried. Named apart from `frequency_cap` because the two send you to different numbers, and widening a per-person cap to fix a rate ceiling changes what every recipient receives. |
| `too_soon` | over the campaign's own pacing rule. Distinct from a frequency cap because the two are different people's decisions about different things. |
| `quiet_hours` | inside the quiet window |
| `weekend` | the Iranian weekend rule |
| `holdout` | a campaign or journey control group |
| `global_holdout` | the account-wide control group |
| `not_reachable` | no device, no number, no chat |
| `campaign_paused` | the campaign stopped |
| `recalled` | queued and then cancelled before it went out. The message was right when it was made and wrong by the time it would have arrived. |
| `switched_off` | a kill switch |
| `expired` | `valid_until` passed. Nothing went wrong; a report that showed it as a failure would have somebody chasing it. |
| `arbitrated` | lost a slot to a more important message and had no cheaper channel to fall back to |
| `fatigued` | stopped by the reduced cap of somebody who has stopped engaging |
| `missing_personalisation` | a template variable had no value and no fallback |
| `empty_content` | the render produced nothing at all |
| `dry_run` | a rehearsal: everything ran except the transport |
| `dry_run_no_channel` | a rehearsal on a channel that is not configured |
| `policy_unavailable` | the policy could not be read |
| `consent_unavailable` | consent could not be read |
| `topics_unavailable` | topics could not be read |
| `counters_unavailable` | the counters could not be read |
The last four are worth their own note. They used to be built by string
concatenation, which put a value outside the known set into every report that
rendered it; the Persian dashboard falls through to the raw code for anything it
does not recognise, so a marketer read `counters_unavailable` on their screen
during a Redis blip. They are constants now.
## Which way each thing fails {#failure-direction}
Some things must stay open when they break and some must close, and the wrong
choice in either direction is an incident.
| Mechanism | On failure | Why |
|---|---|---|
| policy, consent, topic and counter lookups | **closed** | If Redis is down we cannot tell whether somebody has already had their three messages today, and guessing "no" during an outage is how a whole audience receives a campaign twice. Refusing to send is recoverable; sending is not. |
| channel and account kill switches | **open** | Failing closed means one Redis outage silences an entire installation, including its login codes. |
| recall lookups | **open** | A recall only cancels something already permitted, so an outage costs a cancellation rather than somebody's consent decision. |
| the SMS phone-keyed opt-out | **closed** | It is a real consent decision made outside this system. |
| test mode | **closed** | An account that asked not to send reaching real people is not recoverable. |
| the transactional rate limiter | **open** | That endpoint carries order receipts and login codes. |
| the management host's request budget | **closed** | It carries reports and audience queries. Nobody's checkout breaks because a report waited. |
| the campaign approval rules | **closed** | |
**Kill switches are checked first**, before the template and before governance,
and the most specific switch that is off is the one reported. They exist at six
levels: operator, account, channel, path, campaign, and campaign-and-channel.
**A recall applies only to a message that actually waited.** A send decided and
delivered in one pass has no window in which to be recalled, and paying for the
lookup on every one of them would put a round trip on the hot path to answer a
question that cannot be yes. Pausing or cancelling a campaign issues a recall for
the sends it already queued, best-effort and never fatal.
**Test mode is checked after everything else**, after consent, caps, quiet hours,
switches, the template and personalisation, and only the transport is skipped. A
rehearsal is recorded with `is_test` set and reason `dry_run`, so it can never be
counted in a reach report.
## What does not exist {#not-built}
- **No consent or preference route on the management host.** An integration
cannot read or set a person's subscriptions, cannot unsubscribe anybody, and
cannot list topics. The routes above are on the control plane, which the
reference deployment does not publish.
- **Nothing in the shipped code sets `suppressed`.** The state is read on every
send and blocks everything including a critical message, the storage method
exists, and it has no caller: no route, no panel screen, no worker. Today it is
a database write somebody makes by hand.
- **Nothing sets a channel opt-out except a redacted hard bounce or spam
complaint.** The deliverability service acts only on a report whose recipient
the provider withheld: it recovers the person from the message id in
`message_log` and switches off the email channel, never another one. A report
that names the address takes the other path: a hard bounce, a spam complaint
or a repeated soft bounce writes an `email_suppressions` row instead, which is
not a channel opt-out. There is no API for it, and the preference centre shows
a switched-off channel as disabled without offering a way to switch it back
on. A person who wants SMS off has no self-service path to a channel opt-out.
Replying `لغو` does stop marketing texts, but it lands on the separate
number-keyed list in [Replying to an SMS](/en/docs/consent#sms-reply) rather
than on the profile, and no bounce or complaint produces a channel opt-out
either, because that writer only ever touches email.
- **Sliding-window caps cannot be configured through the API.** See
[Frequency caps](/en/docs/consent#caps).
- **No per-topic frequency cap.** A cap is keyed by channel, account, campaign or
recipient, never by topic.
- **No `valid_until` on a transactional send.** The expiry rule exists in the
decision layer and `POST /v1/messages` has no field for it. See
[Transactional](/en/docs/transactional#request).
- **No preview of what a policy change would have blocked.** Clamping tells you
what the rules will be; nothing tells you how many of last month's messages
they would have stopped.
---
# In-app messages and the inbox
> Banners, modals and surveys inside a site or an app, and the inbox messages persist in.
> https://segmentic.net/en/docs/onsite
## What an on-site message is {#what-this-is}
> Diagram: How page views, segment entry and events become eligible banners, modals and surveys
The display rules are not evaluated on our server. They are evaluated in the visitor's browser.
The reason is arithmetic. If every page view makes a request to us, a mid-sized Iranian shop sends us a million requests a day, on the critical rendering path of their own site, with our latency in front of their content and our availability in front of their business. Instead the SDK fetches the list of live campaigns once a minute and matches locally.
That design has one cost and you need to know it: **the targeting rules are public.** Anybody can open the Network tab and read who you are targeting and on what condition. Nothing secret may go inside a targeting rule.
Three endpoints on the collector, with the write key `wk_seg_...`:
| Method and path | What it does |
| --- | --- |
| `GET /v1/onsite` | The list of live campaigns |
| `POST /v1/onsite/event` | Records an impression, click, dismissal or conversion |
| `POST /v1/onsite/response` | Records a survey answer |
All three are registered only when your installation has an on-site store. If it does not, the answer is `405 Method Not Allowed` rather than `404`, because the `OPTIONS /v1/` pattern registered for CORS claims the whole `/v1/` prefix.
> [!note]
> The write key is public and sits in your site's own code. The management key, prefixed `sk_seg_`, is for `https://api.segmentic.net` and must never reach a browser. Displaying a campaign uses the write key. Creating and publishing one uses neither: those routes are on the control plane and are reached in the panel, signed in as a person, as [Building and publishing a campaign](/en/docs/onsite#managing) sets out.
## Fetching the campaign list {#fetch}
`GET https://in.segmentic.net/v1/onsite`. It takes no parameter other than the credential.
The key is read from three places, in this order: the `Authorization: Bearer` header, the `X-Segmentic-Key` header, or the `write_key` query parameter. The web SDK uses the query parameter for this GET, because that makes it a simple request with no preflight, and a CDN that will not vary on `Authorization` can cache the response.
```bash title="Fetching live campaigns"
curl -s "https://in.segmentic.net/v1/onsite?write_key=wk_seg_..."
```
```json title="200 response"
{
"campaigns": [
{
"id": 7,
"name": "تخفیف عید",
"kind": "modal",
"status": "live",
"content": {
"headline": "۱۵ درصد تخفیف تا پایان هفته",
"body": "کد را در سبد خرید وارد کنید.",
"button_text": "دیدن محصولات",
"button_url": "/products",
"accent": "#2563eb"
},
"targeting": {
"url_contains": ["/products"],
"devices": ["desktop"],
"delay_seconds": 5
},
"max_impressions": 3,
"cooldown_hours": 24,
"dismissible": true,
"starts_at": "2026-08-01T00:00:00Z",
"ends_at": "2026-08-14T00:00:00Z",
"impressions": 1842,
"clicks": 96,
"dismissals": 311,
"created_by": "ملیکا",
"created_at": "2026-07-28T09:12:44Z",
"updated_at": "2026-07-30T11:02:10Z"
}
],
"cache_seconds": 60
}
```
The response carries `Cache-Control: public, max-age=60`, and `cache_seconds` repeats the same number so the browser applies the same frequency-cap arithmetic from local storage without asking. The list is filtered server side on `status = live` and on the date window.
If our store fails the answer is still `200`, with `{"campaigns": []}` and no `cache_seconds` and no cache header. This code runs inside the customer's page load, so a failure of ours must degrade to "no banner today" and never to a console error on their site.
Authentication failures:
| Case | Status | Body |
| --- | --- | --- |
| No key sent | `401` | `{"status":"error","message":"missing write key"}` |
| Unknown, revoked or suspended key | `401` | `{"status":"error","message":"invalid write key"}` |
| The key lookup itself failed | `503` with `Retry-After: 5` | `{"status":"error","message":"cannot verify the write key right now; retry"}` |
The third row is deliberate. An SDK reads `401` as "this key will never work", stops, and throws the events away; it reads `503` as "try again later" and keeps them.
CORS on all three endpoints: `Access-Control-Allow-Origin: *`, methods `POST, OPTIONS`, headers `Content-Type, Authorization, X-Segmentic-Key`, and `Access-Control-Max-Age: 86400`.
### The campaign on the wire {#campaign-shape}
| Field | Type | Notes |
| --- | --- | --- |
| `id` | int64 | |
| `name` | string | The internal name, shown in the panel |
| `kind` | string | `banner`, `modal`, `slidein` or `survey` |
| `status` | string | `draft`, `live`, `paused` or `ended` |
| `content` | object | Below |
| `targeting` | object | See [Evaluating the rules](/en/docs/onsite#targeting) |
| `max_impressions` | int | Zero means uncapped |
| `cooldown_hours` | int | Zero means no cooldown |
| `dismissible` | bool | |
| `starts_at` | RFC3339, optional | |
| `ends_at` | RFC3339, optional | |
| `impressions` | int64 | Always present |
| `clicks` | int64 | Always present |
| `dismissals` | int64 | Always present |
| `created_by` | string, optional | The display name of whoever saved it, or `apikey:` |
| `created_at` | time, optional | |
| `updated_at` | time, optional | |
The `content` fields, all optional: `headline`, `body`, `image_url`, `button_text`, `button_url`, `position`, `background`, `text_color`, `accent`, `question`, `nps` (bool), `follow_up`, `choices` (array of string), `thank_you`.
`position` means top or bottom for a banner, and which corner for a slide-in.
### What this response discloses {#what-the-response-exposes}
The response is the whole campaign structure, not a trimmed version of it. That means `impressions`, `clicks`, `dismissals`, `created_by`, `created_at` and `name` are in it, and any visitor to your site can read them in the Network tab. The only thing removed is the account identifier.
If having a colleague's real name sit in `created_by` matters to you, the account that saves the campaign needs a different display name. **There is no way to suppress these fields from the response.**
## Evaluating the rules {#targeting}
Three gates, in this order: the time window, the rule match, the frequency cap. A campaign is eligible when all three pass.
The `targeting` fields, all optional:
| Field | Meaning |
| --- | --- |
| `url_contains` | Array of strings. Passes if **any** of them is a substring of the page URL |
| `url_not_contains` | Array of strings. Fails if **any** of them is in the URL |
| `devices` | `desktop`, `mobile`, `tablet`. Empty means all |
| `delay_seconds` | int, clamped to `0` to `120` on save |
| `scroll_percent` | int, clamped to `0` to `100` on save |
| `on_exit_intent` | bool |
| `new_visitors_only` | bool. Fails if the visitor is returning |
| `returning_only` | bool. Fails if the visitor is not returning |
| `logged_in` | Optional bool. Absent means either state |
| `traits` | Map of string to string. **Every** key must match exactly |
URL matching is substring matching and never a regular expression. A pattern written by a marketer can be catastrophically slow, and this runs on every page of your site. `url_contains` and `url_not_contains` together are capped at twenty rules on save.
`traits` is compared only against the traits the SDK holds locally at that moment, which is what you gave it with `identify()`. **It is not compared against warehouse segments.** If you want to attach an on-site campaign to a segment, that cannot be done from here.
The device class comes from the viewport, not the user agent: under 768 pixels `mobile`, under 1024 pixels `tablet`, otherwise `desktop`.
On Android there are two differences that will leave your campaign never shown if you do not know them. First, there are only two classes: a smallest width of 600 or more is `tablet` and anything narrower is `mobile`. **So a campaign with `devices: ["desktop"]` is never eligible on Android.** Second, `url_contains` and `url_not_contains` are matched on a phone against the screen name, meaning the string the app passes to `screen()`, and not against a web address.
### The frequency cap {#frequency-cap}
The cap is applied both in the browser and on the server, and neither alone is sufficient: local storage alone means anybody who clears it gets an uncapped modal, and the server alone means one request per page view.
The order of the rules is identical on both sides and it matters:
1. If the campaign is not live, no.
2. If this browser has already **converted**, never again. This rule outranks every other one, including a campaign that is still running.
3. If it was dismissed and the campaign is `dismissible`, no.
4. If `max_impressions > 0` and the impression count has reached it, no.
5. If `cooldown_hours > 0` and the last impression was inside that window, no.
A click counts as a conversion for capping. Somebody who followed the link has done the thing, and showing it again asks them to do it twice.
The browser's record of what it has seen is kept in local storage under the key `sg_onsite`. A corrupt or absent value reads as empty, and a quota error on write is swallowed.
### When it appears {#triggers}
- `scroll_percent` above zero attaches a passive `scroll` listener and fires at that percentage.
- `on_exit_intent` attaches a `mouseout` listener and fires when `clientY` reaches zero or less. In practice this is desktop only, because a touch device has no pointer to leave for the tab bar.
- `delay_seconds` **is a trigger of its own only when neither of the other two is set.** Otherwise it would race them and show the message on a timer the marketer meant as a minimum.
## Which kinds are actually drawn {#kinds}
Four kinds can be built in the panel. The three SDKs do not draw them alike, and the difference is documented here so nobody spends half a day hunting a bug that does not exist.
| Kind | Web SDK | Android SDK | iOS SDK |
| --- | --- | --- | --- |
| `banner` | Drawn | Drawn | Does not exist |
| `modal` | Drawn, with a backdrop | Drawn | Does not exist |
| `slidein` | Drawn, in a corner | **Drawn as a banner**, with no animation | Does not exist |
| `survey` | Drawn, NPS scale or choice list | **Not drawn** | Does not exist |
On Android the survey returns `false` rather than being approximated. The consequence is that the campaign is left uncapped and unreported, so the moment the kind is supported the same user still sees it and the opportunity has not been burnt.
**The iOS SDK ships no on-site code at all.** Its only endpoints are `POST /v1/devices` and `POST /v1/batch`. If you want in-app messages on iOS, you call `GET /v1/onsite` yourself and draw the widget yourself.
Some web rendering constraints worth knowing:
- The widget never throws. An analytics tool must not be the thing that breaks a customer's checkout.
- Nothing is inherited. Every property is set directly on the element, so only a host page `!important` can win.
- Content is written with `textContent`, never `innerHTML`. A headline containing `
` renders as text.
- The container id is `segmentic-onsite` with `z-index: 2147483000`, deliberately just below the maximum so the customer's own layer can still sit above. The container is `pointer-events: none` so it does not swallow the site's clicks, and it is `direction: rtl`.
- **At most one campaign is shown at a time.**
- A button's `href` is set only when the URL starts with `http://` or `https://` or with a leading `/`. Anything else, including `javascript:`, leaves the anchor with no `href`.
- A broken `image_url` removes the image rather than leaving a broken-image icon.
- The close button carries `aria-label="بستن"` and sits on the left, because Persian reads right to left.
- Colour defaults when the content omits them: background `#1f2937`, text `#ffffff`, accent `#2563eb`.
- The NPS row is forced to `direction: ltr` so zero sits on the left and ten on the right, even inside a right-to-left card.
## Reporting an interaction {#report-event}
`POST https://in.segmentic.net/v1/onsite/event`
| Field | Type | Required |
| --- | --- | --- |
| `campaign_id` | int64 | Yes, zero is rejected |
| `user_id` | string | One of the two |
| `anonymous_id` | string | One of the two |
| `action` | string | No, empty means `impression` |
| `page_url` | string | No |
| `score` | int | Survey only |
| `answers` | Map of string to string | Survey only |
`action` is lower-cased and must be one of `impression`, the empty string, `click`, `dismiss` or `convert`. Anything else gets `400` with the message `unknown action`.
```bash title="Recording an impression"
curl -s -X POST https://in.segmentic.net/v1/onsite/event \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"campaign_id":7,"action":"impression","anonymous_id":"a_9f21c4","user_id":"u_9137"}'
```
```json title="Response"
{"status":"ok"}
```
The subject key is `"u:" + user_id` when there is a user id and `"a:" + anonymous_id` otherwise. One column and one key, because the cap asks "has this browser seen it" and somebody who signs in mid-session is one browser.
If `campaign_id` is missing, or neither identifier is present, the answer is `400` with `campaign_id and a visitor id are required`. A malformed JSON body gets `400` with `malformed JSON`.
**Once validation passes the answer is always `200`, even when the store write fails.** The error is logged at warn and swallowed. Losing an impression count costs a number on a dashboard; returning an error to a script running inside the customer's page costs them a console error on every page view.
What each action does to the store:
| `action` | Effect |
| --- | --- |
| `impression` | `seen_count` increments, `last_seen_at` is updated, and the campaign's `impressions` counter increments too |
| `dismiss` | `dismissed_at` is set (the first one is kept), `dismissals` increments |
| `click` | **`converted_at`** is set and `clicks` increments |
| `convert` | `converted_at` is set and no counter increments |
Two things you will meet when you go looking for the numbers:
- **These events are not metered and not quota-gated.** Unlike `/v1/track` and `/v1/batch`, neither on-site endpoint counts usage or checks the account's quota.
- **These events are not published to the event bus.** They are written straight into Postgres. They appear in no event stream, no relay and no ClickHouse table, so you cannot build a segment on them with the ordinary event reporting tools. The running totals are on the public campaign list above, which is why any visitor can read them in the Network tab. Everything past a total, the survey results and the individual responses, is only on the control-plane routes in [Building and publishing a campaign](/en/docs/onsite#managing), which are reached in the panel.
## Answering a survey {#survey-response}
`POST https://in.segmentic.net/v1/onsite/response`, with the same body structure as above.
The order of work:
1. If `campaign_id` is zero or there is no visitor identifier, `400` with `campaign_id and a visitor id are required`.
2. The campaign is **loaded from the store, not trusted from the body**: whether this is an NPS survey decides whether `score` means anything at all, and the browser is not the authority on that. If the load fails, `400` with `unknown campaign`.
3. Validation, five checks, in the table below. A failure gets `400` and the message is that English sentence verbatim.
4. If the save fails, `503` with `temporarily unavailable, please retry`. **This is the only on-site endpoint that can return a server error.**
5. On success a `convert` interaction is also recorded, best effort. Somebody who told you what they think should not be asked the same question next week.
6. `200` with `{"status":"ok"}`.
Two of the five are not failures at all, which is worth seeing in the same place as the three that are:
| Check | What happens |
|---|---|
| the campaign's `kind` is not `survey` | `400`, `onsite: this campaign is not a survey` |
| neither `user_id` nor `anonymous_id` is present | `400`, `onsite: a response must name a browser or a person` |
| `content.nps` is true and `score` is outside zero to ten, **or absent** | `400`, `onsite: an NPS score must be between 0 and 10` |
| `content.nps` is false | `score` is overwritten with `-1`, no error |
| a value in `answers` is longer than two thousand runes | truncated, never rejected |
```bash title="Answering an NPS survey"
curl -s -X POST https://in.segmentic.net/v1/onsite/response \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"campaign_id":12,"user_id":"u_9137","score":9,"answers":{"reason":"ارسال سریع بود"}}'
```
```json title="Response"
{"status":"ok"}
```
The save is idempotent on the key `(account, campaign, user_id, anonymous_id)` and replaces `score` and `answers` with the new values. Somebody who answers, refreshes and answers again has one opinion. `responded_at` comes from the server clock, never from the body.
### One defect, and two that were fixed {#survey-defects}
**`page_url` is never populated by any shipped SDK.** The field exists and is stored, but neither the web SDK nor the Android SDK sends it. If you need it, you have to send it yourself.
The other two entries in this section were real and are fixed, and they are kept here rather than deleted because both of them corrupted rows that are still in your account.
**Multiple-choice and free-text answers from the web SDK were silently discarded.** The renderer sent the answer as `{ score?, choice?, text? }` and the client spread that straight into the POST body. The server structure has no `choice` field and no `text` field, so `encoding/json` dropped both and answered `200`. For a multiple-choice survey the stored row recorded that somebody had answered and not what they answered. The renderer now sends an `answers` map, the same shape the Android SDK always sent.
**A follow-up free-text submission overwrote the real NPS score with zero.** On an NPS survey with `follow_up` set the web SDK posted twice, once with `{"score": 9}` and once with `{"text": "..."}`. The second post carried no `score`, so Go's zero value was used, validation accepted it because zero is a valid detractor score, and the save on the same key replaced the real score with it. Every follow-up cost the account one detractor. Two things changed: each post now carries the whole answer rather than the increment, and a body that omits `score` on an NPS survey is refused with `400` instead of being read as zero.
> [!warning]
> **Answers recorded before this fix cannot be recovered.** A discarded choice was never written down, and an overwritten score replaced the real one in place. If an NPS number from a survey with a follow-up question has been quoted anywhere, it was too low, by one detractor for every person who wrote a sentence. Scores collected since the fix are sound.
>
> Half the fix is in the SDK bundle and half is on the server. A page that pinned an older copy of `segmentic.js` still sends the old shape, so its multiple-choice and free-text answers are still lost. Its scores are not: the server now refuses a follow-up post that carries no score with `400`, so the real score stays where it is instead of being replaced by zero. Serve the current bundle to get the answers back.
## The web SDK {#web-sdk}
On-site is **on by default**. Installing the SDK with no other configuration draws your published campaigns on your site.
```ts title="The ordinary install"
import segmentic from "@segmentic/web";
const client = segmentic.init({
writeKey: "wk_seg_...",
apiHost: "https://in.segmentic.net",
});
segmentic.identify("u_9137", { city: "تهران", plan: "gold" });
```
What happens behind that: the list is fetched once during `init()` and one campaign is drawn, then the list is refetched every sixty seconds to stay in step with the server's cache header. If the user has opted out with `optOut()`, nothing is fetched and nothing is drawn. A network error is swallowed.
Reports do not go through the batching queue: both posts go directly, with `keepalive: true`. An impression must not sit behind a batch waiting for nineteen more messages, on a page the visitor is about to leave.
After a client-side navigation, in a single-page application for instance, you re-run the evaluation yourself:
```ts title="After a route change"
client.refreshOnsite();
```
### Drawing your own widget {#own-widgets}
If you have your own design system, turn our rendering off and keep only the eligibility and capping logic:
```ts title="Custom rendering"
import segmentic, { eligible, deviceOf, readSeen } from "@segmentic/web";
const client = segmentic.init({
writeKey: "wk_seg_...",
apiHost: "https://in.segmentic.net",
onsite: false,
});
// Once the list has been fetched at least once.
const visitor = {
url: location.href,
device: deviceOf(window.innerWidth),
loggedIn: segmentic.getUserId() !== null,
returning: true,
traits: { plan: "gold" },
now: Date.now(),
};
const [campaign] = eligible(client.onsiteCampaigns(), visitor, readSeen(localStorage));
if (campaign) {
drawYourOwnWidget(campaign);
}
```
> [!warn]
> `onsiteCampaigns()` and `refreshOnsite()` are instance methods on the client and are not on the module's default object. `segmentic.onsiteCampaigns()` does not exist; you have to keep the instance that `init()` returns. The functions `eligible`, `matches`, `maySee`, `isLive`, `deviceOf`, `readSeen`, `writeSeen`, `recordSeen` and `recordAction` are exported from the module itself.
With `onsite: false` nothing is reported automatically either. You call `POST /v1/onsite/event` yourself for impressions, clicks and dismissals, or the server-side cap never fills and the campaign's numbers stay at zero.
## Building and publishing a campaign {#managing}
These six routes are on the control plane, which is the API the panel talks to. There is no on-site management route on the management host.
> [!danger]
> **The control plane is not routed from the internet.** In the reference deployment, `api.segmentic.net` serves the management API on its own listener and the control plane's listener is deliberately not published. The same paths on `https://api.segmentic.net` fall to the catch-all and answer `404 unknown_endpoint`. The only public path to the routes in this table is the panel's own server-side proxy at `https://app.segmentic.net/api/proxy/v1/...`, which authenticates with the signed-in user's session cookie and answers `401` without one. An `sk_seg_` key does not reach it. Building and publishing an on-site campaign is therefore something a person does in the panel.
| Method and path | Permission |
| --- | --- |
| `GET /v1/onsite/campaigns` | `campaign.read` |
| `GET /v1/onsite/campaigns/{id}` | `campaign.read` |
| `PUT /v1/onsite/campaigns` | `campaign.write` |
| `POST /v1/onsite/campaigns/{id}/status` | **`campaign.send`** |
| `GET /v1/onsite/campaigns/{id}/results` | `campaign.read` |
| `GET /v1/onsite/campaigns/{id}/responses` | **`profile.read`** |
Two of those permissions carry a reason. Taking a campaign live puts a banner on your website in front of every visitor immediately, with no send to schedule and no audience to review, so it is the same act as sending. And a free-text survey answer is somebody's own words and regularly contains their phone number, so reading the raw answers takes the same permission as every other route to a named individual.
**Saving never publishes.** If you put `status` as `live` in the `PUT` body it is rewritten to `draft`. An empty `status` becomes `draft` too.
```http title="Creating a banner"
PUT /api/proxy/v1/onsite/campaigns
Content-Type: application/json
{
"campaign": {
"name": "ارسال رایگان مرداد",
"kind": "banner",
"content": {
"headline": "ارسال رایگان تا پایان مرداد",
"button_text": "خرید",
"button_url": "/products",
"position": "top"
},
"targeting": { "url_not_contains": ["/checkout"], "delay_seconds": 3 },
"max_impressions": 3,
"cooldown_hours": 24,
"dismissible": true
}
}
```
```http title="Taking it live"
POST /api/proxy/v1/onsite/campaigns/7/status
Content-Type: application/json
{"status":"live"}
```
```json title="Response"
{"id":7,"status":"live"}
```
`status` accepts only `draft`, `live`, `paused` or `ended`; anything else gets `400`.
Validation failures on `PUT`, all `400` with a Persian message:
| Condition | When |
| --- | --- |
| A name is required | `name` is empty after trimming |
| Unknown kind | `kind` is not one of the four |
| Content is required | A non-survey campaign with both `headline` and `body` empty |
| A question is required | A survey with no `question` |
| Invalid link | `button_url` or `image_url` does not start with `http://`, `https://` or `/` |
| Both audiences at once | `new_visitors_only` and `returning_only` both true |
| Too many rules | More than twenty URL rules |
| Too many choices | A survey with more than eight choices |
Values corrected automatically on save:
- `max_impressions` at or below zero becomes `3`.
- A negative `cooldown_hours` becomes `24`. Note that an explicit zero is left alone and means no cooldown.
- `kind` of `modal` forces `dismissible` true. A modal that cannot be dismissed is not a message, it is a hostage situation.
- An NPS survey loses its `choices` list.
- A button with text but no URL, or a URL but no text, has the surviving half deleted.
- `javascript:` and `data:` are refused. That link is rendered into the customer's own page and would run in their origin with their cookies.
`GET /v1/onsite/campaigns` returns at most a hundred campaigns as `{"campaigns": [...], "kinds": [...]}`. Each campaign carries `kind_label`, `status_label` and `ctr` on top of the ordinary fields. `ctr` is `clicks / impressions * 100`, and zero impressions gives zero rather than a division error.
### The NPS result {#nps}
```http title="Reading the result"
GET /api/proxy/v1/onsite/campaigns/12/results
```
```json title="Response"
{
"nps": {
"responses": 128,
"promoters": 61,
"passives": 40,
"detractors": 27,
"score": 26.5625,
"reliable": true
},
"min_reliable": 50
}
```
The arithmetic is percent promoters minus percent detractors. **Passives are in the denominator and nowhere else**, which is the part every reimplementation gets wrong: dropping them inflates the score.
The buckets are fixed and not configurable: nine and ten promoter, seven and eight passive, zero to six detractor. NPS is only worth quoting because it means the same thing everywhere.
`reliable` is false below fifty responses. NPS from eleven responses swings by twenty points on one more answer, and a figure quoted in a board meeting should not do that.
The raw answers:
```http title="Reading the raw answers"
GET /api/proxy/v1/onsite/campaigns/12/responses?limit=200
```
`limit` is honoured between `1` and `500`, and anything outside that, including an unreadable value or none at all, becomes `100`. Answers are ordered newest first. **There is no pagination**: no cursor and no offset.
## The inbox {#inbox}
The inbox is the other place campaign messages live, waiting until the app next opens. It has two endpoints on the collector and both are POSTs:
```text
POST /v1/inbox
POST /v1/inbox/ack
```
POST and not GET, for two reasons. The user id and its proof belong in a body rather than in a query string that every proxy, browser history and access log keeps a copy of. And fetching the inbox has a side effect, the rows come back marked delivered, which is not something a GET is allowed to do.
### Two-step authentication {#inbox-auth}
This is the one place where the write key alone is not enough. The inbox is the platform's first **read** endpoint, its rows contain the message body and a personalised discount code, and "give me the inbox of user 91372" behind a key anyone can read out of the page source is not an endpoint that can exist.
So two separate checks run:
1. The write key resolves the account. It is public and says nothing about who is asking.
2. `user_hash` proves that your own server authenticated this person.
The formula, which your server computes at sign-in:
```text title="The user_hash formula"
user_hash = hex(hmac_sha256(identity_secret, user_id))
```
The comparison is constant time, and the supplied value is trimmed and lower-cased. If the account has no `identity_secret`, or the user id is empty, or the proof is empty, the answer is false.
> [!danger]
> **There is no way to create the `identity_secret` from the panel or from the API.** The only writer in the whole tree is the `adminctl` command line tool, which generates thirty-two random bytes, base64url encodes them, and prints the formula above. That means a customer cannot self-serve the inbox and has to ask us. Rotating the secret invalidates every hash already issued, signing the whole app out of its inbox until the customer redeploys.
Error responses:
| Case | Status | Body |
| --- | --- | --- |
| `user_id` missing | `400` | `{"status":"error","message":"user_id is required"}` |
| Wrong hash, missing hash, or an account with no `identity_secret` at all | `403` | `{"status":"error","message":"user identity is not verified"}` |
`403` rather than `404`, and the same answer in both cases, so the endpoint cannot be used as an oracle for which user ids exist.
```bash title="Fetching the inbox"
curl -s -X POST https://in.segmentic.net/v1/inbox \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"user_id":"u_9137","user_hash":"3f2a1c...","limit":25}'
```
```json title="Response"
{
"status": "ok",
"messages": [
{
"message_id": "c104.u_9137",
"title": "سفارش شما ارسال شد",
"body": "کد رهگیری در اپلیکیشن قابل مشاهده است.",
"image": "https://cdn.example.ir/box.png",
"deeplink": "myapp://orders/104",
"surface": "inbox",
"token": "1.42.k1.oi.mfx2b1.QkNERUZHSElK",
"created_at": "2026-08-06T08:11:00Z",
"expires_at": "2026-09-05T08:11:00Z",
"seen": false
}
]
}
```
`messages` is always an array and never `null`. If the store fails, the answer is `503` with `{"status":"error","messages":null,"message":"temporarily unavailable, please retry"}`.
A `limit` at or below zero, or above `25`, becomes `25`. Only rows that have not been dismissed and have not expired come back, newest first. The same statement sets `delivered_at`, because that is the moment that can be proved: the bytes left the server. Whether the person then looked is `seen_at`, a different and weaker claim.
`token` is what proves that an open reported back belongs to a message we really sent. Without it every in-app open is an unsigned claim, which the ledger stores but does not count.
Acknowledging what was seen or dismissed:
```bash title="Acknowledging"
curl -s -X POST https://in.segmentic.net/v1/inbox/ack \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"user_id":"u_9137","user_hash":"3f2a1c...","seen":["c104.u_9137"],"dismissed":["c99.u_9137"]}'
```
```json title="Response"
{"status":"ok"}
```
`seen` keeps the first sighting and is not overwritten, so a message re-rendered on every app open does not report its open time as "just now" for ever. `dismissed` removes the row from later fetches. An empty array is not an error and does nothing. A failure of either gets `503`.
**`seen` is not an engagement signal.** For an in-app open to be counted, the app sends a `message_opened` event through the ordinary event path, carrying `context.campaign.message_id` and `context.campaign.token` from the inbox entry. A second, trusted path for the same signal would be trusted on the word of a caller holding nothing but a public write key.
### What your app has to build {#inbox-build}
**No shipped SDK has an inbox client.** Not web, not Android, not iOS. What you write:
1. Computing `user_hash` on your own server at sign-in. The `identity_secret` must never reach a browser or an app binary.
2. Calling `POST /v1/inbox` and holding the result.
3. The entire interface: the list, read and unread state, the dismissal gesture, and opening the `deeplink`.
4. Sending `message_opened` with the `message_id` and the `token` so that opens are countable.
5. Pagination. **There is none.** Twenty-five rows per call, no cursor, no offset. If a user has a hundred messages you see only the twenty-five newest until the rest are dismissed or expire.
A message's default lifetime is thirty days unless the campaign set another one.
## What is not there today {#not-built}
An honest list of things a customer expects and that do not exist today:
- **On-site on iOS.** There is no code at all.
- **Surveys on Android.** The renderer returns `false`.
- **A slide-in animation on Android.** It is drawn as a banner.
- **Attaching on-site targeting to a segment.** `traits` is compared only against the SDK's local traits.
- **Inbox pagination** and **an inbox client in any SDK**.
- **Creating an `identity_secret` without us.** Command line only.
- **Seeing on-site interactions in event-based reports.** They are not published to the bus.
- **Suppressing the counters and `created_by` from the public `GET /v1/onsite` response.**
- **Populating `page_url` from any SDK.** The column is there and nothing writes it. See [One defect, and two that were fixed](/en/docs/onsite#survey-defects).
If one of these blocks you, say so. It is written down here so it is not discovered after an afternoon.
---
# Reports and exports
> Build funnels and retention reports, then import and export data safely from the panel.
> https://segmentic.net/en/docs/reports
The management host `https://api.segmentic.net` serves two reports and no more: funnel and retention. Everything else you see in the panel, paths, churn, RFM, engagement, the dashboard builder, has no public address at all. The full list is in [what exists only in the panel](/en/docs/reports#panel-only) and [what is not possible](/en/docs/reports#not-possible).
Both reports take a management key (`sk_seg_...`), never a write key. The write key belongs to `in.segmentic.net` and reaches none of these routes.
On a local install this host is not served until you set `PUBLIC_API_ADDR`. The collector runs separately on `http://localhost:8080`.
> Diagram: How exposure, conversion and holdout observations become funnel, retention and lift reports
## Funnel {#funnel}
`POST /v1/reports/funnel`. Permission `analytics.read`. Costs 25 units of the request budget. Times out after 45 seconds. Body limit 1 MiB.
The number that comes back is **cumulative**. `users` on a step means everybody who reached that step or beyond, not everybody who stopped there. ClickHouse `windowFunnel` reports the furthest step each user reached, and we accumulate the histogram backwards. Read it directly and you build a funnel whose later steps have more users than its first.
```bash
curl -X POST https://api.segmentic.net/v1/reports/funnel \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"steps": [
{"name": "product_viewed", "label": "دیدن محصول"},
{"name": "add_to_cart"},
{"name": "purchase"}
],
"range": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
"window": "7d"
}'
```
| Field | Type | Required | Default | Rule |
|---|---|---|---|---|
| `steps` | array | yes | none | 2 to 12 members |
| `steps[].name` | string | yes | none | the event name, at most 256 bytes |
| `steps[].label` | string | no | the `name` | chart label only |
| `steps[].filters` | array | no | none | at most 10 members |
| `range.from` | RFC3339 | yes | none | inclusive |
| `range.to` | RFC3339 | yes | none | exclusive |
| `window` | string | yes | none | `"7d"`, `"2h"`, `"30m"` |
| `strict` | boolean | no | `false` | no other event between two steps |
| `split_by` | string | no | empty | allow-list below |
`from` is inclusive and `to` is exclusive so consecutive ranges tile without counting the events on the boundary twice. A range longer than 730 days is refused.
`window` is parsed by hand because Go has no day unit. `"7d"`, `"1d"`, `"0.5d"`, `"2h"` and `"30m"` are accepted. `""` and `null` become zero, and zero is refused. `"tomorrow"` is a JSON decoding error. A window longer than the range itself is refused too: nobody takes ninety days to convert inside a thirty-day report.
> [!warn]
> Nothing on this platform validates an event name. A typo in `steps[].name` returns a perfectly well-formed funnel full of zeroes, which is indistinguishable from a real audience of nobody. Call `GET /v1/schema/events` first and check the name exists.
Anonymous users (an empty `user_id`) and bot traffic are excluded from every funnel, retention and path report. Bots are not dropped from the warehouse, they are flagged with `is_bot` and left out of reports: a traffic dip nobody can explain destroys trust in the whole set of numbers.
### The saved funnel library {#funnel-library}
The panel keeps named funnels. Save a definition once and it appears in a library where every entry draws its own chart, which is the point: twenty funnels side by side is how somebody notices that one of them broke last Tuesday.
**None of it has a public address.** The library is on the panel's control plane, which is deliberately not routed from outside, so `GET /v1/funnels` is not reachable with a management key. This section is here so nobody spends an hour writing an integration against a route that answers 404. What the API offers is this page's `POST /v1/reports/funnel`, which computes a funnel from a definition you hold yourself.
When you have not built a funnel yet, the library shows only an empty state and a Start with ready-made funnels button. The ready-made list stays folded until that button is pressed. There is one template per kind of business, with the steps this documentation's [event dictionary](/en/docs/event-dictionary) publishes for that vertical. Taking one creates an ordinary saved funnel; nothing about the result remembers it came from a template. Each template is checked against the account's own event catalogue first and says so when it names an event the account has never sent, because a step nobody sends returns a well-formed funnel full of zeroes and that is indistinguishable from a real audience of nobody.
Two things about the library are worth knowing even if you only ever use the API.
A card's chart is not computed when the page loads. A background job recomputes each saved funnel on a timer and stores the answer on the row, because drawing them live would be one `windowFunnel` over the whole events table per card on every visit. So a card can be a few hours behind, and every card says how old its number is. Opening a funnel and running it gives the number as of now.
A saved definition holds the steps, the conversion window, the strict flag and the breakdown, and **not the range**. The range is the question asked of a saved definition rather than part of it; freezing "the last thirty days" into the row would mean every saved funnel silently aged, and a year later the library would be a set of questions about last spring.
### Filters {#funnel-filters}
Each filter has three fields: `{"prop": "...", "op": "...", "value": "..."}`. `value` is **always a JSON string**, including for the numeric operators.
| Group | Operators | Column compared |
|---|---|---|
| text | `eq`, `ne`, `contains`, `prefix` | `props_str` |
| numeric | `gt`, `gte`, `lt`, `lte`, `num_eq`, `num_ne` | `props_num` |
An unknown operator is a 400. `prop` is capped at 128 bytes and `value` at 512.
One behaviour that costs an afternoon: if the `value` of a numeric operator does not parse as a float, it renders as the literal `0` rather than erroring, so it matches nothing. The reason is that a half-typed number in the UI must not blank the whole chart with a stack trace. What it means for you is that a typo in a numeric filter silently produces an empty funnel.
```json
{
"steps": [
{"name": "product_viewed",
"filters": [{"prop": "category", "op": "eq", "value": "mobile"}]},
{"name": "purchase",
"filters": [{"prop": "amount", "op": "gte", "value": "500000"}]}
],
"range": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
"window": "2d"
}
```
### Breakdown {#funnel-breakdown}
`split_by` is either one of these allow-listed keys, or the form `prop:` followed by a property name.
| `split_by` | Column |
|---|---|
| `platform` | `os_name` |
| `os` | `os_name` |
| `device` | `device_type` |
| `app_version` | `app_version` |
| `country` | `country` |
| `city` | `city` |
| `region` | `region` |
| `province` | `region` |
| `utm_source` | `utm_source` |
| `utm_campaign` | `utm_campaign` |
| `browser` | `browser_name` |
`prop:category` groups by `props_str`, deliberately not `props_num`: a numeric property used as a breakdown produces one bucket per distinct value, which is a chart with four thousand bars. The key after `prop:` is never validated; an unknown key groups everything under the empty string, which is a true and readable answer rather than an error.
An unknown value that does not start with `prop:` is a 400 carrying the raw Go text `analytics: unknown breakdown "..."`.
### Funnel response {#funnel-response}
```json
{
"steps": [
{"index": 0, "name": "product_viewed", "label": "دیدن محصول",
"users": 1000, "from_start": 1.0, "from_previous": 1.0, "dropped_here": 0},
{"index": 1, "name": "add_to_cart", "label": "add_to_cart",
"users": 600, "from_start": 0.6, "from_previous": 0.6, "dropped_here": 400},
{"index": 2, "name": "purchase", "label": "purchase",
"users": 300, "from_start": 0.3, "from_previous": 0.5, "dropped_here": 300}
],
"entered": 1000,
"completed": 300,
"conversion": 0.3,
"description": "کاربرانی که «دیدن محصول» سپس ... را به ترتیب انجام دادند، حداکثر در ۷ روز."
}
```
- `from_start`, `from_previous` and `conversion` are fractions from 0 to 1, not percentages.
- `from_previous` on step 0 is always `1`.
- `dropped_here` on step 0 is always 0.
- Division by zero is guarded: an empty funnel yields 0, never `NaN`.
- `description` is generated from the same request the query was built from, so it cannot drift from the numbers underneath it. On this host the sentence and its dates are always Persian and Jalali, because this host never parses `Accept-Language` and its default locale is Persian.
With `split_by`, a `buckets` key appears as well, sorted by `entered` descending. The top-level `steps`, `entered` and `completed` remain the whole funnel across every bucket, not the largest one.
```json
{
"steps": [],
"buckets": [
{"value": "ios", "steps": [], "entered": 1000, "completed": 100, "conversion": 0.1},
{"value": "android", "steps": [], "entered": 200, "completed": 100, "conversion": 0.5}
],
"entered": 1200,
"completed": 200,
"conversion": 0.16666666666666666,
"description": "..."
}
```
If the only bucket key is the empty string, `buckets` is absent entirely.
## Retention {#retention}
`POST /v1/reports/retention`. Same permission, same cost of 25 units, same 45-second timeout.
```bash
curl -X POST https://api.segmentic.net/v1/reports/retention \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"start": {"name": "signup"},
"return": {"name": "purchase"},
"range": {"from": "2026-05-01T00:00:00Z", "to": "2026-05-10T00:00:00Z"},
"granularity": "day",
"periods": 3
}'
```
| Field | Type | Required | Default | Rule |
|---|---|---|---|---|
| `start` | same shape as a step | no | `{}` | an empty name means any activity |
| `return` | same shape as a step | no | `{}` | an empty name means any activity |
| `range` | `{from, to}` | yes | none | as for the funnel, at most 730 days |
| `granularity` | string | no | `day` | `day`, `week` or `month` |
| `periods` | number | no | 30 | 1 to 60 |
`start` and `return` are two separate fields because "came back" rarely means "did the same thing again". With both empty the report counts any activity.
A `periods` of zero or below becomes 30, but above 60 it is a 400 carrying the raw text `analytics: at most 60 periods`. A grid of more than 120 cohort rows is refused as well: a full year at day granularity is refused, the same range at month granularity is fine.
> [!warn]
> This endpoint has no `event` field, and it does not reject unknown keys, it ignores them. Send `{"event": "purchase"}` and `start` and `return` stay empty, so you get an any-activity grid and nothing anywhere says your question was not understood. The only clue is the `description` sentence, which says «هر فعالیتی». The same defect affects our own ready-made MCP tool; see the [MCP page](/en/docs/mcp).
### Jalali cohorts {#cohorts-jalali}
None of ClickHouse's calendar functions is correct for this market, so every bucket boundary is computed in Go in `Asia/Tehran` and ClickHouse is only asked which bucket a timestamp falls into.
| Granularity | Bucket start | Next step |
|---|---|---|
| `day` | Tehran midnight | one day |
| `week` | Saturday | seven days |
| `month` | day 1 of the Jalali month | one day past the end of that Jalali month |
`toStartOfMonth` is Gregorian, so a "monthly" cohort report would be cut ten days away from where every Iranian user believes the month starts. `toStartOfWeek` offers Monday or Sunday, and the Persian week begins on Saturday, so a weekly report would split each week across two rows.
The month arithmetic is not "plus 31 days" either: a Jalali month is 29, 30 or 31 days, and adding 31 days would skip a 30-day month entirely. The boundaries are built in Go and passed to ClickHouse as an `Array(Date)`.
Boundary dates are sent as local Tehran calendar dates, not converted to UTC. Converting to UTC first would move every boundary back three and a half hours and put the small hours of each day in the previous bucket.
A user's cohort is **the first period they qualified in within the requested range**, not their all-time first. If it were the all-time first, a two-year-old customer who bought something today would drop into this month's cohort and the first cell of the grid would stop meaning anything.
### Cells that are not knowable yet {#observable}
Every cell carries an `observable` field. `false` means "the report has not run long enough to know yet", not zero. A cohort that started yesterday has no day-30 number, and rendering that cell as zero per cent is how a healthy product looks like it is dying.
The boundary is the newest bucket that has fully elapsed, judged against a clock read once per request so every cell in one response is judged against one instant. The period in progress still appears in the grid and is excluded from the averaged curve.
### Retention response {#retention-response}
```json
{
"granularity": "day",
"period_label": "روز",
"cohorts": [
{
"cohort": "2026-05-01",
"label": "۱۱ اردیبهشت ۱۴۰۵",
"size": 100,
"cells": [
{"period": 0, "users": 100, "rate": 1.0, "observable": true},
{"period": 1, "users": 40, "rate": 0.4, "observable": true},
{"period": 2, "users": 25, "rate": 0.25, "observable": true},
{"period": 3, "users": 0, "rate": 0.0, "observable": false}
]
}
],
"average": [
{"period": 0, "users": 10004, "rate": 1.0, "observable": true},
{"period": 1, "users": 1004, "rate": 0.10036, "observable": true}
],
"description": "از کاربرانی که برای اولین بار «signup» انجام دادند ..."
}
```
- `cohort` is the machine key and is always a Gregorian `YYYY-MM-DD` taken from the Tehran instant. `label` is the human row header, and on this host it is Jalali with Persian digits.
- `cells` always holds exactly one more entry than `periods`, period 0 to the last.
- `rate` is a fraction from 0 to 1.
- `average` is the **weighted** curve: total returners over total starters, across observable cells only. It is not the mean of the per-cohort percentages, because a cohort of four people who all came back would pull the curve up as hard as one of forty thousand.
- Cohorts with no rows at all are omitted from `cohorts`. An empty result is `"cohorts": []`.
## Cost, timeout and the shape of an error {#report-errors}
These two endpoints are the panel's own internal handlers, registered on the management host as well. That produces one real inconsistency you have to know about:
| Failure | Envelope | Status |
|---|---|---|
| no key, wrong kind of key, expired key | `{"error":{"code":"unauthenticated"}}` | 401 |
| missing permission | `{"error":{"code":"forbidden","need":"analytics.read"}}` | 403 |
| request budget spent | `{"error":{"code":"budget_exhausted"}}` with `Retry-After: 60` | 429 |
| account soft-locked | `{"error":{"code":"account_locked","details":{"reason":"usage_300"}}}` | 403 |
| malformed JSON | `{"error":"a Persian sentence"}` | 400 |
| invalid report | `{"error":"a Persian sentence","code":"invalid_report"}` | 400 |
| warehouse failed | `{"error":"a Persian sentence"}` | 503 |
> [!danger]
> A client that only parses `error.code` breaks on every 400 and every 503 from these two endpoints, because those responses send `error` as a string rather than an object. Handle both shapes.
Nine distinct validation faults (too few steps, too many steps, a blank name, a bad range, a range that is too wide, a bad window, a bad granularity, a bad operator, a depth that is too great) all share one code: `invalid_report`. The sentence changes, the code does not. A warehouse failure carries no code at all.
The request budget is 600 units a minute and is keyed on the **API key**, not on the account. Each report costs 25, so one key runs 24 reports a minute. `PUBLIC_API_BUDGET_PER_MINUTE` changes it. If the budget store cannot be read the request is refused rather than let through: `budget_unavailable` with a 503.
The soft lock closes when usage reaches three hundred per cent of the allowance or an issued invoice is 75 days overdue. It closes exactly four routes: both reports, and queueing and listing exports. Ingest (`POST /v1/events`) and sending are deliberately left open.
The report timeout is 45 seconds. Note that `GET /v1/capabilities` publishes `query_timeout_sec` as 30 and that number is not the report timeout. None of the analytics ceilings (12 steps, 60 periods, 120 cohorts, 730 days, 45 seconds) is published on any endpoint. You have to hardcode them.
## Exports {#exports}
An export is a background job, not a response. `POST /v1/exports` queues it and returns 202. `GET /v1/exports` reports its state. There is no webhook, no callback and no notification; polling is the only mechanism.
The permission is `data.export`, and it is separate because an export walks out of the building: read access inside a dashboard that logs every query is a different risk from a CSV of every customer's email address on somebody's laptop. The `owner`, `admin`, `marketer` and `analyst` roles carry it; `viewer`, `approver` and `finance` do not.
### Queueing an export {#export-queue}
```bash
curl -X POST https://api.segmentic.net/v1/exports \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"kind": "events",
"format": "ndjson",
"spec": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"}
}'
```
```json
{"id": 42, "status": "queued", "kind": "events", "expires_after_hours": 168}
```
`kind` is exactly one of four: `events`, `profiles`, `segment`, `messages`. Anything else is a 422 with the code `export_kind_invalid`.
`format` is either exactly the string `csv`, or anything else, which is **silently rewritten to `ndjson`**. Send `"parquet"` and you get a 202 and an NDJSON file. NDJSON is the default because an export of events with nested properties is not a rectangle, and flattening it into CSV silently loses the nesting.
`spec` is a free-form object of which exactly three keys are read:
| Key | Type | Used by | Default |
|---|---|---|---|
| `from` | RFC3339 string | `events`, `messages` | 90 days ago |
| `to` | RFC3339 string | `events`, `messages` | now |
| `segment_id` | number | `segment` | required; 0 is a terminal failure |
`profiles` ignores the window entirely. A reversed range is swapped rather than refused. Anything unparseable in `spec` is ignored and the default applies.
An installation with no export storage configured answers **503** rather than 202. The download route is not registered at all on such an installation, so a 202 would promise a file with nowhere to be collected from, and the job would sit in the queue looking like work in progress for ever. 503 rather than 400 because the request was fine and the installation is not, which is the difference between fixing your code and asking your operator.
**There is no `columns` field, no `max_rows` and no `limit`.** The columns are fixed and the row cap is a server constant.
### Polling {#export-poll}
```bash
curl https://api.segmentic.net/v1/exports \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"data": [
{
"id": 42, "kind": "events", "format": "ndjson",
"spec": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
"status": "ready", "rows_written": 412334, "bytes": 91203344,
"location": "/var/lib/segmentic/exports/7/42.ndjson",
"attempts": 1, "truncated": false,
"expires_at": "2026-06-08T09:12:00Z",
"requested_by": "api-key:3",
"created_at": "2026-06-01T09:04:00Z",
"finished_at": "2026-06-01T09:12:00Z"
}
],
"has_more": false
}
```
Five statuses exist: `queued`, `running`, `ready`, `failed`, `expired`. No endpoint publishes that list.
- `location` is the file's path on our server and it goes out on the wire. It is of no use to you.
- `attempts` is published because "it failed" and "it failed three times and stopped" are different answers. The attempt cap is 3.
- `truncated` says the query stopped at the five-million-row cap rather than at the end of the data, so the file is not the whole result. It is absent when false. A result of exactly five million rows is indistinguishable from a truncated one and is reported as truncated: a complete file wrongly labelled incomplete costs one query to check, and a truncated file labelled complete is a number in a report that is quietly wrong.
- A job stuck in `running` whose claim is older than 45 minutes is re-claimable, so a worker that was killed does not strand a job forever. One build has 30 minutes.
- `expired` means the sweeper deleted the file and kept the row, so the record that an export happened, and who asked for it, outlives the file.
> [!warn]
> The response carries no `next_cursor` key at all, so a client that reads it gets nothing rather than an empty string, and `has_more` is never `true`. Nothing anywhere in the API assigns `next_cursor`, so that is how the shared page envelope behaves on every route that returns it, not a quirk of exports. A `cursor` parameter is read and thrown away. `limit` defaults to 25 and is clamped to 100. So only the 100 most recent jobs are reachable, newest first, and older ones cannot be seen through the API at all.
`GET /v1/exports/{id}`, to read one job by id, **does not exist**. The only route is the list.
### Collecting the file {#export-download}
`GET /v1/exports/{id}/download` **does not exist on the management host**. It is registered only on the dashboard's control-plane listener, which is deliberately not addressable from outside the overlay network.
Stated plainly: your integration can build an export through `api.segmentic.net` and has no programmatic way to fetch its bytes. There is no signed URL, no object storage and no `download_url` field anywhere in the codebase. A person has to collect the file from the panel, under Reports, on the Exports tab. That screen also queues an export, so a one-off does not need an API key at all.
If your programme needs raw data weekly, the queued export is not your answer until that route opens.
### What is in each export {#export-contents}
Every source writes out its column list rather than using `SELECT *`, because an export is a file that leaves the building and `SELECT *` means the day somebody adds a column holding a hashed identifier or an internal flag, it silently appears in every customer's next download.
`events`, 23 columns in this order: `message_id`, `type`, `name`, `user_id`, `anonymous_id`, `session_id`, `event_time`, `received_at`, `revenue`, `currency`, `props_str`, `props_num`, `app_version`, `device_type`, `os_name`, `country`, `region`, `city`, `page_url`, `page_path`, `utm_source`, `utm_medium`, `utm_campaign`.
`profiles`, 27 columns: `user_id`, `email`, `phone`, `first_name`, `last_name`, `gender`, `city`, `region`, `country`, `language`, `timezone`, `device_type`, `os_name`, `app_version`, `traits`, `traits_num`, `has_push`, `has_email`, `has_phone`, `push_opt_in`, `email_opt_in`, `sms_opt_in`, `total_events`, `total_revenue`, `order_count`, `first_seen`, `last_seen`.
`ip` and `national_id` are deliberately left out of the profiles export: an export is the copy of the data with the least protection around it, and a national identifier in a spreadsheet on somebody's laptop is the single worst row in this database to lose.
`messages`, 18 columns, read from Postgres: `message_id`, `user_id`, `channel`, `category`, `transport`, `campaign_id`, `journey_id`, `node_id`, `variant`, `topic_id`, `status`, `reason`, `gateway`, `gateway_id`, `delivery`, `delivery_detail`, `delivered_at`, `sent_at`.
`segment`, 20 columns: `user_id`, `email`, `phone`, `first_name`, `last_name`, `gender`, `city`, `region`, `country`, `total_events`, `total_revenue`, `order_count`, `first_seen`, `last_seen`, `has_push`, `has_email`, `has_phone`, `push_opt_in`, `email_opt_in`, `sms_opt_in`. The traits map is not here because its keys differ per user, so it cannot become a fixed set of columns without scanning the whole audience first.
The segment export uses the same compiler the dashboard preview uses, so the file and the preview cannot disagree about who is in the audience.
### Format and encoding {#export-encoding}
NDJSON: one complete JSON object per line, keyed by column name so that a column added later does not shift every downstream index by one. Times are `RFC3339Nano` in UTC. A nil map becomes `{}`, never `null`. Numbers stay numbers.
CSV: it starts with a UTF-8 BOM, without which Excel on Windows renders every Persian name as mojibake. Any cell starting with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with a tab, because profile traits come from the customer's own end users, and the person opening the export is an employee of our customer, on their laptop, on their network.
In CSV, booleans become the localised yes or no word, floats use plain notation rather than scientific, and a zero timestamp becomes an empty cell rather than 1970, which looks like a real date somebody might act on.
Column names are Persian in exactly one combination: `format` of `csv` and `kind` of `segment`. Every other combination ships machine column names, because NDJSON is read by a loader keying on field names and a JSON key in Persian is hostile to every pipeline downstream.
`xlsx` never comes out of the export queue. Only `csv` and `ndjson` have encoders.
### Row cap and expiry {#export-limits}
A cap of five million rows is applied to all four kinds, as a bare SQL `LIMIT`. Past that the file is not something anybody opens, it is a pipeline that should be reading the warehouse directly.
> [!warn]
> When the cap is reached the job carries `truncated: true`, and the panel puts a warning on the row. It was silent for a long time: nothing in the job row and nothing in any response said an export had hit the cap, `rows_written` simply read the cap, and a suspiciously round number is not something anybody notices. If your data can approach five million, split the range yourself rather than relying on the flag to tell you afterwards.
The file is deleted after 7 days (168 hours), and that number is published in the 202 as `expires_after_hours`. A file containing every customer's email address sitting on a share forever is what turns one careless export into a breach, and nobody remembers to delete it, so the platform does.
The destination is a directory on disk, with directory mode `0700` and file mode `0600`. There is no S3 or object-storage implementation; on-premise installs have a mounted volume and no S3 endpoint.
## Importing data {#import}
> [!danger]
> CSV import is **panel-only**. There is no multipart upload endpoint on `api.segmentic.net`. `POST /v1/imports` and `GET /v1/imports/{id}` do not exist. What ships is synchronous, and it is documented here so you know exactly what the panel does.
All three import endpoints take `profile.write`, including `inspect`, which stores nothing: it is still the step that reads the customer's spreadsheet. `viewer`, `analyst`, `approver` and `finance` get a 403; `marketer` is accepted.
### Users from a CSV {#import-users}
`POST /v1/import/inspect` reads the file, returns its guess at a mapping, and writes nothing:
```json
{
"header": ["email", "موبایل", "امتیاز"],
"preview": [["a@b.com", "09123456789", "1500"]],
"total": 1,
"mapping": {"columns": [
{"index": 0, "field": "email", "name": "email"},
{"index": 1, "field": "phone", "name": "موبایل"},
{"index": 2, "field": "trait", "name": "امتیاز"}
]}
}
```
`preview` is at most 5 rows and `total` counts data rows, excluding blank ones.
`POST /v1/import/users` actually imports the same file. A multipart form with the file part named `file`, plus two optional fields: `mapping`, a JSON object that replaces the guess entirely, and `dry_run`.
> [!danger]
> `dry_run` defaults to **off**. Only the exact string `"true"` turns it on and anything else is off. Omit the field and real profiles are written, which campaigns then target.
```json
{
"total": 2,
"accepted": 1,
"rejected": 1,
"errors": [{"row": 3, "column": "موبایل", "value": "rubbish", "reason": "..."}],
"truncated": false,
"dry_run": false,
"ingested": 1
}
```
`row` is 1-based counting the header as row 1, so the first data row is row 2. `truncated` says the error list was cut at 50, so that "50 problems" is not mistaken for "exactly 50 problems".
`ingested` can be lower than `accepted` if the bus rejected some. It exists because saying "twenty thousand imported" when nineteen thousand arrived is the kind of lie that surfaces a week later as a campaign that reached fewer people than promised.
File-level failures are a 400 with the code `invalid_file`: no header row, no data rows, no column mapped to user id or email or mobile, too many rows, too many columns, no file chosen, file too large. A partial failure is a 503 with the code `partial_import` and carries the whole `result`, because part of the file is already in and telling the operator it all failed would have them upload it a second time.
| Limit | Value |
|---|---|
| Data rows per file | 500,000 |
| Header columns | 100 |
| Bytes in one cell | 4096 |
| File size | 64 MiB (67108864 bytes) |
| Bad rows reported | 50 |
`GET /v1/import/fields` publishes the field list and the two numbers `max_rows` and `max_bytes`.
### Column mapping {#import-mapping}
The mapping is `{"columns": [{"index": 0, "field": "email", "name": "email"}]}`. Columns are addressed **by index**, not by header text, because spreadsheets routinely have duplicate or blank headers and a name-keyed map silently drops one of them.
The fields are `user_id`, `email`, `phone`, `first_name`, `last_name`, `gender`, `birthday`, `national_id`, `city`, `region`, `country`, `language`, `trait` and `ignore`. When `field` is `trait`, `name` is the trait key.
The auto-guess recognises Persian and English headers together, because an Iranian marketing team exports from a Persian CRM and a foreign analytics tool in the same week. «شناسه», «کد کاربر», «ایمیل», «رایانامه», «موبایل», «شماره تماس», «نام خانوادگی», «کد ملی», «استان» and their English counterparts are all recognised. Headers are folded through the Persian matcher, so the Arabic forms of ye and kaf match the Persian ones.
A header that matches nothing becomes a trait keyed by the header text. A second column claiming an identity field that is already taken becomes a trait too, because two `email` columns means one of them is something else. A blank header becomes `ignore`.
The delimiter is detected, not assumed. Comma, semicolon and tab are all considered, and the score is **consistency** across the first six lines rather than a raw count, so a comma inside quoted Persian text does not win. Excel on a Persian Windows locale writes semicolons, and a file that silently parses as one giant column is the most common import support ticket there is.
A leading UTF-8 BOM is stripped, otherwise `email` arrives with an invisible character in front of it and silently becomes a custom trait. Ragged rows are tolerated and blank rows are skipped.
### Row conversion {#import-rows}
Per-row failures are collected rather than stopping the job: a twenty-thousand-row export with three bad phone numbers should import nineteen thousand nine hundred and ninety-seven people and tell the operator about the three.
| Field | Rule |
|---|---|
| any field | a cell longer than 4096 bytes rejects the row |
| any field | an empty cell is skipped entirely |
| `user_id` | Persian digits are folded to ASCII |
| `phone` | stored as E.164; invalid rejects the row |
| `email` | lowercased and shape-checked; invalid rejects the row |
| `national_id` | checksum verified; invalid rejects the row |
| `birthday` | stored as Gregorian `YYYY-MM-DD`; invalid rejects the row |
| `gender` | normalised to `male` or `female`; never fails |
| `trait` | numeric-looking values stored as numbers, the rest as text; never fails |
The phone is normalised here rather than downstream, because a phone stored in two shapes is two profiles for one human. `۰۹۱۲۳۴۵۶۷۸۹` becomes `+989123456789`.
Gender accepts `male`, `m`, «مرد», «آقا», «پسر» and `female`, `f`, «زن», «خانم», «دختر».
Numeric traits: a value with a leading zero stays text, and a value longer than 15 characters stays text, because a postcode of `01234` parsed as 1234 is wrong and a national id past the limit of float precision loses its last digits. Persian digits count as numbers.
Dates are read in both calendars and both digit sets, with `/`, `-` or `.` as separators. **A year under 1700 is read as Jalali** and converted to Gregorian; the calendars are far enough apart that there is no ambiguous range a person would actually type. `1370/05/12` becomes `1991-08-03`, and `1370/13/45` is an error, because the Jalali month and day bounds are checked before conversion.
With no `user_id`, the email is used, then the phone. With none of the three the row is rejected, because an identify with nothing to identify creates an anonymous profile nobody can ever reach.
The import's output is an ordinary `identify` envelope, not a direct profile write. Writing profiles directly would be a second, divergent way of building the same rows, and the first time an imported profile disagreed with an SDK-built one, a phone stored as `09123456789` in one path and `+989123456789` in the other, nobody would be able to say which path was wrong. The account is always taken from the credential, never from the payload.
### Historical events {#import-events}
`POST /v1/import/events` writes events at past timestamps. It is panel-only too, and deliberately not on the collector: the collector authenticates with a write key, which by design ships inside mobile apps and website bundles, so anyone who views source has one. A public credential that could write events at arbitrary past timestamps is a credential that can rewrite a competitor's funnel.
```json
{"events": [
{"type": "track", "event": "order_completed", "user_id": "u1",
"timestamp": "2025-04-02T10:00:00Z"}
]}
```
- Body capped at 64 MiB.
- An empty array is a 400.
- More than 10,000 events in one request is a 400, and nothing reaches the bus.
- Ten-minute timeout.
- There is no `dry_run` on this endpoint. The field exists on the response struct but nothing sets it.
The permitted window comes from the account's own retention policy. Zero days of event retention means keep forever, which is a window of 3650 days. If the policy cannot be read the window falls back to 30 days. For what that number means, see [personal data](/en/docs/privacy).
The difference from live ingest is the whole point: live ingest silently moves an out-of-window timestamp onto the window's edge and answers 200, which turns a year of orders into one enormous day. This endpoint refuses the row and names it by row number.
```json
{
"total": 10000, "accepted": 9997, "rejected": 3,
"errors": [{"row": 412, "reason": "..."}],
"truncated": false, "dry_run": false,
"oldest": "2024-03-01T08:00:00Z",
"newest": "2026-05-01T21:30:00Z"
}
```
`oldest` and `newest` are the range actually written, so an operator can confirm the import landed where they meant before running the next batch of half a million. The error list here is cut at 100, not 50. A partial failure is a 503 with the code `partial_backfill`.
For synchronous ingest from your own server, `POST /v1/events` is on the same management host and takes up to 500 events per call. **Do not migrate history through it.** The rules on this page do not apply there: the window is a fixed 30 days, the account's retention policy is never read, and anything older is silently moved onto the edge of that window and answered with a 200. A year of orders becomes one enormous day and nothing in the response says so. See [sending from a server](/en/docs/server).
## What exists only in the panel {#panel-only}
These are built, working and tested, and none of them has an address on `api.segmentic.net`:
- Path analysis. `POST /v1/reports/paths` does not exist.
- Churn, engagement and RFM scores, both the summaries and the member lists.
- Event exploration and the account overview.
- Journey and campaign time series.
- Scheduled reports and the event debugger.
- The saved funnel library. `GET/POST /v1/funnels` and the rest of its CRUD are on the control plane only; see [the saved funnel library](/en/docs/reports#funnel-library).
- The dashboard builder engine, that is `/v1/widgets/query`, `funnel`, `cohort` and `validate`. Rendering a saved dashboard does not exist either.
- The panel message log, searchable by date, campaign, user, recipient, message id, channel, outcome and test status. `GET /v1/messages.csv` and `GET /v1/messages.json` stream the complete filtered result rather than only the current page.
- The billing ledger export and the audit log export.
- `GET /v1/segments/{id}/export`, the only place xlsx is produced. It streams, and its cap is one million rows, again silently. It also carries a defect written into the code itself: the 200 and the headers are committed before the first row is read, so a mid-stream failure cannot become a 503 and the connection is dropped instead. There is no row count and no completeness flag on the wire.
- CSV import and event backfill, described above.
## What is not possible {#not-possible}
The honest list of what a management key cannot do:
- Collect the bytes of a queued export.
- Read one export job by its id.
- Page beyond the 100 most recent export jobs.
- Get xlsx out of the export queue.
- Choose columns or a row cap for an export.
- Read the analytics ceilings programmatically. `GET /v1/reports/limits` does not exist and `GET /v1/capabilities` publishes none of them.
- Get a delivery and engagement rollup. `GET /v1/reports/messages` does not exist.
- Discover the values of a property. No endpoint lists the distinct values of one; `GET /v1/schema/events` gives keys only.
- Be told that an event name you wrote is wrong.
- Choose the response language. `Accept-Language` is never parsed on this host and every localised string is Persian.
- Get a distinct error code for an invalid report or a warehouse failure.
- Receive a callback when an export or an import finishes. There is no webhook for either.
For the error shapes and codes, see [errors](/en/docs/errors). For the ceilings, see [limits](/en/docs/limits).
---
# API reference
> Two surfaces, two kinds of key, two hosts. This page says which job belongs to which.
> https://segmentic.net/en/docs/api
Segmentic answers on two hosts. They share nothing: not a credential, not a request shape, not an error shape. A request sent to the wrong one fails with an authentication error that says nothing about the mistake, so which host a job belongs to is the first thing to get right.
> Diagram: The callers, credentials and trust boundaries of the Segmentic ingest and management APIs
## The two surfaces {#surfaces}
| | Ingest | Management |
|---|---|---|
| Host | `https://in.segmentic.net` | `https://api.segmentic.net` |
| Key | `wk_seg_...` | `sk_seg_...` |
| Where the key lives | inside your app: a JavaScript bundle, an APK, an IPA | on your own server, in an environment variable |
| Who calls it | your users' devices | your backend, a script, an agent |
| What it does | writes events, devices and addresses | reads and changes what is in the account |
| Reference | [Ingest endpoints](/en/docs/api/ingest) | [Management API](/en/docs/api/management) |
The panel, `https://app.segmentic.net`, is a third host and it is a website, not an API. The API it talks to runs on a listener that is not routed from the internet at all, which is how the two routes that return a plaintext credential in their body are kept unreachable rather than merely forbidden.
Every path on both surfaces is under `/v1/`. There is no `/api` segment and no other prefix.
## The two keys {#keys}
Both keys are thirty-two bytes from the operating system's random source, base64url encoded without padding, with a prefix in front. So a real key is `wk_seg_` or `sk_seg_` followed by forty-three characters. Only the SHA-256 hash of a key is stored, which is why neither can be shown to you a second time: a leaked database dump must not be a pile of working credentials.
The two prefixes differ so that a leak can be triaged in one glance. A `wk_` in a public bundle is working as designed. An `sk_` in the same place is an incident.
| | Write key `wk_seg_` | Management key `sk_seg_` |
|---|---|---|
| Identifies | one app inside one account | one key inside one account, carrying a role |
| Permissions | none, and none are possible | whatever its role grants |
| Can read a person's data | no, except that person's own in-app inbox, which needs a second credential | yes, if the role allows it |
| Public | yes, deliberately | no. It is a secret |
| Expiry | none | 365 days by default, chosen at creation |
| Revocation | per app, in the panel | per key, in the panel |
A write key resolves to an account, an app, and the app's environment (`development`, `staging` or `production`). That is all it carries. It cannot read a profile, list a segment, count an audience or send a message, and there is no setting that would let it.
A management key resolves to a role: `admin`, `marketer`, `analyst`, `viewer`, `approver` or `finance`. The role decides the permissions and the permissions decide the routes. `GET /v1/whoami` returns the effective list, and a `403` names the exact permission you were missing in a `need` field, so nobody has to open a support ticket to learn which one to grant.
## Getting each key {#getting-keys}
A write key comes from the panel, on the SDK Setup screen. Choose the app, create a key, copy the plaintext. Each app carries its own key so that revoking one does not silence the others, and the plaintext is on the screen once and never recoverable afterwards.
A management key comes from the panel, under Settings, API keys. You choose the role at creation and the key holds it for life. Three rules the screen enforces: the `owner` role is refused, so no key can ever transfer or delete an account; a key cannot outrank the person creating it; and a blank expiry becomes 365 days rather than never.
Neither key can be created through the management API. Both creation routes live on the dashboard's own listener, which is not routed publicly, so minting a credential is something a signed-in person does in the panel. This is deliberate and it is not going to be relaxed.
> [!note]
> Scoped keys do not work. The database carries a `scopes` column on every key, the lookup reads it, and nothing in the product ever writes it, so every key holds its whole role and `GET /v1/whoami` always answers `"scoped": false`. The narrowest key you can actually create is the narrowest role. The per-key recipient and PII budgets in the same table are in the same state: columns exist, no code reads them.
## The management key never reaches a client {#never-in-the-client}
The write key is public on purpose. It ships in your JavaScript, anybody can read it out of the page source, and that is acceptable because the worst a stranger can do with one is add noise to your own data, which is visible and repairable.
The management key is the opposite. It can count your audiences, export your customers' phone numbers and send messages to all of them. It goes on your server, in an environment variable, and it goes nowhere else. Not in a mobile app, where it can be extracted from the binary. Not in a browser, where it is one view-source away. Not in a mobile app's build config, which is the same thing with an extra step.
Swapping the two is common enough that both directions have a defined answer, and only one of them tells you what you did wrong.
A `wk_` key sent to the management host is `401` with its own code:
```json
{
"error": {
"code": "write_key_rejected",
"message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
}
}
```
The refusal happens before any database lookup, on the prefix alone.
An `sk_` key sent to the ingest host is `401` with the ordinary refusal:
```json
{"status":"error","message":"invalid write key"}
```
The collector never looks at the prefix. It hashes whatever it was given and looks the hash up in the write-key table, where a management key is simply not present, so it is indistinguishable from a key that was revoked or never existed. That collapsing is deliberate: the endpoint must not become a way to test which keys exist. The cost is that this direction gives you no hint, and if you are staring at `invalid write key` with a key you are certain is valid, check its prefix first.
## Which job belongs to which host {#which-surface}
| Job | Host | Route |
|---|---|---|
| Record what a person did | ingest | `POST /v1/track` |
| Set traits on a profile | ingest | `POST /v1/identify` |
| Record a page or screen view | ingest | `POST /v1/page`, `POST /v1/screen` |
| Attach an anonymous history to a signed-in person | ingest | `POST /v1/alias` |
| Send many events at once from a device | ingest | `POST /v1/batch` |
| Send events from your own server | management | `POST /v1/events` |
| Register a device for push | ingest | `POST /v1/devices` |
| Subscribe a browser to web push | ingest | `POST /v1/webpush/subscribe` |
| Link a Bale, Eitaa or Rubika chat | ingest | `POST /v1/messenger/link` |
| Read a signed-in user's in-app inbox | ingest | `POST /v1/inbox` |
| Fetch the on-site campaigns for a page | ingest | `GET /v1/onsite` |
| Validate or count an audience | management | `POST /v1/audiences/validate`, `POST /v1/audiences/count` |
| Create, change or delete a segment | management | `POST /v1/segments`, `PUT /v1/segments/{id}`, `DELETE /v1/segments/{id}` |
| Create a campaign, then send it | management | `POST /v1/campaigns`, `POST /v1/campaigns/{id}/send` |
| Run a funnel or retention report | management | `POST /v1/reports/funnel`, `POST /v1/reports/retention` |
| Send one transactional message | management | `POST /v1/messages` |
| Queue an export | management | `POST /v1/exports` |
| Ask what a key may do | management | `GET /v1/whoami` |
| Ask what this deployment serves | management | `GET /v1/capabilities` |
| Check a host is up | both | `GET /v1/status` |
> [!warn]
> `POST /v1/batch` on the ingest host and `POST /v1/events` on the management host both take an array of events and they are not interchangeable. The array key is `batch` on one and `events` on the other. The ingest one answers `200` and de-duplicates by `message_id`; the management one answers `202` and does not de-duplicate at all, so a retried batch double-counts. The management one also clamps any timestamp older than thirty days to exactly thirty days ago instead of refusing it, which silently ruins a historical migration. Both are documented in full on their own pages.
## Shared conventions {#conventions}
**JSON both ways.** Every response on both surfaces carries `Content-Type: application/json; charset=utf-8`. Neither surface checks the request's own content type: both read the body and parse it as JSON whatever it claims to be. Send `application/json` anyway, because that is what will be checked the day it changes.
**Timestamps are RFC 3339 and nothing else.** Every time field on the wire, `timestamp`, `sent_at`, `scheduled_at`, the `from` and `to` of a report range, is decoded by Go's standard JSON decoder, which accepts RFC 3339 only. Epoch seconds, epoch milliseconds and a bare `2026-08-06` all fail to decode, and on the ingest host that fails the whole request with `malformed JSON`. Send UTC.
**Text is UTF-8 and Persian is normalised at ingest.** Arabic look-alike letters are folded to their Persian forms, `ي` becomes `ی` and `ك` becomes `ک`, diacritics and tatweel are stripped, and exotic whitespace is collapsed. The half-space (ZWNJ), letter case and Persian digits are kept exactly as sent. This is why a segment on `city = تهران` matches a user whose keyboard was Arabic.
**Ids you send are strings and ids we return are numbers.** `user_id`, `anonymous_id` and `message_id` are strings of at most 256 bytes with no format requirement. A segment id, campaign id or export id is an unsigned integer in JSON, and the path form must be a positive integer or the answer is `400`.
**Nothing in a body can change the account.** Both surfaces take the account from the credential and overwrite whatever the body says. A `tenant_id` in a segment body is not rejected, it is simply never read.
## The two error envelopes {#errors}
The ingest host answers a failure in the same envelope it answers a success:
```json
{"status":"error","message":"request body too large"}
```
The management host answers an object with a stable code:
```json
{
"error": {
"code": "forbidden",
"message": "this key does not carry data.export, see GET /v1/whoami for what it does carry",
"need": "data.export"
}
}
```
The `code` is the contract. The `message` is not, and an integration that branches on message text will break the first time the wording improves. `details` appears on some validation failures and carries the offending part of your payload. `need` appears only on a `403`.
> [!danger]
> On the management host the envelope is not uniform, despite the comment in the source saying it is. Eleven of the twenty-two routes reuse handlers that were written for the panel and answer `{"error":"count unavailable"}`, where `error` is a string rather than an object, and the two report routes answer in Persian. Parse defensively: read `error`, branch on whether it is a string or an object, and never assume `error.code` exists. The [Management API](/en/docs/api/management) reference names which route answers which.
## Pagination {#pagination}
There is none worth the name, and this is the gap most likely to cost you an afternoon.
`?limit=` is read on exactly one route, `GET /v1/exports`, where it defaults to twenty-five and is clamped to one hundred. `?cursor=` is read there too and then discarded. Every other list route ignores both parameters. No handler ever emits `next_cursor`, so the key is absent from every response body rather than present and empty, and `has_more` is always `false` even when there are more rows.
`GET /v1/segments` and `GET /v1/campaigns` are worse than unpaginated: the SQL behind them ends in `ORDER BY updated_at DESC LIMIT 200`, and nothing in the response says so. There is no count, no `has_more` and no warning. An account holding 250 segments receives the 200 most recently updated, and no route on any surface reaches the other 50. Treat these two lists as "the newest 200", and keep anything you need addressable by id.
## Limits and budgets {#limits}
**The ingest host has no rate limit.** Not per second, not per minute, not per key, not per IP. The only volume control on it is the monthly billing quota, and when that is spent the answer is `402 Payment Required` with a Persian sentence and the whole request refused. An SDK must not retry a `402`: nothing changes until somebody pays.
**The management host meters by weight, not by count.** Each key gets six hundred units per calendar minute. A call that reads nothing costs one unit, a bounded warehouse query costs five, a scan that grows with your history costs twenty-five. The budget is per key rather than per account, so a runaway agent cannot exhaust the budget your order pipeline depends on. Exhaustion is `429` with `Retry-After: 60`:
```json
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
```
There are no `X-RateLimit-*` headers on the budget and `GET /v1/whoami` does not report what is left, so a client cannot see how close it is until it is refused. The only rate headers on the whole surface are `X-RateLimit-Limit` and `X-RateLimit-Remaining` on `POST /v1/messages`, and those describe a second, separate per-account limiter that counts requests rather than weight.
**The budget fails closed.** If the counter store cannot be reached the answer is `503` with code `budget_unavailable`, including on `GET /v1/whoami` and `GET /v1/capabilities`, because those spend budget too. `GET /v1/status` is the only route that survives that outage. The direction is deliberate: an unmetered agent in a loop is more expensive than a report that waits.
## Checking a host is up {#status}
Both hosts serve `GET /v1/status` with no credential, no database read and no rate limit.
```bash
curl -i https://in.segmentic.net/v1/status
```
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache, must-revalidate
X-Server-Time: 2026-08-07T09:12:41Z
{"status":"ok","service":"collector","version":"1.42.0"}
```
`service` is `collector` on the ingest host and `api` on the management host, so a status page can show one dot per component. `version` is the build stamp, which makes "is the deploy out" answerable from outside. `X-Server-Time` exists so that a probe measuring round-trip time can tell "we are slow" from "the path between you and us is slow". The body is never cached, because a status page reading a cached `ok` from a CDN during an outage is worse than no status page.
## Local development {#local}
The collector listens on `http://localhost:8080` by default (`HTTP_ADDR`). Everything on the ingest host works there with a write key from your local account.
The management API listens on whatever `PUBLIC_API_ADDR` names, and that variable is **empty by default**, which means the management API is not served at all until somebody sets it. The symptom is a refused connection, not a `404`, and it is the first thing to check when your server-side integration cannot reach anything. The deployment binds it to `:8082`, which is also the address the MCP server assumes when `SEGMENTIC_API_URL` is unset.
`https://in.segmentic.ir` is not the ingest host and is deliberately not redirected to the current one. An SDK still pointed at the old name is meant to fail loudly rather than keep working.
## What is not here {#absent}
Documented as absent because finding out by trying costs more:
- **No way to create, list or revoke a key through the API.** Both key types are panel-only.
- **No way to stop a campaign.** `POST /v1/campaigns/{id}/send` is on the management host; pause, resume and cancel are not. Once your backend schedules a send, only the panel can stop it.
- **No way to download an export.** You can queue one and list it. The file is collected through the panel.
- **No way to approve a campaign, or to read whether it was approved.** Submission has a route, the decision does not. An integration learns the answer by retrying the send and reading the `409` code.
- **No message status lookup.** There is no `GET /v1/messages/{idempotency_key}`.
- **No profile read.** There is no `GET /v1/profiles/{user_id}` on either host.
- **No journey, template, consent, governance or audit routes** on the public surface.
- **No `PATCH` anywhere.** `PUT /v1/segments/{id}` replaces the whole object, with no `If-Match` and no version token, so two concurrent writers silently clobber each other.
- **No idempotency except on `POST /v1/messages`.** A retried timeout on `POST /v1/segments` creates a second segment.
- **No IP geolocation.** `country`, `region` and `city` are filled only from what the SDK sends in `context.location`.
- **No CORS preflight on the management host.** No `OPTIONS` responder is registered on that mux, so a browser's preflight reaches the catch-all and gets a `404` and the real call is never made. A browser cannot call the management API at all. That is the intended state: an `sk_seg_` key does not belong in a page.
- **A wrong method is not a `405` on the management host.** `PUT /v1/campaigns/5` falls through to the catch-all and answers `404 unknown_endpoint`. On the ingest host it is the other way round: an unregistered path under `/v1/` answers `405`, because the CORS preflight pattern claims the whole prefix.
---
# Reference: the ingest endpoints
> Every route on the ingest host, with a complete request and response you can copy.
> https://segmentic.net/en/docs/api/ingest
This is every route on the ingest host, the one your users' devices talk to. It takes a public write key, it accepts writes, and the only thing it ever reads back is either public (the on-site campaign list) or protected by a second credential (the in-app inbox). For audiences, campaigns, reports and sending, see the [Management API](/en/docs/api/management).
## The host {#host}
```text
https://in.segmentic.net
```
Locally the collector listens on `http://localhost:8080`.
The collector's whole job is to validate, de-duplicate and hand off, fast, and never to tell an SDK to drop data because of a problem at our end. That single sentence explains most of the status codes below: an outage on our side is a `503` the SDK will retry, never a `401` it would treat as permanent.
`https://in.segmentic.ir` is the old name and is deliberately not redirected here. An SDK still pointed at it fails, on purpose, rather than appearing to work.
## Authenticating with the write key {#authentication}
A write key looks like `wk_seg_` followed by forty-three base64url characters. It may be presented in three places, and they are read in this order, first match wins:
1. `Authorization: Bearer wk_seg_...`
2. `X-Segmentic-Key: wk_seg_...`
3. `?write_key=wk_seg_...` in the query string
The header is the normal form. The query parameter exists because an image beacon and a `navigator.sendBeacon` call cannot set headers, and the browser SDK uses it for `GET /v1/onsite` so that the request stays a simple cross-origin GET with no preflight.
Only the exact prefix `Bearer ` (capital B, one space) is stripped from `Authorization`. Any other scheme falls through to the next place rather than being rejected, so `Authorization: Token wk_seg_...` is read as "no bearer token here" and then the header is ignored entirely.
The key resolves to an account, an app and the app's environment. The account and the app are stamped on every event from the key, never taken from the body, so a payload naming another account has no effect. The environment is not stamped on anything: it stays on the resolved credential, no event column holds it, and nothing on this host reads it. The lookup is by SHA-256 hash: nothing checks the prefix, which is why a management key sent here is simply an unknown write key.
Resolved keys are cached for one minute (`WRITE_KEY_CACHE`), and so are failures, because an app shipped with a bad key would otherwise hammer the database forever.
Revocation is not instant, and the panel does not promise otherwise. There is a function that drops a cached entry and nothing in the running product calls it: the panel writes the revocation to Postgres and the collector is a separate process holding its own map. So a key revoked in the panel keeps being accepted until its cache entry expires, up to one minute later. Plan a leaked key around that minute rather than around the button press.
## When authentication fails {#auth-errors}
| Situation | Status | Body | Header |
|---|---|---|---|
| No key in any of the three places | `401` | `{"status":"error","message":"missing write key"}` | |
| Unknown key, revoked key, or a suspended account | `401` | `{"status":"error","message":"invalid write key"}` | |
| The lookup itself failed | `503` | `{"status":"error","message":"cannot verify the write key right now; retry"}` | `Retry-After: 5` |
Unknown, revoked and suspended give byte-identical answers so that the endpoint cannot be used to find out which keys exist.
The third row used to answer `401` too, and that was the worst possible answer. An SDK reads `401` as "this key will never work", stops, and throws the buffered events away; it reads `503` as "try again later" and keeps them. It was measured rather than reasoned about: with the database scaled to zero, eight of eight events came back `401`. The write-ahead log exists precisely so that an infrastructure failure never costs an event, and that one line defeated it, because the request never reached the log.
## CORS {#cors}
Every write-key endpoint writes these headers before it does anything else, including before authenticating, so a browser sees the real status code rather than a CORS error on a `401` or a `413`:
```http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Segmentic-Key
Access-Control-Max-Age: 86400
```
`OPTIONS` on any path under `/v1/` answers `204` with those headers and no body.
`Access-Control-Allow-Credentials` is never set, and that is what makes the wildcard origin safe. The write key is the only credential this host accepts and it is public by design, so there is nothing ambient for a browser to attach and nothing for a wildcard to expose. Do not send cookies here; they will not be honoured.
`GET /v1/status` and the email endpoints under `/e/` do not carry CORS headers. They are not called from a page.
## The request body {#body}
JSON, up to five megabytes (`5242880` bytes) per request. Over that is `413` with `{"status":"error","message":"request body too large"}`.
`Content-Type` is not checked. The handlers read the body and parse it as JSON whatever the request claims, so `text/plain` with a JSON body works today. Send `application/json` anyway.
Anything that is not valid JSON is `400` with `{"status":"error","message":"malformed JSON"}`. Note that this includes a well-formed body carrying a badly formed time: `timestamp` and `sent_at` are decoded as RFC 3339 and nothing else, so `"timestamp": 1786000000` fails the whole request as malformed JSON rather than as a bad field.
## The event body, field by field {#event-fields}
The five single-event endpoints share one body shape. Every field is optional at the parsing stage; what is actually required depends on the endpoint.
| Field | Type | Required | Notes |
|---|---|---|---|
| `event` | string | on `/v1/track` only | at most 128 bytes after normalisation. Persian is fine, spaces are kept, there is no case folding and no snake_case rule |
| `user_id` | string | one of `user_id` or `anonymous_id` | at most 256 bytes |
| `anonymous_id` | string | one of the two | at most 256 bytes, no format requirement, not required to be a UUID |
| `previous_id` | string | on `/v1/alias` only | the id being merged away. **No length bound at all**, unlike the two above |
| `message_id` | string | no, but send one | at most 256 bytes. Without it a retry cannot be recognised as a duplicate |
| `timestamp` | RFC 3339 | no | defaults to the moment we received it |
| `sent_at` | RFC 3339 | no | enables clock-skew correction |
| `properties` | object | no | at most 256 keys, key at most 128 bytes, string value at most 8192 bytes |
| `traits` | object | no | at most 256 keys, same value bound |
| `context` | object | no | see below |
| `type` | string | ignored here | the path decides the type. Required on batch items only |
`type` in the body of a single-event request is overwritten by the path, so `POST /v1/track` can only ever produce a track event no matter what the body says. This is not a validation failure; the field is simply replaced.
The 256-byte bound is enforced in three different ways and the difference bites. `user_id` and `anonymous_id` are **rejected** when they are too long, with `id_too_long`. `context.session_id` is **truncated** at 256 bytes, silently, so a long session id becomes a different session id. `previous_id` is neither: it is stored whole, and the only thing bounding it is the five-megabyte body cap.
Property and trait keys are normalised: trimmed, control characters dropped, then every run of whitespace and every `.` and `-` becomes a single `_`, with leading and trailing underscores removed. So `" spaced key "` becomes `spaced_key`, `dotted.key` becomes `dotted_key` and `dashed-key` becomes `dashed_key`. A key that normalises to nothing is skipped.
Property values are stored twice where that is meaningful: as text always, and as a number when the value is a number or a boolean. A numeric-looking string is never parsed into a number, because parsing `"01234"` would throw away the leading zero of a postcode and a national id past `2^53` loses its last digits to a float. A `null` property is dropped entirely rather than stored as an empty string, so that an `is not set` filter stays correct.
## The `context` object {#context}
`context` describes the device, the app and the page. Some of it is stored on the event, some of it is deliberately not, and the difference matters because you cannot filter on something that was never kept.
| Field | Stored as | Bound |
|---|---|---|
| `context.app.version` | `app_version` | 64 |
| `context.device.type` | `device_type` | 32 |
| `context.device.model` | `device_model` | 128 |
| `context.device.manufacturer` | `device_vendor` | 64 |
| `context.device.push_provider` | `push_provider` | 16 |
| `context.os.name`, `context.os.version` | `os_name` (lower-cased), `os_version` | 32 each |
| `context.network.carrier` | `carrier` | 64 |
| `context.page.url`, `.path`, `.referrer` | `page_url`, `page_path`, `page_referrer` | 2048 each |
| `context.page.title` | `page_title` | 512 |
| `context.campaign.source`, `.medium`, `.name`, `.term`, `.content` | `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | 128 each |
| `context.campaign.campaign_id`, `.journey_id` | `campaign_id`, `journey_id` | numeric |
| `context.campaign.variant_id`, `.message_id`, `.token` | `variant_id`, `source_message_id`, `sg_t` | 64, 256, 128 |
| `context.locale`, `.timezone`, `.session_id` | `locale`, `timezone`, `session_id` | 32, 64, 256 |
| `context.location.country`, `.region`, `.city` | `country`, `region`, `city` | 64 each |
| `context.ip` | `ip`, **only** when the connection gave us nothing | 64 |
| `context.user_agent` | nothing. The `User-Agent` header wins | |
| `context.screen.width`, `.height`, `.density` | nothing. Accepted and dropped | |
| `context.location.latitude`, `.longitude` | nothing. Accepted and dropped | |
| `context.device.id`, `.name`, `.push_token`, `.has_gms`, `.ad_tracking_enabled` | nothing. Accepted and dropped | |
`context.device.push_token` is dropped on purpose. Only the route is kept, never the token: a push token in the event stream would be copied into the warehouse, every export and every backup, for a value the device registry already owns. Register the token with `POST /v1/devices` instead.
The client cannot set the IP, the user agent, the browser name, the bot flag or the account. Those come from the connection and the key, because a client must not be able to fake its own geo or device. Facts the SDK does send about the device win over what the `User-Agent` header parses to; the header only fills gaps. Bot traffic is flagged and stored, never dropped, because dropping it silently makes a traffic dip unexplainable; every report filters it out by default.
There is no IP geolocation in the deployed build. `country`, `region` and `city` are filled only from `context.location`.
## The response shape {#response}
Every JSON response on this host is one of these fields:
| Field | Type | Present when |
|---|---|---|
| `status` | `"ok"` or `"error"` | always |
| `accepted` | number | non-zero |
| `duplicates` | number | non-zero |
| `rejected` | number | non-zero |
| `warnings` | array of `{code, field, note}` | there are any |
| `errors` | array of `{index, reason}` | batch items failed |
| `message` | string | on an error |
`accepted` is omitted when it is zero, so a fully rejected batch answers without an `accepted` key at all, and an error answers without one too. Read it as "zero if absent".
`duplicates` is the part of `accepted` we already held. It does not come out of `accepted`, because `accepted` answers the one question an SDK asks, "may I stop sending these", and a client that resent whatever was not accepted would resend a duplicate forever. So `accepted: 500, duplicates: 493` means seven events were stored and 493 were recognised as ones we already had. Absent means zero, like the others.
A warning means the event was accepted with a correction applied. The codes you can see:
| Code | Meaning |
|---|---|
| `generated_message_id` | no `message_id` was sent, so we minted one and retries of this event cannot be de-duplicated |
| `timestamp_in_future` | the device clock was more than an hour ahead; clamped to receive time |
| `timestamp_too_old` | older than the account's ingest window; clamped to the edge of it |
| `too_many_properties` | more than 256 properties; the note says how many were sent and how many were kept |
| `unserialisable_property` | one property could not be encoded; `field` names it |
| `too_many_traits` | more than 256 traits |
| `invalid_phone` | not a valid Iranian mobile number; the raw value was stored as given |
| `invalid_national_id` | the national id failed its check digit; **the trait was dropped** |
Note the asymmetry in the last two, which is deliberate: a bad phone number is kept because it is often a real number in an unexpected format, and a bad national id is dropped because a national id that fails its check digit is not a national id.
## POST /v1/track {#track}
Records something a person did. `event` is required; without it the answer is `400 missing_event_name`.
```bash
curl -X POST https://in.segmentic.net/v1/track \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"message_id": "m-1",
"event": "order_completed",
"user_id": "u_123",
"properties": { "revenue": 2500000, "currency": "IRR", "order_id": "8821", "city": "تهران" }
}'
```
```json
{"status":"ok","accepted":1}
```
Without a `message_id` the same call answers:
```json
{
"status": "ok",
"accepted": 1,
"warnings": [
{
"code": "generated_message_id",
"field": "message_id",
"note": "no message_id sent; retries of this event cannot be de-duplicated"
}
]
}
```
Revenue is extracted from the properties, in this order: the first non-zero of `revenue`, `total`, `value`; failing that `price` multiplied by `quantity`, where `quantity` defaults to one. `currency` defaults to `IRR` and is upper-cased, so `"irt"` is stored as `IRT`. No conversion is ever guessed.
## POST /v1/identify {#identify}
Sets traits on a profile. `event` is ignored; the stored event is always named `identify`.
```bash
curl -X POST https://in.segmentic.net/v1/identify \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"message_id": "id-8821",
"user_id": "u_123",
"traits": {
"email": " Ali@Digikala.COM ",
"phone": "0912 345 6789",
"first_name": "علی",
"city": "کرج",
"gender": "مرد",
"lifetime_value": 48200000,
"is_subscriber": true,
"referral_code": "0912345"
}
}'
```
```json
{"status":"ok","accepted":1}
```
What that payload becomes:
| Trait | Stored | Why |
|---|---|---|
| `email` | `ali@digikala.com` | trimmed and lower-cased. There is no format validation at all |
| `phone` | `+989123456789` plus `phone_operator: "mci"` | anything but E.164 creates a second profile for the same human. The operator is derived from the four-digit prefix, and `0912` is `mci` (Hamrah-e Aval) |
| `city` | `کرج` | Persian normalised, so an Arabic-keyboard `كرج` matches too |
| `gender` | `male` | folded and mapped. `m`, `male`, `man`, `مرد`, `اقا`, `پسر` all become `male`; the female set becomes `female`; anything else becomes `other` |
| `lifetime_value` | text `48200000` and number `48200000` | numeric traits are written twice so that both `equals` and `greater than` filters work |
| `is_subscriber` | text `true` and number `1` | |
| `referral_code` | text `0912345` only | a numeric-looking string is never parsed, so the leading zero survives |
The double write is not cosmetic. It was added after a live account with about 115,000 profiles had an empty numeric map for every trait, so an audience of «موجودی کلید ۱۰۰ یا بیشتر» returned nobody and «کمتر از ۱۰» returned all 114,943 including a user holding 428. No error, no warning, an audience that reads like an answer.
`national_id` is validated with the Iranian check digit and **dropped** if it fails, with the warning `invalid_national_id`. A valid one is stored with its Persian and Arabic-Indic digits converted to ASCII and its surrounding spaces trimmed, and otherwise exactly as you sent it. The check strips dashes and spaces before it counts, accepts 8 to 10 digits, and pads a short one to ten only for its own arithmetic, so `12345679` is stored at eight characters, `001-234-5679` keeps its dashes and `001 234 5679` keeps its inner spaces. Send the ten-digit form if your segments and joins expect ten.
## POST /v1/page and POST /v1/screen {#page-screen}
The same body. `event` is optional here: without it the stored event is named `page_viewed` on `/v1/page` and `screen_viewed` on `/v1/screen`.
```bash
curl -X POST https://in.segmentic.net/v1/page \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"message_id": "p-4471",
"anonymous_id": "a_9f21c0",
"event": "product",
"properties": { "sku": "DKP-118820" },
"context": {
"page": {
"url": "https://shop.example.ir/p/118820?utm_source=sms",
"path": "/p/118820",
"title": "گوشی موبایل",
"referrer": "https://www.google.com/"
},
"session_id": "s_20260807_01",
"locale": "fa-IR"
}
}'
```
```json
{"status":"ok","accepted":1}
```
## POST /v1/alias {#alias}
Attaches an anonymous history to a signed-in person. `previous_id` is required; without it the answer is `400 missing_previous_id`. The stored event is always named `alias`.
```bash
curl -X POST https://in.segmentic.net/v1/alias \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"message_id":"al-1","user_id":"u_123","previous_id":"a_9f21c0"}'
```
```json
{"status":"ok","accepted":1}
```
The SDKs send this automatically on the first `identify` after anonymous browsing, before the identify itself. If you are writing your own client, copy that: without the alias the user's entire pre-login history is orphaned and every funnel that crosses the login boundary reports the wrong number.
## POST /v1/batch {#batch}
Up to 500 events in one request. Each item carries its own `type`, and here the field is load-bearing rather than ignored: it must be one of `track`, `identify`, `alias`, `page`, `screen`.
```bash
curl -X POST https://in.segmentic.net/v1/batch \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"sent_at": "2026-08-07T09:12:41Z",
"context": { "locale": "fa-IR", "app": { "version": "5.2.1" } },
"batch": [
{"type":"track","message_id":"b1","event":"product_viewed","user_id":"u1"},
{"type":"track","message_id":"b2","event":"checkout_started","user_id":"u2"},
{"type":"identify","message_id":"b3","user_id":"u3","traits":{"phone":"09123456789"}}
]
}'
```
```json
{"status":"ok","accepted":3}
```
The top-level `context` and `sent_at` are defaults: they are copied into any item that does not carry its own, and an item's own value is never overwritten.
A bad item does not lose the good ones. The response names the index so your retry logic can find the item without matching on content:
```json
{
"status": "ok",
"accepted": 2,
"rejected": 1,
"errors": [ { "index": 1, "reason": "missing_identity" } ]
}
```
Two failures do refuse the whole request, both with `400`: an empty `batch` array (`batch_empty`) and more than 500 items (`batch_too_large: 501 items, limit 500`). A quota refusal also takes the whole batch, never a prefix, because a partial accept would leave the SDK unable to tell which items to resend.
A fifty-event batch costs one de-duplication round trip and one publish round trip, not fifty of each. The `warnings` array in the response is bounded at roughly fifty entries so that a batch of five hundred slightly-wrong events cannot answer with a megabyte of advice; every warning is still counted in the account's own metrics.
> [!warn]
> The live event debugger in the panel does not see events sent through `/v1/batch`. Recording happens on the single-event path and the webhook path only. Every mobile SDK batches and so does the web SDK, so if you are watching the debugger and seeing nothing while `accepted` counts up, this is why.
## message_id and de-duplication {#dedupe}
`message_id` is what makes a retry safe. SDKs on flaky mobile networks resend aggressively, so without it a purchase count silently doubles.
- The scope is your account. Two accounts may use the same `message_id` without colliding.
- The window is 48 hours. It has to comfortably exceed the longest SDK retry: an Android client that buffered events offline for a day and then flushed must still be recognised.
- A duplicate answers `200` with `accepted: 1` and `duplicates: 1`, exactly like a first delivery, because an SDK that got an error would keep retrying forever. In a batch, duplicates count toward `accepted` for the same reason and are reported in `duplicates` beside it.
- A message id is claimed before the event is published and the claim is given back if the publish fails, so an event that answered `503` is not mistaken for a duplicate when the SDK resends it.
- A duplicate is not billed. A retrying SDK costs us a cache lookup, not an invoice line you will dispute.
- If the de-duplication store is unreachable, the event is accepted and published anyway. Accepting a possible duplicate is strictly better than losing the event: a duplicate is repairable downstream and missing data is not.
The mechanism is a set-if-absent with a time to live, not a Bloom filter, so there are no false positives.
## Timestamps and clock skew {#timestamps}
`timestamp` is when the thing happened, on the device. `sent_at` is when the device sent the request. The second one is what lets us correct the first.
1. No `timestamp` means the time we received it. No warning.
2. A `timestamp` more than one hour ahead of our clock is clamped to receive time with the warning `timestamp_in_future`. A time ahead of now can only come from a wrong device clock, and letting it through would put events in periods that reports have already finalised.
3. A `timestamp` older than your account's ingest window is clamped to the edge of that window with the warning `timestamp_too_old`. The default window is 30 days; an account that keeps events for longer gets a longer one. It is per account, not a global constant.
4. Otherwise, if `sent_at` is present and differs from our clock by more than a minute, the whole difference is added to `timestamp`. The corrected value is used only if it still lands inside the window. No warning is raised for a correction.
Worked example: a device clock is two hours slow. It claims the event happened at 08:00 and that it sent at 10:00. We receive at 12:00. The skew is two hours, so the stored time is 10:00, not 08:00.
> [!danger]
> Live ingest always clamps and never rejects an out-of-window timestamp. That means a historical migration through this host silently stacks everything older than the window on one instant, answers `200`, and looks fine until a funnel makes no sense months later. This has happened to a real account migrating two years of history. Do not backfill through `/v1/track` or `/v1/batch`.
## Status codes on the event endpoints {#status-codes}
| Situation | Status | Body |
|---|---|---|
| Accepted | `200` | `{"status":"ok","accepted":1}`, with `warnings` if any |
| Accepted, and it was a duplicate | `200` | identical |
| No write key | `401` | `{"status":"error","message":"missing write key"}` |
| Bad, revoked or suspended key | `401` | `{"status":"error","message":"invalid write key"}` |
| Key lookup failed, our outage | `503` | `{"status":"error","message":"cannot verify the write key right now; retry"}` |
| Body over five megabytes | `413` | `{"status":"error","message":"request body too large"}` |
| Body is not JSON | `400` | `{"status":"error","message":"malformed JSON"}` |
| Account over its quota | `402` | `{"status":"error","message":""}` |
| Validation failed, single event | `400` | `{"status":"error","message":""}` |
| Batch empty or over 500 | `400` | `{"status":"error","message":"batch_empty"}` or `"batch_too_large: 501 items, limit 500"` |
| Some batch items were bad | `200` | `{"status":"ok","accepted":N,"rejected":M,"errors":[...]}` |
| Bus and disk buffer both failed | `503` | `{"status":"error","message":"temporarily unavailable, please retry"}` |
The rejection reasons are stable codes, because the panel maps them to Persian and customers alert on them: `unknown_type`, `missing_identity`, `missing_event_name`, `event_name_too_long`, `event_name_invalid_chars`, `id_too_long`, `missing_previous_id`, `batch_too_large`, `batch_empty`. Two of them carry your own value in the message, as in `unknown_type: "trak"`, so match on the prefix rather than on equality.
The last row is the only case where an SDK should retry for a data reason. A bus outage alone does not produce it: the collector falls back to a local write-ahead log, so the queue being down is not visible to you at all. Both had to fail. Note that this `503` carries no `Retry-After`; only the key-lookup `503` does.
The `402` is Persian regardless of your `Accept-Language`. The collector has no locale negotiation: nothing tags the request with a language, so the sentence falls back to Persian every time. Branch on the status code, not on the text.
## POST /v1/devices {#devices}
Registers a device so a campaign can push to it. Served only when push is configured.
```bash
curl -X POST https://in.segmentic.net/v1/devices \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"device_id": "d_5f2a91c4",
"user_id": "u_123",
"platform": "android",
"tokens": { "fcm": "cZ1x...:APA91b..." },
"push_enabled": true,
"has_gms": true,
"app_version": "5.2.1",
"manufacturer": "Samsung",
"model": "SM-A546E",
"os_name": "android",
"os_version": "14",
"locale": "fa-IR",
"timezone": "Asia/Tehran",
"sdk_name": "segmentic-android",
"sdk_version": "1.4.0"
}'
```
```json
{"status":"ok"}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `device_id` | string | yes | at most 256 bytes |
| `user_id` | string | one of the two | |
| `anonymous_id` | string | one of the two | |
| `platform` | string | yes | `android`, `ios`, `web`, `windows`, `macos`, `linux`, plus aliases such as `iphone`, `ipad`, `osx`, `darwin`, `win`, `browser`. `server` is refused |
| `tokens` | object | one usable token | transport name to token |
| `push_provider` and `push_token` | string | no | the older single-route form. `tokens` wins if both are sent |
| `push_enabled` | boolean | no | omitted means enabled, so an old SDK does not mute its own users |
| `has_gms` | boolean | no | omitted means "did not say", which is not the same as `false` |
| `app_version`, `manufacturer`, `model`, `os_name`, `os_version`, `locale`, `timezone`, `sdk_name`, `sdk_version` | string | no | each at most 256 bytes |
Which transport may reach which platform:
| Platform | Transports |
|---|---|
| `android` | `fcm`, `bazaar`, `myket`, `mqtt` |
| `ios` | `apns`, `mqtt` |
| `web`, `windows`, `macos`, `linux` | `webpush` |
A token on the wrong transport is kept out and warned about rather than stored, because the failure mode without that check is not an error: it is a campaign that reports 100% sent and delivers nothing.
That table says what registration accepts, not what can be delivered to, and two rows of it currently deliver nothing at all. **No `mqtt` provider is implemented**: the transport name is a constant and it sits in the router's preference order, and no code behind it sends anything, so an Android or iOS device holding only an `mqtt` token registers cleanly and is never reachable. **`windows`, `macos` and `linux` have no route in the push router either**: the router's per-platform preference table has entries for `android`, `ios` and `web` only, so a desktop registration is stored, counted, and never sent to. Register `fcm`, `apns` or `webpush` on `web`, and read a desktop or `mqtt` registration as bookkeeping rather than as reachability.
APNs tokens are repaired on the way in. Older iOS APIs stringify a token as ``, and sending that verbatim is rejected by Apple for every message forever, so the angle brackets and spaces are stripped and the value is lower-cased.
Failures answer `400` with the reason and, unusually, **with the warnings attached**:
```json
{
"status": "error",
"message": "device: registration carries no usable token",
"warnings": [
{ "code": "transport_not_supported", "message": "...", "field": "apns" }
]
}
```
Without those an SDK author sending an APNs token from an Android build sees only "no usable token" and has nothing to go on. The warning codes here are `empty_token`, `token_too_long` (over 4096), `transport_not_supported` and `fcm_without_gms`. The rejection reasons, verbatim, are `device: device_id is required`, `device: platform must be one of android, ios, web, windows, macos, linux`, `device: user_id or anonymous_id is required` and `device: registration carries no usable token`. A registration with `push_enabled: false` and no token is accepted, because that is a real state change.
A store failure is `503` with `{"status":"error","message":"temporarily unavailable, please retry"}`. Unlike an event, a failed registration has no buffer behind it, so the SDK must retry.
## POST /v1/devices/unregister {#devices-unregister}
```bash
curl -X POST https://in.segmentic.net/v1/devices/unregister \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"device_id":"d_5f2a91c4","user_id":"u_123","revoked":false}'
```
```json
{"status":"ok"}
```
`device_id` is required. Without it the answer is `400 {"status":"error","message":"device_id is required"}`, and a body that is not valid JSON gets exactly the same answer rather than `malformed JSON`.
`revoked: true` means the app was uninstalled and the install is gone. `revoked: false`, the default, means a sign-out: the user is detached and the token is kept. Call it on sign-out. On a shared phone, leaving the previous account attached means the next person receives someone else's order updates.
## Web push {#webpush}
Served only when web push is configured. Two routes.
`POST /v1/webpush/subscribe` accepts what the browser handed you, either nested or flattened:
```bash
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_123",
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/dK9...",
"p256dh": "BJ7s...",
"auth": "k1Qb..."
}
}'
```
```json
{"status":"ok"}
```
The flat form works too:
```json
{"user_id":"u_123","endpoint":"https://...","p256dh":"BJ7s...","auth":"k1Qb..."}
```
The nested form exists so a page can post what the browser gave it without picking it apart, and more importantly without re-encoding the keys. Base64 that has been decoded and re-encoded by a well-meaning helper is the classic way a subscription silently stops decrypting.
All four of `user_id`, `endpoint`, `p256dh` and `auth` are required. Missing any of them is `400 {"status":"error","message":"user_id and a complete subscription are required"}`, because an endpoint with no keys is unusable: the payload cannot be encrypted.
`POST /v1/webpush/unsubscribe` needs only `endpoint`, and no user id is checked:
```bash
curl -X POST https://in.segmentic.net/v1/webpush/unsubscribe \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"endpoint":"https://fcm.googleapis.com/fcm/send/dK9..."}'
```
```json
{"status":"ok"}
```
The endpoint is the subscription's own secret. Holding it is already enough to send to that browser, so demanding more before letting someone stop receiving would be protecting the wrong direction. An empty `endpoint` is `400 {"status":"error","message":"endpoint is required"}`.
A store failure on either route is `503`, not a swallowed `200`, because the user has already granted a permission the page cannot ask for twice.
> [!danger]
> A web push subscription on its own does not make anybody reachable. Before it reaches the web push sender, every send on the `webpush` channel loads that user's device rows and suppresses the message as `not_reachable` when there are none. The check runs whether or not a device registry is configured, so on an install with no device store **every** web push is suppressed, and the campaign reports the suppression rather than an error. If you are integrating browser push only, register the same user with `POST /v1/devices` (`platform: "web"`) as well as subscribing, and confirm on a test send before you build a campaign on it.
## Messengers {#messenger}
Served only when messengers are configured. Links a Bale, Eitaa or Rubika chat to a profile.
```bash
curl -X POST https://in.segmentic.net/v1/messenger/link \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_123",
"platform": "bale",
"chat_id": "44120099",
"username": "ali_gh",
"source": "bot_start"
}'
```
```json
{"status":"ok"}
```
`platform` must be exactly `bale`, `eitaa` or `rubika`. Anything else, including `telegram`, is `400 {"status":"error","message":"user_id, chat_id and a known platform are required"}`. The column behind it has a database check constraint, so an unrecognised value would otherwise fail deeper down with an error nobody can act on.
`user_id`, `chat_id` and a valid `platform` are required. `username` and `source` are optional and are passed through as sent. The value that carries real consent is `bot_start`, meaning the person started the bot themselves; anything else is worth being able to find later.
`POST /v1/messenger/unlink` needs `user_id` and `platform` only:
```bash
curl -X POST https://in.segmentic.net/v1/messenger/unlink \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{"user_id":"u_123","platform":"bale"}'
```
```json
{"status":"ok"}
```
## The in-app inbox {#inbox}
Served only when the inbox is configured. This is the one route on this host that reads a person's own data, and a public write key cannot be what protects it.
Every request carries a second credential, `user_hash`, which your own backend computes at sign-in:
```text
user_hash = lowercase_hex( HMAC-SHA256( identity_secret, user_id ) )
```
```bash
printf '%s' "u_123" \
| openssl dgst -sha256 -hmac "$SEGMENTIC_IDENTITY_SECRET" -r \
| cut -d' ' -f1
```
The identity secret never reaches a browser or an app. There is no screen in the panel that issues it and no API route that returns it: it is created by a Segmentic operator, so getting one means asking us. Rotating it invalidates every hash you have already handed out, which signs your whole app out of its inbox until you redeploy.
```bash
curl -X POST https://in.segmentic.net/v1/inbox \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_123",
"user_hash": "9f1c0b7d3e5a...",
"limit": 20
}'
```
```json
{
"status": "ok",
"messages": [
{
"message_id": "c104.u_123",
"title": "سفارش شما ارسال شد",
"body": "بسته شما تحویل پست شد.",
"image": "https://cdn.example.ir/parcel.png",
"deeplink": "myapp://orders/8821",
"surface": "inbox",
"token": "1.7.k2.ce.mfz1t8.9c4a...",
"created_at": "2026-08-07T09:00:00Z",
"expires_at": "2026-08-21T09:00:00Z",
"seen": false
}
]
}
```
`messages` is always an array, never `null`, so an SDK that iterates without a nil check gets an empty loop rather than a crash.
`token` is the attribution signature for that message. Send it back in `context.campaign.token` on the `message_opened` event you post to `/v1/track`, so that the open can be proven to belong to a message we really sent.
A `POST` rather than a `GET` for two reasons: the proof belongs in a body rather than in a query string that every proxy, browser history and access log along the way keeps a copy of, and fetching has a side effect, since the rows come back marked delivered.
`limit` is passed to the store unchanged. Zero means the store's own bound applies, and that bound is not published here.
Every identity failure is the same `403`:
```json
{"status":"error","message":"user identity is not verified"}
```
Wrong hash, missing hash, and an account with no identity secret configured are indistinguishable, because distinguishing them would turn this into an oracle for which user ids exist. There is no unverified mode.
`POST /v1/inbox/ack` takes the same credential plus two arrays:
```bash
curl -X POST https://in.segmentic.net/v1/inbox/ack \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_123",
"user_hash": "9f1c0b7d3e5a...",
"seen": ["c104.u_123"],
"dismissed": ["c99.u_123"]
}'
```
```json
{"status":"ok"}
```
The ack does not record an engagement. An in-app open is reported through the ordinary event path with the token the inbox handed you, so that it is verified by exactly the same signature check as every other channel's. A second, trusted path for the same signal would be a second thing to get wrong, and this one would be trusted on the word of a caller holding nothing but a public write key.
## On-site messages {#onsite}
Served only when on-site is configured. Three routes: what to show, and what happened.
`GET /v1/onsite` is the one request in the product that runs on the critical rendering path of somebody else's website, and every decision about it follows from that. It carries no user identity, so one response serves every visitor and a CDN can cache it. It returns targeting rules rather than decisions, so the browser matches locally without a round trip.
```bash
curl "https://in.segmentic.net/v1/onsite?write_key=wk_seg_..."
```
```http
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
Content-Type: application/json; charset=utf-8
```
```json
{
"campaigns": [
{
"id": 12,
"name": "بنر تخفیف نوروز",
"kind": "banner",
"status": "live",
"content": { },
"targeting": { },
"max_impressions": 3,
"cooldown_hours": 24,
"dismissible": true,
"starts_at": "2026-03-15T00:00:00Z",
"ends_at": "2026-03-25T00:00:00Z",
"impressions": 41822,
"clicks": 1104,
"dismissals": 380
}
],
"cache_seconds": 60
}
```
`kind` is `banner`, `modal`, `slidein` or `survey`. `content` and `targeting` are collapsed in the sample above; their shapes are on [On-site messages](/en/docs/onsite). Sixty seconds is long enough that a busy shop's page views mostly do not reach us, and short enough that pausing a campaign takes effect while the person who pressed pause is still watching. Because of that window the browser checks the start and end dates again locally, so a campaign whose end passes inside the cache stops showing without waiting for it.
The targeting rules in this response are public. Anybody can read them in the network tab, which is why the rule vocabulary contains nothing you would mind a competitor seeing. Do not put anything secret in a targeting rule.
A store failure here answers `200` with an empty list, never a `5xx`. This runs inside your page load: a failure of ours must degrade to "no banner today", never to a console error on your site.
`POST /v1/onsite/event` records what happened:
```bash
curl -X POST https://in.segmentic.net/v1/onsite/event \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"campaign_id": 12,
"anonymous_id": "a_9f21c0",
"action": "click",
"page_url": "https://shop.example.ir/p/118820"
}'
```
```json
{"status":"ok"}
```
`campaign_id` and one of `user_id` or `anonymous_id` are required; without them the answer is `400 {"status":"error","message":"campaign_id and a visitor id are required"}`. `action` is `impression` (the empty string means the same), `click`, `dismiss` or `convert`, matched case-insensitively; anything else is `400 {"status":"error","message":"unknown action"}`.
A storage failure still answers `200`. Losing an impression count costs a number on a dashboard; returning an error to a script running inside your page costs you a console error on every page view.
`POST /v1/onsite/response` records a survey answer, and adds `score` and `answers`:
```bash
curl -X POST https://in.segmentic.net/v1/onsite/response \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"campaign_id": 31,
"user_id": "u_123",
"score": 9,
"answers": { "why": "ارسال سریع بود" },
"page_url": "https://shop.example.ir/thanks"
}'
```
```json
{"status":"ok"}
```
The campaign is loaded and checked rather than trusted: whether this is an NPS survey decides whether the score means anything, and the browser is not the authority on that. An id that does not resolve is `400 {"status":"error","message":"unknown campaign"}`. A campaign that is not a survey is `400 {"status":"error","message":"onsite: this campaign is not a survey"}`. When the campaign is configured as NPS the score must be present and between 0 and 10, otherwise `onsite: an NPS score must be between 0 and 10`; when it is not NPS the score is forced to `-1`, meaning no score. **Omitting the field is refused, not read as zero.** It used to be read as zero, which is a valid detractor, so a body with no score at all was stored as the angriest answer on the scale and replaced whatever score that person had already given. A free-text answer longer than 2000 characters is truncated rather than rejected, because somebody who wrote three paragraphs about their delivery has said something worth keeping. Saving a response also records a `convert`, so somebody who told you what they think is not asked the same question next week.
A save failure here is `503`, unlike the other two on-site routes: an answer is not a counter.
## GET /v1/status {#status}
Unauthenticated, no database read, no rate limit.
```bash
curl -i https://in.segmentic.net/v1/status
```
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache, must-revalidate
X-Server-Time: 2026-08-07T09:12:41Z
{"status":"ok","service":"collector","version":"1.42.0"}
```
`version` is the build stamp of the running collector, so "is the deploy out" is answerable from outside. `/readyz` and the metrics endpoint are on a separate administrative listener and are not part of this surface.
## Routes that exist only when configured {#conditional-routes}
A feature with nowhere to write is not served at all, rather than accepting data it would silently discard. A `404` tells an SDK author immediately that a feature is not configured; a `200` that quietly dropped the subscription would be found weeks later, by a campaign that reached nobody.
| Routes | Served when |
|---|---|
| `/v1/track`, `/v1/identify`, `/v1/page`, `/v1/screen`, `/v1/alias`, `/v1/batch`, `/v1/status` | always |
| `/v1/devices`, `/v1/devices/unregister` | a device store is configured |
| `/v1/webpush/subscribe`, `/v1/webpush/unsubscribe` | web push is configured |
| `/v1/messenger/link`, `/v1/messenger/unlink` | messengers are configured |
| `/v1/inbox`, `/v1/inbox/ack` | the inbox is configured |
| `/v1/onsite`, `/v1/onsite/event`, `/v1/onsite/response` | on-site is configured |
An unregistered path under `/v1/` answers **`405 Method Not Allowed`, not `404`**. The CORS preflight pattern claims every path under that prefix for `OPTIONS`, so the router knows the path and not the method. Treat a `405` here as "this feature is not turned on for this deployment", and check with the same eye you would give a `404`. Outside `/v1/` an unregistered path is a plain `404`.
## Other paths on this host {#other-paths}
These are on the same host and are not part of the SDK surface. They are documented where they belong.
| Path | What it is |
|---|---|
| `GET /e/o` | the open pixel in an email. Always answers a transparent GIF, even for a forged token, because a broken image in a marketing email is the most visible defect a recipient can see |
| `GET /e/u`, `POST /e/u` | one-click unsubscribe. The `GET` deliberately does not unsubscribe; see [Consent and unsubscribes](/en/docs/consent) |
| `GET /e/p`, `POST /e/p` | the recipient's preference centre |
| `POST /v1/hooks/{source}/{token}` | platform webhooks from Digikala, Basalam, Torob, ZarinPal, WooCommerce, Shopify and Segment; see [Webhooks](/en/docs/webhooks) |
| `POST /v1/bounce/{local}` | the email bounce intake, addressed by its return path |
| `GET /sdk/*` | the browser SDK bundle, served from this origin so that one entry in your content security policy covers both the script and the requests it makes |
| `GET /s/*` | a hard `404` here. Short links live on their own short domain, because a shorter domain is fewer characters of every SMS |
None of these take a write key. The email endpoints take a signed token instead, which is stronger: a write key is public by design and a signature is not.
## What this host does not do {#absent}
- **No rate limiting of any kind.** Not per second, not per key, not per IP, at the edge or in the application. The only volume control is the monthly quota, which answers `402`.
- **No `Retry-After` on the publish-failure `503`.** Only the key-lookup `503` carries one. Use your own backoff.
- **No `Content-Type` enforcement.** A JSON body labelled anything is accepted.
- **No locale negotiation.** `Accept-Language` is ignored; the one human-readable message on this host, the quota refusal, is always Persian.
- **No IP geolocation.** Location comes only from `context.location`.
- **No `GET`, `PUT` or `DELETE` variants of the event endpoints.** `GET /v1/track` answers `405`.
- **No debug recording on the batch path**, so the panel's live event debugger is blind to batched traffic.
- **No server-side cookie and no server-assigned anonymous id.** Persisting `anonymous_id` is entirely the SDK's job, and if you write your own client it is your job.
- **No way to read an event back.** Nothing on this host returns what you sent. Query it in the panel or through the [Management API](/en/docs/api/management).
---
# Reference: the management API
> Stable management routes for audiences, campaigns, journeys, reports and messages, with each required permission.
> https://segmentic.net/en/docs/api/management
## The host and the key {#surface}
This API answers on `https://api.segmentic.net` and every route begins with `/v1/`. There is no `/api` segment in the path. If you have seen `/api/v1/events` written somewhere, it is wrong, and it answers `404` with the code `unknown_endpoint`.
The only credential accepted here is `sk_seg_...`. The write key (`wk_seg_...`) that sits inside your app and your site is refused, and the error message says exactly that, because somebody who arrives here with a write key has almost certainly copied the wrong value out of a page, and the word "unauthorized" would send them looking for a typo instead.
This is a separate listener, not a path prefix. The two routes that mint credentials (`POST /v1/team/keys` and `POST /v1/team/invites`) return a plaintext credential in the response body; on a shared mux they were one forgotten guard away from being publicly routable. Here they are not addressable at all. Absence beats a check.
Three things keep a browser out of this door:
- A session cookie is refused. That means this surface has no ambient credential, and every CSRF question follows from having one.
- No preflight responder is registered on this mux. An `OPTIONS` from a browser reaches the catch-all and gets a `404`.
- This API is for server-to-server calls. Do not put an `sk_seg_` key in browser code.
On a local install this API is not served at all until `PUBLIC_API_ADDR` is set; it defaults to the empty string. The first thing a new integration should do is read `GET /v1/status`.
A wrong method on a real path does not get `405`. Because routes are registered with method-prefixed patterns, `PUT /v1/campaigns/5` falls to the catch-all and gets the same `404` with the code `unknown_endpoint`.
### Every route, in one table {#routes}
Twenty-two authenticated routes, plus a status probe and a catch-all. Nothing else exists on this host.
| Method and path | Permission | Cost | Soft lock | Registered when |
|---|---|---|---|---|
| `GET /v1/status` | none | none | no | always |
| `GET /v1/whoami` | none | 1 | no | always |
| `GET /v1/capabilities` | none | 1 | no | always |
| `GET /v1/schema/events` | `event.read` | 5 | no | always |
| `GET /v1/schema/traits` | `event.read` | 5 | no | always |
| `GET /v1/ingest/quality` | `event.read` | 5 | no | always |
| `POST /v1/audiences/validate` | `segment.read` | 1 | no | always |
| `POST /v1/audiences/count` | `segment.read` | 25 | no | always |
| `GET /v1/segments` | `segment.read` | 1 | no | `segments` |
| `GET /v1/segments/{id}` | `segment.read` | 1 | no | `segments` |
| `POST /v1/segments` | `segment.write` | 1 | no | `segments` |
| `PUT /v1/segments/{id}` | `segment.write` | 1 | no | `segments` |
| `DELETE /v1/segments/{id}` | `segment.delete` | 1 | no | `segments` |
| `GET /v1/campaigns` | `campaign.read` | 1 | no | `campaigns` |
| `GET /v1/campaigns/{id}` | `campaign.read` | 5 | no | `campaigns` |
| `POST /v1/campaigns` | `campaign.write` | 1 | no | `campaigns` |
| `PUT /v1/campaigns/{id}/recurrence` | `campaign.send` | 1 | no | `campaigns` and `campaign_recurrence` |
| `DELETE /v1/campaigns/{id}/recurrence` | `campaign.write` | 1 | no | `campaigns` and `campaign_recurrence` |
| `POST /v1/campaigns/{id}/send` | `campaign.send` | 1 | no | `campaigns` |
| `GET /v1/templates` | `template.read` | 1 | no | `templates` |
| `GET /v1/templates/{id}` | `template.read` | 1 | no | `templates` |
| `POST /v1/templates` | `template.write` | 1 | no | `templates` |
| `POST /v1/templates/render` | `template.read` | 1 | no | `templates` |
| `GET /v1/journeys` | `journey.read` | 1 | no | `journeys` |
| `GET /v1/journeys/{id}` | `journey.read` | 5 | no | `journeys` |
| `POST /v1/journeys` | `journey.write` | 1 | no | `journeys` |
| `GET /v1/journeys/{id}/draft` | `journey.read` | 1 | no | `journeys` |
| `POST /v1/journeys/validate` | `journey.read` | 1 | no | `journeys` |
| `POST /v1/journeys/{id}/publish` | `journey.publish` | 1 | no | `journeys` |
| `POST /v1/journeys/{id}/{action}` | `journey.write` | 1 | no | `journeys` |
| `POST /v1/campaigns/{id}/submit` | `campaign.write` | 1 | no | `campaigns` and `campaign_approval` |
| `POST /v1/events` | `profile.write` | 5 | no | `ingest` |
| `GET /v1/exports` | `data.export` | 1 | yes | `async_exports` |
| `POST /v1/exports` | `data.export` | 25 | yes | `async_exports` |
| `POST /v1/reports/funnel` | `analytics.read` | 25 | yes | `analytics` |
| `POST /v1/reports/retention` | `analytics.read` | 25 | yes | `analytics` |
| `POST /v1/messages` | `campaign.send` | 1 | no | `transactional` |
The last column names a key in the `features` map of the [capabilities](/en/docs/api/management#capabilities) response. If that key is `false`, those routes were never registered on this install and answer `404`. The cost column is explained in [the request budget](/en/docs/api/management#budget) and the lock column in [the soft lock](/en/docs/api/management#lock).
---
## Authorisation {#auth}
The credential is read from three places, in this order:
1. The header `Authorization: Bearer ` (the scheme name is case-insensitive)
2. The header `X-Segmentic-Key: `
3. The cookies `__Host-segmentic_session` and then `segmentic_session`
The cookie is last on purpose: a request carrying an explicit `Authorization` header meant to use it, and silently preferring an ambient cookie is how a browser ends up performing an API client's request as the wrong identity. On this surface the cookie path is useless anyway, because a session is refused.
```bash
curl -s https://api.segmentic.net/v1/whoami \
-H "Authorization: Bearer sk_seg_..."
```
There are four `401` answers and each carries its own code, because each sends you somewhere different:
| Code | What happened | Message |
|---|---|---|
| `unauthenticated` | No credential at all, or one that did not resolve (revoked key, unknown key, suspended account) | `a valid API key is required` |
| `api_key_required` | A valid session was offered, as a cookie or as a bearer token | `this API accepts sk_seg_ keys only; session credentials are not valid here` |
| `write_key_rejected` | A token starting with `wk_`, refused before any database lookup | the body below |
| `key_expired` | The key's `expires_at` has passed | `this API key has expired` |
```json
{
"error": {
"code": "write_key_rejected",
"message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
}
}
```
> [!warn]
> There is no `key_revoked` code. A revoked key and a suspended account both answer `unauthenticated`, which is indistinguishable from "you mistyped the key". If a key that worked yesterday answers `unauthenticated` today, check the panel for a revocation first.
A missing permission is a `403` that names the permission. This is deliberate: the alternative is a customer opening a support ticket to learn which permission to grant.
```json
{
"error": {
"code": "forbidden",
"message": "this key does not carry campaign.send, see GET /v1/whoami for what it does carry",
"need": "campaign.send"
}
}
```
Two other things can stop a valid key, and neither speaks this surface's envelope:
- If the account has set an IP allow-list and applied it to API keys, a call from an address outside the list gets `403` with the code `ip_not_allowed`.
- A Segmentic staff credential on this host gets `401` with the code `wrong_surface`.
Both answer in the panel's flat envelope, not this API's. See [the error envelope](/en/docs/api/management#errors).
Keys expire. A key created in the panel lives 365 days unless you give it a number. A key cannot be created with the `owner` role, so no API key ever carries `tenant.transfer` or `tenant.delete`.
---
## GET /v1/whoami {#whoami}
No permission, cost 1. Any valid key gets an answer. This and [capabilities](/en/docs/api/management#capabilities) are the two calls a client should make at start-up: one says what this key can do, the other says what this install has.
```bash
curl -s https://api.segmentic.net/v1/whoami \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"tenant_id": 7,
"api_key_id": 3,
"role": "analyst",
"permissions": [
"analytics.read",
"audit.read",
"campaign.read",
"data.export",
"event.read",
"journey.read",
"member.read",
"profile.read",
"segment.read",
"settings.read",
"template.read"
],
"scoped": false
}
```
| Field | Type | Always present | Meaning |
|---|---|---|---|
| `tenant_id` | number | yes | the account id |
| `api_key_id` | number | yes | this key's id. The name on the wire is `api_key_id`, not `key_id` |
| `role` | string | yes | one of the seven roles |
| `permissions` | array of strings | yes, and empty is `[]` rather than `null` | the effective set, sorted alphabetically |
| `scoped` | boolean | yes | whether the key was narrowed below its role |
`permissions` is the effective set: the role's grants intersected with the key's scopes. A well-written client can fail at start-up rather than failing once a month on the one call that needs the permission it lacks.
There is deliberately no email, no full name and no account list here. A key introspecting itself does not need to know which human created it, and publishing that makes every key a small identity disclosure.
> [!note]
> The remaining budget is not in this response. There is no way to ask how much budget is left. See [the request budget](/en/docs/api/management#budget).
`scoped` is `false` in practice, always. The `scopes` column on a key is read at lookup, but no Go code ever writes it and no API route or panel screen sets it. A narrowed key today is created only by writing to the database directly.
---
## GET /v1/capabilities {#capabilities}
No permission, cost 1. This response says what this install serves today and which ceilings it enforces.
```bash
curl -s https://api.segmentic.net/v1/capabilities \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"version": "v1",
"features": {
"segments": true,
"campaigns": true,
"analytics": true,
"transactional": true,
"export": false,
"import": true,
"journeys": true,
"ingest": true,
"async_exports": true,
"campaign_approval": true
},
"limits": {
"max_page_size": 100,
"max_preview_rows": 100,
"max_batch_size": 500,
"estimate_sample": 100,
"query_timeout_sec": 30
}
}
```
Two Segmentic installs genuinely differ: most capabilities register only when their configuration exists, so a client that assumed the whole surface would be writing against a fiction. Limits are published rather than merely documented, so that no client and no agent hardcodes a number we later change.
### features, key by key {#capabilities-features}
| Key | Which routes it turns on |
|---|---|
| `segments` | the five `/v1/segments` routes |
| `campaigns` | the seven `/v1/campaigns` routes |
| `campaign_recurrence` | `PUT` and `DELETE /v1/campaigns/{id}/recurrence` |
| `analytics` | `POST /v1/reports/funnel` and `POST /v1/reports/retention` |
| `transactional` | `POST /v1/messages` |
| `export` | **nothing on this host.** It is the panel's CSV exporter. Informational only |
| `import` | **nothing on this host.** It is the panel's CSV upload |
| `journeys` | **nothing on this host.** Journeys have no public route |
| `ingest` | `POST /v1/events` |
| `async_exports` | `GET /v1/exports` and `POST /v1/exports` |
| `campaign_approval` | `POST /v1/campaigns/{id}/submit`, and the approval gate on a send |
`ingest` and `import` are the same boolean and can never disagree. They are named separately because a client that conflated them could send a batch at an install that serves only the other one.
### limits, key by key {#capabilities-limits}
| Key | Value | What it actually bounds |
|---|---|---|
| `max_page_size` | 100 | the ceiling on `?limit=` on `GET /v1/exports`, the only route that reads it |
| `max_preview_rows` | 100 | the panel's segment preview. **No route on this host honours it**, because there is no public preview endpoint |
| `max_batch_size` | 500 | the most events in one `POST /v1/events` |
| `estimate_sample` | 100 | the sampling rate of the panel's live counter. **No route on this host uses it** |
| `query_timeout_sec` | 30 | the context deadline on most handlers |
`query_timeout_sec` is not the deadline on the two report routes. Funnel and retention get 45 seconds, and that number is published nowhere.
---
## Permissions {#permissions}
Handlers always ask for a permission, never for a role. "May this request approve a campaign" has one answer, while "is this person an admin" has a different answer in every handler that asks it.
Twenty-eight permissions exist. These are the exact strings on the wire.
| Permission | What it opens on this host |
|---|---|
| `segment.read` | `POST /v1/audiences/validate`, `POST /v1/audiences/count`, `GET /v1/segments`, `GET /v1/segments/{id}` |
| `segment.write` | `POST /v1/segments`, `PUT /v1/segments/{id}` |
| `segment.delete` | `DELETE /v1/segments/{id}` |
| `profile.read` | nothing |
| `profile.write` | `POST /v1/events` |
| `event.read` | `GET /v1/schema/events`, `GET /v1/schema/traits` |
| `campaign.read` | `GET /v1/campaigns`, `GET /v1/campaigns/{id}` |
| `campaign.write` | `POST /v1/campaigns`, `POST /v1/campaigns/{id}/submit`, `DELETE /v1/campaigns/{id}/recurrence` |
| `campaign.send` | `PUT /v1/campaigns/{id}/recurrence`, `POST /v1/campaigns/{id}/send`, `POST /v1/messages` |
| `campaign.approve` | nothing. There is no public approve route |
| `analytics.read` | `POST /v1/reports/funnel`, `POST /v1/reports/retention` |
| `data.export` | `GET /v1/exports`, `POST /v1/exports` |
| `journey.read` | nothing |
| `journey.write` | nothing |
| `journey.publish` | nothing |
| `template.read` | nothing |
| `template.write` | nothing |
| `member.read` | nothing |
| `member.write` | nothing |
| `apikey.read` | nothing |
| `apikey.write` | nothing |
| `settings.read` | nothing |
| `settings.write` | nothing |
| `billing.read` | nothing |
| `billing.write` | nothing |
| `audit.read` | nothing |
| `tenant.transfer` | nothing, and no key can hold it |
| `tenant.delete` | nothing, and no key can hold it |
Eighteen of those twenty-eight open no door on this host. They exist for the panel and the dashboard surface. If your key carries one of them, it has no effect here.
### Roles {#permissions-roles}
Roles are written out row by row rather than derived by inheritance. Inheritance ("admin is viewer plus a few things") turns the interesting question, exactly what can a marketer do, into reading four other definitions and composing them in your head.
| Role | What it can do on this host |
|---|---|
| `owner` | all twenty-two routes. But an API key is never created with this role |
| `admin` | all twenty-two routes |
| `marketer` | all twenty-two routes. It holds all ten permissions this host uses |
| `analyst` | the event and trait schema, validate and count an audience, read segments, read campaigns, reports, exports |
| `viewer` | the same as analyst, minus exports |
| `approver` | the same as viewer. It also holds `campaign.approve`, which opens no door here |
| `finance` | nothing. It holds `billing.read` and `billing.write` and neither opens a door here |
If you are building an AI agent that should only read reports, give it `viewer` rather than `analyst`. `analyst` holds three permissions `viewer` does not: `profile.read`, `data.export` and `audit.read`, which are seeing named people's phone numbers, taking a file out of the building, and reading the audit trail. On this host only `data.export` opens a route. The other two open nothing here, as the permission table above says.
---
## The request budget {#budget}
"Requests per minute" is the wrong unit for a surface where one call reads a struct and the next scans a warehouse. An agent that can make 600 `whoami` calls a minute is harmless; one that can make 600 retention reports is a self-inflicted outage.
So the budget is weighted rather than counted. There are three cost classes:
| Class | Units | Meaning |
|---|---|---|
| trivial | 1 | reads nothing, or reads one row by primary key |
| query | 5 | one bounded warehouse query |
| heavy | 25 | a scan whose cost scales with the account's history |
The default allowance is **600 units per minute**, set by `PUBLIC_API_BUDGET_PER_MINUTE`. That is roughly "two heavy reports a minute, or six hundred cheap ones".
The allowance is **per key**, not per account. Deliberately: a customer issues one narrow key to an agent and keeps their own integration key separate, and a runaway agent must not be able to exhaust the budget their order pipeline depends on.
The window is a **fixed calendar minute**, not a sliding one. The Redis key is built from the account, the key and the minute number, and it lives 70 seconds.
When the budget runs out:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json; charset=utf-8
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
```
The cost is debited **before** the refusal, so a client hammering an exhausted key only inflates that minute's counter. It does not extend the window, and it gains nothing. Because the window is a calendar minute, `Retry-After: 60` is conservative and the budget may return sooner.
If the counter itself is unavailable, the answer is `503`:
```json
{"error":{"code":"budget_unavailable","message":"could not verify the request budget"}}
```
This deliberately fails **closed**, unlike the transactional rate limiter. That one carries login codes, where being late is worse than being loose. This one carries reports and audience queries, where an unmetered agent in a loop is the more expensive failure, and nobody's checkout breaks because a report waited.
> [!warn]
> The consequence: a Redis outage takes the whole API down with `503`, including `GET /v1/whoami` and `GET /v1/capabilities`. The only route that survives is `GET /v1/status`.
> [!note]
> There is no `X-RateLimit-Limit`, `X-RateLimit-Remaining` or `X-RateLimit-Reset` on the budget. A client cannot see what it has left, and `whoami` does not say either. The only rate headers on this host belong to `POST /v1/messages`, and they describe the separate message limiter, not the budget.
A `403` for a missing permission is written before the budget is debited, so a refused call costs nothing.
---
## Pagination {#pagination}
Read this section in full, because what the code does differs from what you expect.
The page envelope looks like this:
```json
{
"data": [],
"has_more": false
}
```
`next_cursor` carries `omitempty`, so the key is absent from the body whenever it is empty, which today is on every response. Nothing in the codebase ever assigns it, so its format is not something you can observe. If it ever arrives, treat it as opaque: a client that parses it is a client we can never change the ordering for.
The `limit` ceiling is 100, the `max_page_size` in capabilities. The default is 25. A larger number is clamped, not rejected. A caller asking for 5,000 wants everything and will loop for it; refusing outright only teaches them to loop with a smaller number, which is what they should have done anyway.
> [!danger]
> **Pagination does not work today.** `?limit=` is read only on `GET /v1/exports`. `?cursor=` is read nowhere. No response ever carries a `next_cursor` key at all, so a client that reads it gets nothing rather than an empty string, and `has_more` is always `false`, even when more rows exist. `GET /v1/segments` and `GET /v1/campaigns` do not use this envelope at all, and they are not unpaginated either: each is hard-capped in SQL at `ORDER BY updated_at DESC LIMIT 200`. The cap is silent. There is no count, no `has_more` and no warning, so an account holding 250 segments receives the 200 most recently updated and has no route on any surface that reaches the other 50.
If a tool tells you these lists are paginated, it is wrong. The server is the only instrument that measures.
---
## The soft lock {#lock}
The soft lock is a commercial gate. A locked account loses the dashboard and the data export and keeps everything else: event collection and campaign sending go on running. A hole in a customer's data cannot be filled in afterwards and a debt can be collected afterwards, so the thing we withhold is the thing we can give back.
It has two triggers:
| `reason` value | When | Meaning |
|---|---|---|
| `overdue_75` | An **issued** invoice is 75 or more whole days past its due date | somebody has to pay an invoice |
| `usage_300` | Usage reached **three times** the plan's included profiles or included events, whichever is higher | the plan has to be upgraded |
An invoice the customer has declared paid, waiting on a bank statement, disarms the trigger. Locking them out during that wait charges them for our own backlog.
Exactly four routes on this host sit behind the lock: `GET /v1/exports`, `POST /v1/exports`, `POST /v1/reports/funnel` and `POST /v1/reports/retention`.
The reason the programmatic API is locked at all is written in the code in as many words: an account seventy-five days past due, holding an API key, could read the funnel and retention numbers it had just been told it could not see, and queue an export. A lock that one credential type honours and another does not is not a lock, it is a detour, and the detour is a script away.
A locked account sees this:
```json
{
"error": {
"code": "account_locked",
"message": "پرداخت این حساب ۷۵ روز از سررسید گذشته است",
"details": { "reason": "overdue_75" }
}
}
```
The status is `403`, not `402`. Payment Required has no agreed meaning in any client, and half of this refusal is not about payment at all: three times the allowance is not a debt.
Three things an integration has to know:
- `account_locked` means "reports and exports are closed", not "the account is off". `POST /v1/events`, `POST /v1/messages` and campaign sending all keep working. Do not shut down the whole connection on this code.
- Branch on `details.reason`, not on the text. The text comes from the catalogue and is Persian.
- The lock verdict is cached for up to a minute. A customer who has just paid their invoice may still be refused for another minute.
The lock fails **open**: any read that cannot answer leaves the account open and logs a warning. And there is no warning before the lock on this surface. Past-due standing, which starts on day 31, is visible only in the panel and is in no response from this API.
---
## GET /v1/status {#status}
Unauthenticated, no cost, no database. The only route that still answers when everything else is answering `503`.
```bash
curl -i https://api.segmentic.net/v1/status
```
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache, must-revalidate
X-Server-Time: 2026-08-07T09:12:41Z
{"status":"ok","service":"api","version":"1.4.2"}
```
Compare `X-Server-Time` with your own clock. If yours is more than an hour ahead, every `timestamp` you send to `POST /v1/events` is silently moved to the receive time. It is not refused, and the warning that says so is discarded on that route, so this comparison is the only way to see it.
---
## The data schema {#schema}
Two routes that say what this account has actually sent. Both need `event.read` and cost 5. Neither reads a parameter.
### GET /v1/schema/events {#schema-events}
```bash
curl -s https://api.segmentic.net/v1/schema/events \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"events": [
{
"name": "order_completed",
"volume": 184203,
"prop_keys": ["revenue", "order_id", "currency"],
"last_seen": "2026-08-06"
}
]
}
```
`events` is always an array and never `null`. `prop_keys` and `last_seen` are omitted when empty.
`last_seen` is the most useful column in this list. An event with a large volume that was last seen three weeks ago is a broken integration, and no other figure here says so. Volume alone looks healthy for a month afterwards, because the window is ninety days.
Failure: `503` with the body `{"error":"schema unavailable"}`. That is the panel's envelope, not this API's. See [the error envelope](/en/docs/api/management#errors).
### GET /v1/schema/traits {#schema-traits}
```bash
curl -s https://api.segmentic.net/v1/schema/traits \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"traits": ["city", "email", "lifetime_value"],
"schema": [
{ "name": "city", "kind": "string", "users": 812043 },
{ "name": "lifetime_value", "kind": "number", "users": 61233 }
]
}
```
`traits` deliberately stays a bare array of names. It is a public endpoint and something out there iterates it as strings; changing the element shape would break that integration on an upgrade with no error anywhere. The richer answer is a second key beside it.
`kind` is either `string` or `number`. A trait sent as both appears once, as `number`: the numeric column is the one that supports a range, and the string one still answers equality.
### The traits you do not have to send {#builtin-traits}
`traits` and `schema` describe what your own events carry. Beside them, `builtin` lists what every account can filter on without sending anything, and `engagement` lists what an engagement condition compares against:
```json
{
"builtin": [
{
"name": "has_push",
"kind": "boolean",
"operators": ["eq", "neq"],
"label": "push capability",
"computed": true,
"description": "Whether this person can actually receive a push notification. A push audience without this condition is mostly people who will never see it."
},
{
"name": "birthday",
"kind": "date",
"operators": ["is_set", "is_not_set"],
"label": "birth date",
"computed": false,
"description": "Presence only. Ask days_until_birthday for the anniversary itself.",
"use": "days_until_birthday"
}
],
"engagement": {
"metrics": [ { "name": "open_rate", "kind": "number", "operators": ["gt", "gte", "lt", "lte", "between"], "label": "open rate", "computed": true } ],
"bands": ["champion", "dormant"]
}
}
```
Read this before inventing a filter out of raw keys. Three of them answer questions people otherwise get wrong:
| Trait | What it answers |
|---|---|
| `has_push` | whether the person can receive a push at all. An audience without it is mostly people who will never see the message |
| `days_until_birthday` | days to the next birthday, `0` today. A stored `birthday` is a date in the past and matches nobody after the first year, which is why `birthday` answers presence only and names this one in `use` |
| `days_since_last_seen` | a dormancy window that moves with the calendar, rather than a timestamp frozen on the day the audience was written |
`operators` is the set that compiles against that trait, so a caller picking from it cannot write a condition the server will refuse. Note that a numeric trait has no `in` or `not_in`: a list value is a list of strings.
`computed` is true for a trait the platform works out for you. `label` and `description` follow the request's language, so send `Accept-Language: fa` to read them in Persian. `description` is present only where the name is not the whole story.
Failure: `503` with the body `{"error":"schema unavailable"}`.
---
### GET /v1/ingest/quality {#ingest-quality}
What we refused from you, and what we corrected, per day.
```bash
curl -s "https://api.segmentic.net/v1/ingest/quality?days=7" -H "Authorization: Bearer sk_seg_..."
```
```json
{
"days": 7,
"from": "2026-08-16",
"to": "2026-08-23",
"totals": { "rejected": 126867, "warned": 4102 },
"rows": [
{
"day": "2026-08-22",
"kind": "reject",
"code": "missing_identity",
"sdk": "segmentic-android",
"app_id": 3,
"count": 126867,
"label": "no user_id or anonymous_id, so we cannot tell whose event it is"
},
{
"day": "2026-08-22",
"kind": "warn",
"code": "generated_message_id",
"field": "message_id",
"sdk": "segmentic-js",
"app_id": 1,
"count": 4102,
"label": "no message_id sent, so a retry of this event cannot be recognised as one"
}
]
}
```
`kind` is `reject` or `warn`, and the difference matters: a refusal lost the event, a warning kept it and changed something about it. `days` defaults to 7 and is capped at 90, which is how long the table keeps rows.
`label` follows the request's language, so send `Accept-Language: fa` to read it in Persian. `code` does not: it is the stable half, and an integration should branch on it rather than on the sentence.
Every column is a count or a folded code. There is no value, no identifier and no error string anywhere in this response: a customer reading their own quality report must not be reading somebody else's phone number. That also means a property key you sent that we could not store appears as `field: "other"` rather than by name.
Failure: `503` with the body `{"error":"schema unavailable"}`.
Not served at all on an install with no warehouse reader. An empty answer here would read as "nothing was ever refused", which is the one wrong thing this endpoint could say.
---
## Audiences without saving {#audiences}
Two routes that work on a filter without storing anything. The saved object is a segment; these are the ad-hoc operations on a definition.
Both take the same body, and their body cap is **1 MiB** rather than the 8 MiB the rest of this surface allows:
```json
{
"definition": {
"version": 1,
"root": {
"kind": "group",
"op": "and",
"children": [
{
"kind": "trait",
"trait": "city",
"operator": "eq",
"value": { "type": "string", "str": "تهران" }
}
]
}
}
}
```
The struct also accepts a `limit` field and neither handler reads it. The full condition language is in [building a segment](/en/docs/segments). The compiler's ceilings: depth at most 8, at most 200 nodes, at most 1,000 values in one list, at most 128 bytes in a key.
### POST /v1/audiences/validate {#audiences-validate}
Permission `segment.read`, cost 1. It does not touch a database.
```bash
curl -s -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":"تهران"}}}}'
```
```json
{
"valid": true,
"description_fa": "کاربرانی که شهرشان تهران است"
}
```
The Persian sentence is here because it is the artefact that catches a misread filter: a caller who sees "کاربرانی که شهرشان تهران است" when they meant Mashhad has found their bug before spending a query.
An invalid filter is `422`:
```json
{
"error": {
"code": "filter_invalid",
"message": "segment: unsupported operator: \"nonsense\""
}
}
```
The panel answers `200` with `valid:false` for the same filter, which is right for a form somebody is typing into and wrong for an integration whose error handling branches on status. Here it is `422`.
Malformed JSON here gets `400` with the body `{"error":"malformed JSON"}`, which is the panel's envelope rather than this API's.
### POST /v1/audiences/count {#audiences-count}
Permission `segment.read`, cost **25**. The count is exact, not sampled.
```bash
curl -s -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":"تهران"}}}}'
```
```json
{
"count": 61432,
"approximate": false,
"description": "کاربرانی که شهرشان تهران است",
"took_ms": 812
}
```
`approximate` is always `false` on this route and `sample_rate` is never set, so it is absent.
> [!warn]
> The key holding the Persian sentence is called `description` here, and `description_fa` on `POST /v1/audiences/validate` and on the segment writes. Two names for one thing. This is a real inconsistency, and a shared function that reads both responses has to look for both keys.
The errors are all in the panel's envelope: a filter that does not compile gets `400` with `{"error":"segment: ..."}` (the same filter that `validate` answered `422` for), and a warehouse failure gets `503` with `{"error":"count unavailable"}`.
---
## Segments {#segments}
A segment is the saved object. It has five routes, all registered only when `features.segments` is on.
### GET /v1/segments {#segments-list}
Permission `segment.read`, cost 1.
```bash
curl -s https://api.segmentic.net/v1/segments \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"segments": [
{
"id": 12,
"name": "تهرانیها",
"kind": "dynamic",
"definition": { "version": 1, "root": { "kind": "trait", "trait": "city", "operator": "eq", "value": { "type": "string", "str": "تهران" } } },
"description_fa": "کاربرانی که شهرشان تهران است",
"last_size": 61432,
"last_computed_at": "2026-08-06T09:00:00Z",
"updated_at": "2026-08-06T09:00:00Z"
}
]
}
```
`kind` is one of `dynamic`, `static` or `realtime`.
`last_size` and `last_computed_at` are on the wire and are not filled in. They were meant to hold the audience size from the last time something counted it, so that a list page did not run two hundred warehouse queries to open. The store has the function that records them and nothing in the shipped product calls it, so `last_size` is `0` on every segment and `last_computed_at` is absent on every segment. The sample above shows the shape, not what you will receive. For a real number, call `POST /v1/audiences/count` with that segment's definition and pay the 25 units.
`segments` is always an array. `?limit=` and `?cursor=` are silently ignored on this route, and the list is capped at the 200 most recently updated segments with nothing in the response saying so. Failure: `503` with `{"error":"segments unavailable"}`.
### GET /v1/segments/{id} {#segments-get}
Permission `segment.read`, cost 1. The response is a bare object of the shape above, not wrapped.
A non-numeric or zero id gets `400` with `{"error":"invalid segment id"}`. An unknown id, or one belonging to another account, gets `404` with `{"error":"segment not found"}`. The store scopes by account, so a guessed id is indistinguishable from a deleted one.
### POST /v1/segments {#segments-create}
Permission `segment.write`, cost 1. Body cap 8 MiB.
```bash
curl -s -X POST https://api.segmentic.net/v1/segments \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"name": "تهرانیها",
"definition": {
"version": 1,
"root": {
"kind": "trait",
"trait": "city",
"operator": "eq",
"value": { "type": "string", "str": "تهران" }
}
}
}'
```
```json
{
"id": 12,
"name": "تهرانیها",
"description_fa": "کاربرانی که شهرشان تهران است"
}
```
Status `201`. There are only two fields: `name`, which must not be blank after trimming, and `definition`, which must pass validation.
| Status | Code | When |
|---|---|---|
| `400` | `malformed_json` | the body is not valid JSON |
| `400` | `name_required` | the name is blank. Message: `a segment needs a name` |
| `422` | `filter_invalid` | the definition did not validate |
| `503` | `segment_unavailable` | the store could not save it |
There is no `kind` field on this struct. Every segment created through this API is `dynamic`. **Creating a static or realtime list through this API is not possible.**
A `tenant_id` in the body is ignored rather than rejected. The account is taken from the key and the body field is never read: a payload naming another account must have no effect at all, which is a property of not reading the field rather than of checking it.
> [!note]
> Unknown fields in a body are accepted and ignored. The only route on this host that rejects an unknown field is `POST /v1/messages`. On everything else, a typo in a field name is invisible.
### PUT /v1/segments/{id} {#segments-update}
Permission `segment.write`, cost 1. The same body as create.
This is a **whole-object replace**, not a merge. There is no `PATCH`, and no `If-Match` or version token of any kind, so two concurrent writers silently clobber each other.
Three behaviours to know:
1. The definition is validated **before** the existence check. An invalid filter on an id that does not exist is still a `422`.
2. The segment is read first. An unknown id, or another account's, is a `404` with the code `not_found`, rather than a write that silently creates a new segment.
3. A blank or omitted `name` **keeps the existing name**. It does not clear it and it does not error.
The response is `200` with the same three keys as create. A non-positive id gets `400` with the code `bad_id` and the message `the path must carry a positive integer id`.
### DELETE /v1/segments/{id} {#segments-delete}
Permission **`segment.delete`**, cost 1. It has its own permission because removing an audience somebody's journey references is not the same act as editing one.
```bash
curl -s -i -X DELETE https://api.segmentic.net/v1/segments/12 \
-H "Authorization: Bearer sk_seg_..."
```
The response is `204` with no body. **There is no `404` for an unknown id**: the delete is called blind and a successful no-op also answers `204`. **There is no `segment_in_use` refusal** either; deleting an audience a scheduled campaign points at is one unremarkable call.
A role without the permission gets `403` with `need: "segment.delete"`. A store failure is `503` with the code `segment_unavailable`.
---
## Campaigns {#campaigns}
Five routes, all registered only when `features.campaigns` is on. Creating and sending are two calls on two permissions, exactly as they are two buttons in the panel. A single "create and send" would collapse the reversible act into the irreversible one.
### GET /v1/campaigns {#campaigns-list}
Permission `campaign.read`, cost 1. Not paginated, and capped at the 200 most recently updated campaigns with nothing in the response saying so.
```json
{
"campaigns": [
{
"id": 5,
"name": "پوش نوروز",
"channel": "push",
"status": "draft",
"estimated": 61432,
"processed": 0,
"sent": 0,
"scheduled_at": "2026-03-20T06:00:00Z",
"updated_at": "2026-08-06T09:00:00Z"
}
]
}
```
The possible statuses are `draft`, `scheduled`, `running`, `paused`, `completed`, `cancelled` and `failed`. Failure: `503` with `{"error":"campaigns unavailable"}`.
### GET /v1/campaigns/{id} {#campaigns-get}
Permission `campaign.read`, cost **5**. This is the campaign report, not just the record.
```json
{
"campaign": {
"id": 5,
"tenant_id": 7,
"name": "پوش نوروز",
"channel": "push",
"template_id": 3,
"segment_id": 12,
"status": "completed",
"goal_event": "order_completed"
},
"progress": {
"campaign_id": 5,
"cursor": "u-98213",
"estimated": 61432,
"processed": 61432,
"sent": 58210,
"suppressed": 1802,
"deferred": 0,
"failed": 1420,
"holdout": 0,
"started_at": "2026-03-20T06:00:00Z",
"updated_at": "2026-03-20T06:41:00Z",
"finished_at": "2026-03-20T06:41:00Z"
},
"percent": 100,
"reach": [
{ "status": "suppressed", "reason": "no_address", "reason_fa": "نشانی ندارد", "count": 1802 }
],
"delivery": [
{ "delivery": "delivered", "delivery_fa": "تحویل شد", "count": 55012 }
],
"engagement": [
{
"channel": "push",
"channel_fa": "اعلان",
"issued": 58210,
"withheld": 0,
"measurable_open": 58210,
"measurable_click": 58210,
"opened": 19204,
"clicked": 4102,
"opened_unmeasurable": 0,
"clicked_unmeasurable": 0
}
],
"engagement_rejects": [],
"uplift": {
"verdict": "too_early",
"verdict_fa": "در حال جمعآوری نتیجه",
"goal": "order_completed",
"treated_users": 0,
"treated_conversions": 0,
"control_users": 0,
"control_conversions": 0,
"contaminated": 0,
"lift": 0,
"lift_low": 0,
"lift_high": 0,
"extra_low": 0,
"extra": 0,
"extra_high": 0,
"median_order": 0,
"currency": "",
"extra_revenue": 0,
"extra_revenue_low": 0,
"extra_revenue_high": 0,
"money_known": false,
"needed_per_arm": 0,
"window_closed_at": "2026-03-27T06:41:00Z",
"computed_at": "0001-01-01T00:00:00Z"
}
}
```
Three things separate this response from a progress bar:
- `reach` answers the question a platform like this is asked constantly and usually cannot answer: the segment said sixty thousand, why did forty-one thousand receive it.
- Every rate in `engagement` ships as a numerator and a named denominator rather than a percentage. A campaign whose message carried no link is not a campaign with a zero click rate, and `measurable_click` is what says so. A single blended percentage is the number a customer cannot reproduce.
- `percent` is always 100 for a finished run and never exceeds 100. The estimate is sampled, so a run can legitimately go past it, and showing 118 per cent reads as a bug.
`uplift` appears as soon as the campaign finishes, not when the attribution window closes. Until a measurement has been stored the section is synthesised, exactly as the sample above shows it: `verdict` is `too_early`, `verdict_fa` and `goal` are filled in, `window_closed_at` is `finished_at` plus seven days, `computed_at` serialises as `0001-01-01T00:00:00Z`, and every numeric field is a literal zero. Do not poll on `window_closed_at`. It is the earliest the answer can arrive and not the date it does: the measurement waits seven days from the last message that actually went out, and a local-time campaign keeps sending for up to a day and a half after the run finishes, so a `too_early` section can still be served after that date has passed. Only `positive`, `negative` and `inconclusive` carry a measured lift. `no_control` and `contaminated` stop the calculation before the two rates are subtracted, so `lift`, `lift_low`, `lift_high` and every `extra` field read zero on those rows as well; a zero there means there is nothing to compare, not that the effect was zero. The counts around it are real on every verdict: the treated and control totals are written before the calculation stops, and on a `contaminated` row the contaminated count is the whole point of the verdict. A deployment that does not run the campaign worker never stores a measurement, so the synthesised section is all it ever serves. `reach`, `delivery`, `engagement` and `uplift` are all best-effort: a warehouse blip costs that section, never the page.
Note that `tenant_id` is on the wire here, unlike on the segment object.
Errors: `400` with `{"error":"invalid campaign id"}` and `404` with `{"error":"campaign not found"}`, both in the panel's envelope.
### POST /v1/campaigns {#campaigns-create}
Permission `campaign.write`, cost 1.
```bash
curl -s -X POST https://api.segmentic.net/v1/campaigns \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"name": "پوش نوروز",
"channel": "push",
"template_id": 3,
"segment_id": 12,
"scheduled_at": "2026-03-20T06:00:00Z",
"control_group_pct": 5,
"goal_event": "order_completed"
}'
```
```json
{ "id": 5, "status": "draft" }
```
Status `201`.
| Field | Type | Required | Rule |
|---|---|---|---|
| `name` | string | no | not validated at all. An empty name is accepted |
| `channel` | string | in effect yes | must be in the list below. An empty string means "the template decides" |
| `template_id` | number | yes | zero produces `campaign: a template is required` |
| `channels` | array | no | **the whole chain in order**, each `{channel, template_id}`, and its first entry must be `channel` itself. See below |
| `segment_id` | number | no | zero means the inline `definition` is the audience |
| `definition` | object | no | **not validated here**, unlike a segment |
| `topic_id` | number | no | zero means no subscription topic |
| `scheduled_at` | RFC3339 | no | an unparseable value is **silently dropped**, not refused |
| `use_local_time` | boolean | no | defaults to `false` |
| `local_hour` | number | no | validated to 0 through 23 only when `use_local_time` is on |
| `throttle_minutes` | number | no | not validated |
| `control_group_pct` | number | no | must be between 0 and 100 |
| `audience_pct` | number | no | the pilot slice. Must be between 0 and 100, and **0 means everybody**, not nobody |
| `goal_event` | string | no | empty means `order_completed` |
`channels` is **the whole chain, not the fallbacks after the first one**. When you send it, its first entry must be exactly what you put in `channel`, or you get a `422`.
The strictness is there because the other reading fails silently. Once `channels` is set the send path reads it alone and never looks at `channel`, so `channel: "sms"` with `channels: [push, inapp]` produces a campaign that sends push and in-app and **never sends an SMS**, with nothing anywhere reporting it.
The campaign tries the first entry, and moves to the next only when that medium could not carry the message: no device, no phone number, that channel switched off for the account. An unsubscribe, a frequency cap, a recall or a holdout stops it there instead, because those are answers about the person rather than about the medium, and walking the chain past one of them is looking for a way around it.
Each entry needs its own `template_id`, written for that channel. One template cannot serve two: an SMS is seventy Persian characters and a push has a title, and sharing one is how an SMS goes out carrying a push body. The same channel may not appear twice, and a campaign with an A/B split may not have a chain at all, because the variant's template would be sent on every channel in it.
There is no "send on all of them" mode on this route. A campaign counts one entry per person in its progress and one row per message in its log, and those two agree only while each person gets one message. Use a journey when a person should get an inbox card and a push.
`audience_pct` sends the campaign to a stable slice of the people its audience
matches, so a real campaign can go to one percent before it goes to all of it.
The same person always lands the same way, so a run that stops halfway through
and resumes does not reshuffle who is in the pilot.
It is not a control group with the numbers turned around. A holdout is withheld
from so the campaign's effect can be measured against it; a pilot is the group
that receives. The two are drawn independently, so a campaign may carry both.
The people outside the pilot are reported in their own counter,
`progress.outside_pilot`, and are not added to `suppressed`: a pilot that did
exactly what it was told must not read as a campaign that governance blocked.
`external_ref` is returned on reads and cannot be set here. It names the
campaign in the system a tenant was migrated from, and operator tooling joins
the two ledgers on it.
Accepted channels: `push`, `webpush`, `sms`, `email`, `inapp`, `messenger`, `webhook`. The aliases `web`, `p`, `s`, `e`, `w` and `i` also resolve. The three messengers `bale`, `eitaa` and `rubika` are accepted and folded into `messenger`, because a marketer cannot know which of three apps each of two million people installed. Anything else is refused rather than defaulted: a wrong channel that reports success is worse than a `400` naming the field.
> [!danger]
> **`webhook` passes this validation and has no sender behind it.** It is in the campaignable list, so the campaign is created, scheduled and run, and there is no code in the delivery layer that handles the channel at all. A campaign authored on `webhook` fails for its entire audience. Do not use it until this page says otherwise.
**The status is forced to `draft` whatever you send.** A `status` key in the body is simply never read.
| Status | Code | When |
|---|---|---|
| `400` | `malformed_json` | the body is not valid JSON |
| `400` | `invalid_channel` | message `unknown channel "bogus"`, with `details` of `{"field":"channel"}` |
| `422` | `campaign_invalid` | the campaign validation message |
| `503` | `campaign_unavailable` | the store could not save it |
### PUT /v1/campaigns/{id}/recurrence {#campaigns-recurrence-set}
Permission `campaign.send`, cost 1. This starts or replaces the automatic repeat schedule for a saved campaign. Each occurrence is a new campaign with the same audience and content.
All calendar fields are read in Tehran time. A monthly day is a Jalali day. For a weekly schedule, Saturday is `0` and Friday is `6`.
```bash
curl -s -X PUT https://api.segmentic.net/v1/campaigns/5/recurrence \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"recurrence": {
"cadence": "weekly",
"hour": 9,
"day_of_week": 0,
"max_occurrences": 4
}
}'
```
`cadence` is `daily`, `weekly`, `monthly` or `yearly`. Use either `hour`, or `hours` with at most six values from `0` to `23`. `day_of_week` is used by weekly schedules. `day_of_month`, from `1` to `31`, is used by monthly and yearly ones, and `month`, from `1` for Farvardin to `12` for Esfand, is required by yearly and ignored by the rest. An optional RFC3339 `ends_at` or positive `max_occurrences` stops the series. If neither is present, it runs until it is cleared.
A yearly schedule is the one to use for a date in the calendar: a professional day, Nowruz, the anniversary of an account opening. Everything here is Jalali and Tehran, so `month: 12, day_of_month: 5` is 5 Esfand every year. A day past the end of a short month lands on that month's last day rather than rolling into the next, so `day_of_month: 30` in Esfand sends on 29 Esfand in a common year: the end of the year is what somebody who typed it meant, and Nowruz is the one day an end-of-year message must not arrive on.
The request cannot set `occurrences`. That counter is reset and maintained by the worker.
```json
{ "status": "ok" }
```
| Status | Code | When |
|---|---|---|
| `400` | `bad_id` or `malformed_json` | the id or body cannot be read |
| `422` | `recurrence_invalid` | cadence, hour, weekday or month day is invalid |
| `503` | `recurrence_unavailable` | the schedule could not be saved |
### DELETE /v1/campaigns/{id}/recurrence {#campaigns-recurrence-clear}
Permission `campaign.write`, cost 1, not `campaign.send`. This stops future automatic repeats. Campaigns already created by the schedule are not changed or deleted.
Starting a schedule needs `campaign.send` because every occurrence is a new campaign that can reach the whole audience. Stopping one only ever reduces what goes out, so it needs no more than the permission to edit the campaign, which is the same split the panel applies to pause and cancel. It used to need `campaign.send` as well, and that had the cost exactly the wrong way round: a key deliberately minted without `campaign.send`, which is what day-to-day work is supposed to use, was the one key that could not stop a schedule creating campaigns every day.
```bash
curl -s -X DELETE https://api.segmentic.net/v1/campaigns/5/recurrence \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"status": "ok",
"note": "campaigns already created by this schedule are unchanged"
}
```
The errors are `400 bad_id` and `503 recurrence_unavailable`.
### POST /v1/campaigns/{id}/send {#campaigns-send}
Permission `campaign.send`, cost 1. No body is read. This is the irreversible call.
```bash
curl -s -X POST https://api.segmentic.net/v1/campaigns/5/send \
-H "Authorization: Bearer sk_seg_..."
```
```json
{ "id": 5, "status": "scheduled" }
```
Status `202`.
If the account requires campaign approval, the approval gate runs first. An API that let an integration skip a review the panel enforces would make the review decorative, and the integration is exactly where somebody would go to get round it.
| Status | Code | When |
|---|---|---|
| `400` | `bad_id` | a non-positive id in the path |
| `409` | `approval_required` | approval is required and there is none, or it was rejected, or it is not yet approved |
| `409` | `approval_stale` | the campaign changed after it was approved. Submit it again |
| `404` | `not_found` | the campaign could not be read |
| `503` | `approval_unavailable` | reading the rule or the approval state failed. Fails **closed** |
| `503` | `campaign_unavailable` | scheduling failed |
`approval_stale` has its own code because the two send an integration to different places: one to "ask for approval", the other to "somebody edited this after it was approved". The fingerprint covers the segment id, the inline definition, the template, the channel, the topic, the goal event, the holdout percentage, the schedule and the sorted variant list, and it is 32 hexadecimal characters.
> [!danger]
> **There is no way to pause, resume or cancel a campaign on this host.** All three exist in the panel and none is registered on this mux. Once your backend schedules a send, only the panel can stop it.
### POST /v1/campaigns/{id}/submit {#campaigns-submit}
Permission `campaign.write`, not `campaign.approve`: the author is asking, not deciding. Cost 1. Registered only when `features.campaign_approval` is on. No body is read.
```json
{
"approval_id": 88,
"state": "pending",
"fingerprint": "3f1a9c02b7de4415aa0e8c1d2f6b3790"
}
```
Status `202`. The `state` values are `pending`, `approved`, `rejected` and `stale`.
| Status | Code | When |
|---|---|---|
| `400` | `bad_id` | a non-positive id |
| `404` | `not_found` | the campaign does not exist |
| `409` | `not_submittable` | the campaign is not `draft` or `paused` |
| `422` | `campaign_invalid` | the campaign did not validate |
| `503` | `approval_unavailable` | the submission could not be recorded |
The request is attributed to **the key's creator**, not to the key. The two-person rule is about people, and attributing a request to "api key 12" would let one person hold both halves by minting a key. If the key's creator has been deleted, the value is zero.
> [!warn]
> **There is no route to approve, to read the approval queue, or to read approval history on this host.** An integration can ask for a review and then has to wait for a human in the panel. The only programmatic way to learn the answer is to retry the send and read the `409` code.
---
## POST /v1/events {#events}
Permission **`profile.write`**, cost 5, body cap 8 MiB. Registered only when `features.ingest` is on.
`profile.write` rather than a new permission, because that is what this does: it writes to people's profiles and their event history, and inventing a second name for the same capability would let somebody grant one believing they withheld the other. The roles that hold it are `owner`, `admin` and `marketer`.
```bash
curl -s -X POST https://api.segmentic.net/v1/events \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"type": "track",
"event": "order_completed",
"user_id": "u-1",
"message_id": "srv-order-8821",
"timestamp": "2026-08-06T09:12:41Z",
"properties": { "revenue": 480000, "order_id": "8821", "currency": "IRR" }
}
]
}'
```
```json
{ "accepted": 1 }
```
The status is **`202`**, not `200`. The events are queued, not stored: they are in the same pipeline an SDK's events go through, and they become queryable seconds later. Saying `200` would invite a caller to read them back immediately and conclude they were lost.
When part of a batch is refused:
```json
{
"accepted": 2,
"rejected": [
{ "index": 1, "reason": "missing_identity" }
]
}
```
`index` is the position in **your** array, so your retry logic can find the item without matching on content. Your events genuinely may not have ids yet, which is half of why they are being rejected.
Every item is validated **here**, before the sink sees it. The sink behind this is the same one the CSV importer uses, and it drops what it cannot normalise, which is right for an importer that pre-validated its own rows and wrong for an endpoint taking arbitrary JSON from the internet. Without this loop a customer would post five hundred events, receive `200`, and find four hundred of them missing a week later with nothing anywhere to explain it.
The order of refusals:
| Status | Code | When |
|---|---|---|
| `400` | `malformed_json` | the body is not valid JSON |
| `400` | `batch_empty` | the `events` array is empty |
| `413` | `batch_too_large` | more than 500 events. `details` is `{"limit":500,"sent":501}` |
| `402` | `quota_cancelled`, `quota_trial_over` or `quota_event_cap` | a commercial ceiling. The whole batch is refused |
| `422` | `all_events_rejected` | no item could be accepted. `details` is the array of rejections |
| `503` | `ingest_unavailable` | the queue was unavailable. Message: `could not queue these events; retry` |
The limit is published in the refusal, so a client sizing its loop does not have to discover it by bisection. Do **not** retry a `402`: nothing changes until somebody pays or the period rolls. The quota check itself fails **open**; a lookup error accepts the batch and logs, because a quota exists to stop a runaway bill and losing a customer's events because Postgres blinked is the larger incident.
### Eight differences from the ingest host {#events-differences}
This route and `POST /v1/batch` on `in.segmentic.net` do not do the same job. If you are migrating history, read the last row.
| | `POST /v1/batch` on the ingest host | `POST /v1/events` here |
|---|---|---|
| Credential | public `wk_seg_...` | secret `sk_seg_...` |
| The array key in the body | `batch` | `events` |
| Success status | `200` | `202` |
| `rejected` | a count, with the array under `errors` | the array itself |
| Warnings | returned, bounded to 50 | **discarded entirely** |
| De-duplication by `message_id` | **yes** | **no.** A retried batch is counted twice here |
| IP and User-Agent | read, and used for geography | **deliberately not set.** This is a server-to-server call, so the address belongs to the customer's data centre, and attributing a recipient's city from it would put every one of their users in one place |
| The past-timestamp window | from that account's own retention policy | **not set, so the 30-day default applies** |
> [!danger]
> **Every `timestamp` older than thirty days is silently moved to exactly thirty-days-ago on this route.** It is not refused and you get no warning. Migrating two years of history through this door stacks all of it on one date, and the first sign is a funnel that makes no sense months later. Use the panel's backfill path for historical loads.
### A note on `reason` {#events-reason}
The stable rejection codes are `unknown_type`, `missing_identity`, `missing_event_name`, `event_name_too_long`, `event_name_invalid_chars`, `id_too_long`, `missing_previous_id` and `timestamp_too_old`.
But the `reason` value in the response is the full error text, and two of them are wrapped with your own value. For example `unknown_type: "not_a_type"`.
So do not match on equality. Match on the prefix, or split on `": "`. The metric on our side is bounded; the value on the wire is not.
---
## Message templates {#templates}
A template is the message text with holes in it. A campaign and a journey send
node both point at one with `template_id` and neither holds a copy of the text,
so the template is the only place the wording changes.
These routes did not exist before, and the transactional reference said so
plainly. Every path that sends anything therefore needed a number that could only
be obtained by a person opening the panel and reading it off a screen.
**There is no delete, and no update by URL.** Deleting a template silently breaks
a live journey that points at it, and the panel has no delete either. Editing is
`POST` with an `id`, exactly as the panel does it: one door, and the same one.
### GET /v1/templates {#templates-list}
`template.read`, cost 1.
```bash
curl -s https://api.segmentic.net/v1/templates \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"templates": [
{ "id": 42, "name": "SMS welcome", "channel": "sms", "category": "marketing",
"title": "", "body": "Hello {{name}}, welcome." }
]
}
```
### GET /v1/templates/{id} {#templates-get}
`template.read`, cost 1. The whole template, plus three things the list does not
carry:
```json
{
"id": 42,
"channel": "sms",
"category": "marketing",
"title": "",
"body": "Hello {{name}}, welcome.",
"variables": ["name"],
"pattern_code": "welcome_v2",
"pattern_approved": true,
"pattern_tokens": { "name": "1" }
}
```
`variables` is the set of holes the template needs, and it is always an array
even when empty: a missing key and an empty array are different answers to "what
does this template need".
`pattern_approved` matters more than it looks. An SMS template bound to an
unapproved pattern is one that will be refused at send time, and finding that out
here costs nothing.
`icon` is returned even though `POST` cannot set it. Hiding it would make a round
trip look lossless when it is not, and a caller that reads a template, edits the
body and posts it back deserves to see the field it is about to drop.
### POST /v1/templates {#templates-create}
`template.write`, cost 1. Body limit 8 MB.
```bash
curl -s -X POST https://api.segmentic.net/v1/templates \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"name": "SMS welcome",
"channel": "sms",
"category": "marketing",
"body": "Hello {{name}}, welcome."
}'
```
```json
{ "id": 42, "name": "SMS welcome", "channel": "sms" }
```
`201` when it creates and `200` when you pass an `id` and it replaces an existing
one. The name is unique per account.
| Status | Code | When |
|---|---|---|
| `400` | `name_required` | the name is empty after trimming |
| `400` | `content_required` | neither a title nor a body |
| `400` | `invalid_channel` | the channel was not recognised |
| `503` | `template_unavailable` | it could not be saved |
### POST /v1/templates/render {#templates-render}
`template.read`, cost 1. Fills a template with the values you pass and shows what
would be sent. **Nothing is sent and nothing is stored.**
Pass an `id` to render a stored template, or the text inline to check a draft you
have not saved.
```bash
curl -s -X POST https://api.segmentic.net/v1/templates/render \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{ "id": 42, "vars": { "name": "Sara" } }'
```
```json
{
"title": "",
"body": "Hello Sara, welcome.",
"missing": [],
"sendable": true,
"variables": ["name"],
"sms": { "encoding": "ucs2", "parts": 1, "remaining": 47 }
}
```
`missing` lists the variables that got no value, and `sendable` says whether it
would go out with the values given. An unfilled variable does not become an empty
string: a message reading "Hello ," went out wrong, and one that was refused did
not go out at all.
`sms` appears only for the SMS channel and is computed from the **stored**
channel rather than from whatever the request said, because a template saved as
sms is an sms, and the number of parts is what somebody is billed for.
## Journeys {#journeys}
A journey is a graph: one trigger, then nodes that send, wait, branch or take
somebody out. It is the same thing the panel builds with a mouse, and both paths
run the same validation.
**Saving and publishing are two acts with two permissions.** Saving a draft
sends nothing to anybody. Publishing puts the graph in front of everyone who
matches its trigger from that moment, and there is no undo: the instances it
enrols are enrolled. That is why fewer roles hold `journey.publish` than hold
`journey.write`.
### GET /v1/journeys {#journeys-list}
`journey.read`, cost 1. The journeys with their status, their published version
and how many people are inside them right now.
```bash
curl -s https://api.segmentic.net/v1/journeys \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"journeys": [
{ "id": 4, "name": "Welcome", "status": "active", "version": 3, "active": 812, "waiting": 40 }
]
}
```
`status` is one of `draft`, `active`, `paused`, `archived`. `active` counts
instances in flight and `waiting` counts those parked on a wait node.
### GET /v1/journeys/{id} {#journeys-get}
`journey.read`, cost 5. Returns the **published** version, plus a counter per
node.
```json
{
"graph": { "journey_id": 4, "version": 3, "entry_id": "n1", "nodes": [] },
"stats": { "n1": { "entered": 900, "exited": 860, "suppressed": 12, "waiting": 28 } }
}
```
A journey with no published version answers `404` with `not_found`, and so does
an id that does not exist. That is deliberate: telling the two apart is telling
a caller which ids are real.
The counters are best effort. If the warehouse does not answer, `stats` comes
back empty and `graph` is still there, because the graph is what you asked for.
### POST /v1/journeys {#journeys-save}
`journey.write`, cost 1. Saves a draft. **Nothing is published.**
```bash
curl -s -X POST https://api.segmentic.net/v1/journeys \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome",
"graph": {
"entry_id": "n1",
"nodes": [
{ "id": "n1", "kind": "trigger", "trigger": { "event": "signed_up" }, "next": "n2" },
{ "id": "n2", "kind": "send", "send": { "channel": "sms", "template_id": 42 } }
]
}
}'
```
```json
{ "id": 4, "name": "Welcome", "live_version": 0, "status": "draft", "problems": [] }
```
Leave `id` out to create one, or pass an existing journey's id to replace its
draft. It **replaces rather than merges**: send the whole graph.
`live_version` is the version running now, not the one you just saved. Saving a
draft does not move that number, and zero means nothing has ever been published.
Both are returned so that saving cannot be mistaken for publishing.
A half-built graph is **stored**, exactly as the panel stores it, because
building a journey takes more than one sitting. Instead of a refusal you get the
reasons in `problems` on the same response: empty means publishable, and anything
in it is a reason the publish call would refuse. That refusal happens where it
belongs.
| Status | Code | When |
|---|---|---|
| `400` | `name_required` | no `name` |
| `400` | `graph_required` | no `graph` |
| `409` | `name_taken` | this account already has a journey with that name |
| `503` | `journey_unavailable` | the store could not save it |
Journey names are unique per account. A create that collides answers `409` and
names the journey, so the recovery is to pass that journey's id and replace its
draft, or to choose another name. Until this was separated out it answered
`503`, which reads as an outage: a client retrying on that gets the same answer
for ever, while the journey it wanted has existed the whole time.
### GET /v1/journeys/{id}/draft {#journeys-draft}
`journey.read`, cost 1. The saved draft plus two lists:
```json
{
"name": "Welcome",
"graph": { "entry_id": "n1", "nodes": [] },
"problems": [],
"warnings": ["this journey shares an audience with Win-back"]
}
```
The difference between `problems` and `warnings` matters: the first stops a
publish and the second does not. The overlap warning is computed against the
account's other live journeys, and it is the same one the panel shows.
### POST /v1/journeys/validate {#journeys-validate}
`journey.read`, cost 1. Checks a graph you have not saved. Nothing is stored and
nothing changes.
```json
{ "valid": false, "problems": ["node n2 has no template"], "warnings": [] }
```
**Always `200`, even when the graph is invalid.** The request succeeded; the
graph is the thing with a verdict. A `422` here would make "this graph is not
acceptable" indistinguishable from "the server refused my request".
### POST /v1/journeys/{id}/publish {#journeys-publish}
`journey.publish`, cost 1. Publishes a new version and returns its number.
```json
{ "version": 4, "status": "active" }
```
> This cannot be undone. From that moment anybody who matches the trigger enters
> the journey. Check the graph with `POST /v1/journeys/validate` first.
If the draft is not ready you get `422` with `not_publishable` and the same
`details.problems` list, because that is not an outage, it is the graph.
### POST /v1/journeys/{id}/{action} {#journeys-action}
`journey.write`, cost 1. Three actions, and no others:
| Action | Status after | What it means |
|---|---|---|
| `pause` | `paused` | new entries stop. Everybody already inside stays where they are |
| `resume` | `active` | entries start again |
| `archive` | `archived` | it leaves the day-to-day list |
```json
{ "status": "paused" }
```
Anything else answers `400` with `unknown_action`, and the allowed list arrives
in `details.allowed`, because "unknown action" says you were wrong without
saying what to do instead.
## Exports {#exports}
Two routes, both needing `data.export` and both **behind the soft lock**. `data.export` is separate from every read permission because an export walks out of the building.
### GET /v1/exports {#exports-list}
Cost 1. The only route on this host that uses the page envelope, and the only one that reads `?limit=`. `?cursor=` is read and discarded.
```bash
curl -s "https://api.segmentic.net/v1/exports?limit=50" \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"data": [
{
"id": 42,
"kind": "events",
"format": "ndjson",
"spec": { "segment_id": 12 },
"status": "queued",
"rows_written": 0,
"bytes": 0,
"attempts": 1,
"expires_at": "2026-08-13T09:00:00Z",
"requested_by": "api-key:3",
"created_at": "2026-08-06T09:00:00Z"
}
],
"has_more": false
}
```
`status` is one of `queued`, `running`, `ready`, `failed` or `expired`. `attempts` is published because "it failed" and "it failed three times and stopped" are different answers to the only question a customer asks about an export.
`has_more` is always `false`, even when more rows exist. `data` is always an array.
Failure: `503` with the code `export_unavailable` and the message `could not read the export list`.
> [!danger]
> **`GET /v1/exports/{id}` and a download route do not exist on this host.** An integration can queue an export and list it, and then a human has to collect the file from the panel. `location` is on the wire but it is a storage path, not a signed URL.
### POST /v1/exports {#exports-create}
Cost **25**.
```bash
curl -s -X POST https://api.segmentic.net/v1/exports \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"kind": "events",
"format": "ndjson",
"spec": { "segment_id": 12, "from": "2026-07-01", "to": "2026-08-01" }
}'
```
```json
{
"id": 42,
"status": "queued",
"kind": "events",
"expires_after_hours": 168
}
```
Status `202`, because the file does not exist yet. A customer asking for ninety days of events is asking for something that takes minutes, and a request holding a connection open for minutes dies to a load balancer's timeout.
| Field | Required | Rule |
|---|---|---|
| `kind` | yes | one of `events`, `messages`, `profiles`, `segment` |
| `format` | no | **anything that is not exactly `csv` becomes `ndjson`**, including a typo and including unknown formats |
| `spec` | no | passed through untouched and **not validated** |
NDJSON is the default because an export of events with nested properties is not a rectangle, and flattening it into CSV silently loses the nesting.
> [!warn]
> The shape of `spec` differs per `kind`, the code validates nothing inside it, and no document today enumerates its permitted keys per kind. So a wrong `spec` does not produce an error; it produces a file that is not what you expected. Until that is documented, queue a small export first and look at the file.
`expires_after_hours` is 168, which is seven days. A file containing every customer's email address sitting on a share for ever is what turns one careless export into a breach, and nobody remembers to delete it, so the platform does.
`requested_by` is recorded as `api-key:3`. "Who exported every customer's address" is a question an audit asks afterwards, and the answer has to name something revocable.
| Status | Code | When |
|---|---|---|
| `400` | `malformed_json` | the body is not valid JSON |
| `422` | `export_kind_invalid` | message: `kind must be one of events, messages, profiles, segment` |
| `503` | `export_unavailable` | the queue could not accept it |
---
## Reports {#reports}
Two routes, both needing `analytics.read`, both costing **25**, both **behind the soft lock**. Body cap 1 MiB and a deadline of **45 seconds**, not `query_timeout_sec`.
Errors on these two routes are in the panel's envelope and in Persian, with the code `invalid_report`. See [the error envelope](/en/docs/api/management#errors).
### POST /v1/reports/funnel {#reports-funnel}
```bash
curl -s -X POST https://api.segmentic.net/v1/reports/funnel \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"steps": [
{ "name": "product_viewed" },
{ "name": "checkout_started" },
{ "name": "order_completed", "filters": [{ "prop": "revenue", "op": "gte", "value": "500000" }] }
],
"range": { "from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z" },
"window": "7d",
"split_by": "city"
}'
```
```json
{
"steps": [
{ "index": 0, "name": "product_viewed", "label": "product_viewed", "users": 700, "from_start": 1, "from_previous": 1, "dropped_here": 0 },
{ "index": 1, "name": "checkout_started", "label": "checkout_started", "users": 300, "from_start": 0.4286, "from_previous": 0.4286, "dropped_here": 400 }
],
"buckets": [
{ "value": "تهران", "steps": [], "entered": 400, "completed": 180, "conversion": 0.45 }
],
"entered": 700,
"completed": 300,
"conversion": 0.4286,
"description": "..."
}
```
| Field | Required | Rule |
|---|---|---|
| `steps` | yes | between 2 and 12 steps |
| `steps[].name` | yes | the event name, non-blank, at most 256 characters |
| `steps[].label` | no | the chart label |
| `steps[].filters` | no | at most 10 filters per step |
| `steps[].filters[].op` | yes | text: `eq`, `ne`, `contains`, `prefix`. Numeric: `gt`, `gte`, `lt`, `lte`, `num_eq`, `num_ne` |
| `range.from` and `range.to` | yes | RFC3339, `from` before `to`, span at most 730 days |
| `window` | **yes, and it must be greater than zero** | like `"7d"`, `"1.5d"`, `"36h"`. It must not exceed the range |
| `strict` | no | defaults to `false` |
| `split_by` | no | from the list below, or `prop:` |
Do not forget `window`. A checkout funnel measured over thirty days and the same funnel measured over one hour are different questions, and the answer is meaningless without it. Omit it and you get a `400`.
The `split_by` allow-list: `platform`, `os`, `device`, `app_version`, `country`, `city`, `region`, `province`, `utm_source`, `utm_campaign`, `browser`. Plus the `prop:` form for any event property key, such as `prop:category`.
Rates are fractions, not percentages. `buckets` appears only when a breakdown was requested.
### POST /v1/reports/retention {#reports-retention}
```bash
curl -s -X POST https://api.segmentic.net/v1/reports/retention \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"start": { "name": "signed_up" },
"return": { "name": "order_completed" },
"range": { "from": "2026-01-01T00:00:00Z", "to": "2026-07-01T00:00:00Z" },
"granularity": "week",
"periods": 12
}'
```
```json
{
"granularity": "week",
"period_label": "هفته",
"cohorts": [
{
"cohort": "1405-02-11",
"label": "...",
"size": 4021,
"cells": [
{ "period": 0, "users": 4021, "rate": 1, "observable": true },
{ "period": 1, "users": 1802, "rate": 0.448, "observable": true },
{ "period": 8, "users": 0, "rate": 0, "observable": false }
]
}
],
"average": [{ "period": 0, "users": 0, "rate": 1, "observable": true }],
"description": "..."
}
```
| Field | Required | Default | Rule |
|---|---|---|---|
| `start` | no | empty | an empty name means "any activity" |
| `return` | no | empty | the same |
| `range` | yes | none | span at most 730 days |
| `granularity` | no | `day` | one of `day`, `week`, `month` |
| `periods` | no | 30 | at most 60 |
The two steps are separate because "came back" rarely means "did the same thing again". A shopping app cares who signed up and then **bought**; asking whether they opened the app again flatters the number and answers nothing.
`observable: false` means the report has not run long enough to know yet. A cohort that started yesterday has no day-30 number, and rendering that as zero is how a healthy product looks like it is dying.
Cohort boundaries are computed in Go, in Tehran, on the Iranian calendar. ClickHouse's calendar functions are not correct for this market: `toStartOfMonth` is Gregorian and `toStartOfWeek` cannot start on Saturday.
The `average` curve is weighted, total returners over total starters, not the mean of the percentages. A mean of percentages would let a cohort of four people who all came back pull the curve up as hard as one of forty thousand.
---
## POST /v1/messages {#messages}
Permission `campaign.send`, cost 1. Registered only when `features.transactional` is on. Body cap **256 KiB**.
Its cost is trivial in query terms and enormous in consequence. The thing meant to bound it is not the request budget but the per-account message limiter, and that limiter is **off by default**. On an install where nobody has set it, the only thing holding this route back is the 600-unit budget: a key with `campaign.send` can send six hundred messages a minute. If that is too many for you, set the account's limit. See [two limiters on one route](/en/docs/api/management#messages-limits).
```bash
curl -s -X POST https://api.segmentic.net/v1/messages \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_9137",
"channel": "sms",
"category": "transactional",
"template_id": 42,
"vars": { "code": "8391" },
"idempotency_key": "order-8821-shipped"
}'
```
```json
{
"message_id": "t7.order-8821-shipped",
"status": "sent",
"sent_at": "2026-08-06T09:12:41Z"
}
```
The status is `200` for both a fresh send and a replay.
| Field | Required | Rule |
|---|---|---|
| `user_id` | yes | non-empty |
| `channel` | yes | one of `push`, `sms`, `email`, `webpush`, `inapp`, `bale`, `eitaa`, `rubika` |
| `category` | no | `transactional` (the default) or `critical`. `marketing` is **refused** |
| `template_id` | yes | non-zero. **An inline message body is not accepted at all** |
| `vars` | no | at most 40 keys |
| `idempotency_key` | yes | the pattern `^[A-Za-z0-9._:-]{8,200}$` |
> [!warn]
> `web` does **not** work here, even though it works when creating a campaign and some tools advertise it. This route compares the raw string against the list above and the alias table is not on this path. Write `webpush`. `messenger` and `webhook` are also refused here; they are campaign-only.
**This is the only route on this host that rejects unknown fields.** A caller who mistyped `idempotency_key` would otherwise get a brand new key on every retry and send a message per attempt, which is the exact failure this endpoint is built to prevent, arriving through a typo.
The refusal of `marketing` is load-bearing. This route bypasses frequency caps and quiet hours, so accepting a marketing message here would hand every customer a documented way around their own sending rules, and the first time it mattered would be a 3am promotional SMS to a whole list.
### The idempotency key {#messages-idempotency}
`idempotency_key` may arrive in the body or as the header `Idempotency-Key`. If both are present the body wins. Case is preserved: folding it would merge `Order-8821` with `order-8821`, two keys a strict caller may well be using for two different things.
`message_id` is derived rather than generated: `t`, the account id, a full stop, then your key.
| Case | Answer |
|---|---|
| The first call | `200` with the result |
| A repeat **after** the first finished | `200` with the stored body, plus `"replayed": true` |
| A repeat **while** the first is still running | `409` with `Retry-After: 1` and the body `{"error":"transactional: a message with this idempotency key is already in flight"}` |
| A repeat after the first **failed** to dispatch | allowed to run. The reservation was released |
`replayed` lets a caller tell "we already did this" apart from "we just did this", which matters when the first attempt timed out and they do not know which happened.
> [!danger]
> **Idempotency keys are never swept.** A configuration value called a seven-day retention exists, no code reads it, and no command calls the sweep. Two practical consequences: a key such as `order-8821-shipped` reused a year later replays the year-old result rather than sending anything, and a reservation abandoned by a crashed process is never cleared, so every retry of that key answers `409` for ever until somebody deletes the row by hand. Choose keys that are unique and stay unique.
### Two limiters on one route {#messages-limits}
This route is metered twice and has two different refusals with two different bodies:
- The request budget, one unit, per key. Refusal: `429` with the code `budget_exhausted` in this API's envelope.
- The message rate limiter, one request, per account per calendar minute. Refusal: `429` with `Retry-After: 60` and the flat body `{"error":"rate limit exceeded: N requests per minute"}`.
The second limit comes from that account's `api_rate_per_minute` column, falling back to the server configuration, whose **default is zero**. Zero disables this limiter entirely, which is right for a single-tenant install, and on such an install the rate headers are not sent either. This one fails **open**: a lookup error allows the send and logs a warning.
When a limit is configured, `X-RateLimit-Limit` and `X-RateLimit-Remaining` are set on **every** response from this route, not only on refusals. A caller that cannot see how close it is has no way to slow down before being refused.
### Errors {#messages-errors}
All of them in the flat envelope, not this API's:
| Status | Body |
|---|---|
| `400` | `{"error":"malformed JSON: "}` for bad JSON or an unknown field |
| `400` | `{"error":"transactional: user_id is required"}` and its siblings |
| `409` | `{"error":"transactional: a message with this idempotency key is already in flight"}` |
| `429` | `{"error":"rate limit exceeded: N requests per minute"}` |
| `503` | `{"error":"message not sent"}` |
| `200` | the result, even when the ledger write failed after sending. The message really did go, and a caller told otherwise would send a second one |
The exact caller-error strings: `transactional: user_id is required`, `transactional: template_id is required`, `transactional: idempotency_key is required`, `transactional: unknown channel`, `transactional: this endpoint does not send marketing; use a campaign`, `transactional: too many variables`, and the malformed-key message, which names the range of 8 to 200 characters.
**There is no route to read a message's status.** `GET /v1/messages/{idempotency_key}` does not exist. The only way to see the outcome of a send is the response to that call, or the message log in the panel.
---
## The error envelope {#errors}
The envelope looks like this:
```json
{
"error": {
"code": "forbidden",
"message": "this key does not carry data.export, see GET /v1/whoami for what it does carry",
"details": { "limit": 500, "sent": 501 },
"need": "data.export"
}
}
```
`code` is stable and machine-readable. **It is the contract; `message` is not.** An integration that branches on message text will break the first time we improve the wording. `details` is present when the problem belongs to a particular field, and `need` appears only on a `403`.
A path that does not exist gets this:
```json
{
"error": {
"code": "unknown_endpoint",
"message": "no such endpoint: POST /v1/team/keys, see GET /v1/capabilities"
}
}
```
### The envelope is not one shape {#errors-not-uniform}
This is the most important paragraph on this page for anybody writing error handling.
Eleven of the twenty-two routes share a handler with the panel, and those handlers answer in the panel's envelope: `{"error":""}` or `{"error":"","code":"..."}`. So `error` is sometimes an object and sometimes a string.
| Route | Which failure | The actual body |
|---|---|---|
| `GET /v1/schema/events` | `503` | `{"error":"schema unavailable"}` |
| `GET /v1/schema/traits` | `503` | `{"error":"schema unavailable"}` |
| `POST /v1/audiences/validate` | `400` bad JSON | `{"error":"malformed JSON"}` |
| `POST /v1/audiences/count` | `400` and `503` | `{"error":"segment: ..."}` and `{"error":"count unavailable"}` |
| `GET /v1/segments` | `503` | `{"error":"segments unavailable"}` |
| `GET /v1/segments/{id}` | `400` and `404` | `{"error":"invalid segment id"}` and `{"error":"segment not found"}` |
| `GET /v1/campaigns` | `503` | `{"error":"campaigns unavailable"}` |
| `GET /v1/campaigns/{id}` | `400` and `404` | `{"error":"invalid campaign id"}` and `{"error":"campaign not found"}` |
| `POST /v1/reports/funnel` | `400` and `503` | `{"error":"","code":"invalid_report"}` and `{"error":""}` |
| `POST /v1/reports/retention` | `400` and `503` | the same |
| `POST /v1/messages` | every failure | a flat `{"error":""}` |
There are also four refusals produced before the handler runs, all in the flat envelope: `wrong_surface` on a `401`, and `ip_not_allowed`, `impersonation_read_only` and `impersonation_forbidden` on a `403`.
So parse defensively. Check the type of `error` first: if it is an object, read `error.code`; if it is a string, log it as a message and decide on the status.
> [!warn]
> **The text of these errors is always Persian.** The language middleware is not installed on this listener, so `Accept-Language` is never read on this host. Sending `Accept-Language: en` has no effect. A client that logs error text has to tolerate UTF-8 and right-to-left script.
### Every code {#errors-codes}
| Code | Status | Meaning |
|---|---|---|
| `unauthenticated` | `401` | no credential, or one that did not resolve |
| `api_key_required` | `401` | a session was offered |
| `write_key_rejected` | `401` | a `wk_` token was offered |
| `key_expired` | `401` | the key has expired |
| `forbidden` | `403` | a permission is missing. Read `need` |
| `account_locked` | `403` | the soft lock. Read `details.reason` |
| `budget_exhausted` | `429` | the minute's budget is spent. Wait, then retry |
| `budget_unavailable` | `503` | the budget counter is unreachable. Transient |
| `unknown_endpoint` | `404` | wrong path or wrong method |
| `malformed_json` | `400` | the body is not JSON |
| `bad_id` | `400` | the path id is not a positive integer |
| `not_found` | `404` | the segment or campaign does not exist |
| `filter_invalid` | `422` | the segment definition was refused |
| `name_required` | `400` | the segment name is blank |
| `segment_unavailable` | `503` | the segment store failed |
| `invalid_channel` | `400` | the campaign channel did not resolve |
| `campaign_invalid` | `422` | campaign validation failed |
| `campaign_unavailable` | `503` | the campaign store failed |
| `not_submittable` | `409` | the campaign is not `draft` or `paused` |
| `approval_required` | `409` | approval is required and there is none |
| `approval_stale` | `409` | the campaign changed after it was approved |
| `approval_unavailable` | `503` | reading or writing an approval failed |
| `export_kind_invalid` | `422` | `kind` is not in the list |
| `export_unavailable` | `503` | the export queue failed |
| `batch_empty` | `400` | the `events` array is empty |
| `batch_too_large` | `413` | more than 500 events |
| `all_events_rejected` | `422` | no event could be accepted |
| `ingest_unavailable` | `503` | the ingest queue is unavailable |
| `quota_cancelled` | `402` | the subscription is not serving |
| `quota_trial_over` | `402` | the trial has ended |
| `quota_event_cap` | `402` | a hard event ceiling |
| `quota_message_cap` | `402` | declared and never returned. `POST /v1/events` is the only 402 here and it asks the quota check with the event meter; the message ceiling is only read for a message meter |
Do **not** retry the `quota_*` family: nothing changes until somebody pays or the period rolls. The [error codes](/en/docs/errors) page carries this same list with a suggested action for each.
---
## What this API does not do {#absent}
Every line here is something a reasonable person expects and that does not exist today. Writing it down honestly is cheaper than a plausible sentence.
- **Working pagination.** `?cursor=` is read nowhere and no response ever carries a `next_cursor` key.
- **`PATCH` on anything.** Only `PUT` with a whole-object replace, with no `If-Match` and no version token.
- **Idempotency on any route other than `POST /v1/messages`.** A retried timeout on `POST /v1/segments` creates a second segment.
- **Pausing, resuming or cancelling a campaign.**
- **Approving a campaign, the approval queue, approval history.**
- **Downloading an export, and `GET /v1/exports/{id}`.**
- **Reading the status of a transactional message.**
- **Reading or writing one person's profile.** `GET /v1/profiles/{user_id}` does not exist.
- **Consent and unsubscribe through the API.**
- **Templates, journeys, the audit log and the sending rules.** All panel only.
- **Segment preview, segment size and the sampled estimate.**
- **The paths report.** `POST /v1/reports/paths` is not registered on this host.
- **Creating a scoped key.** The column exists in the database and there is no way to write it.
- **Per-key recipient budgets and PII row caps.** Their columns exist in the database and no code reads them.
- **A remaining-budget header.**
- **Any warning before the soft lock.** Past-due standing from day 31 is not visible on this surface.
- **A browser preflight.** This API is for server-to-server calls.
If you need one of these, the panel is the way today. To know what can change without notice and what cannot, read [API versioning and changes](/en/docs/versioning).
---
# Error codes and what to do about them
> Every code that can come back, what it means, and whether to retry, fix something, or wait.
> https://segmentic.net/en/docs/errors
A failure does not have one shape on Segmentic. The ingest host has one shape, the management host has another, and a third leaks through on eleven management routes. A client written against a single shape reads `undefined` on the other two, and the branch it takes next is the branch nobody tested.
This page lists every code a customer-facing surface can return, grouped by what you are supposed to do about it.
## Three envelopes, not one {#envelopes}
Parse defensively. If `error` is an object, read `error.code`. If `error` is a string, there is no code and the HTTP status is all you have. If there is no `error` key at all, you are on the ingest host and the field you want is `status`.
### The ingest host {#ingest-envelope}
`https://in.segmentic.net` answers a flat object. There is no `code` field anywhere on this host. The machine-readable value is `status`, and it is only ever the literal `"ok"` or `"error"`.
```json title="POST /v1/track, 400"
{"status":"error","message":"malformed JSON"}
```
A success carries counts. `accepted` and `rejected` are omitted when they are zero rather than sent as `0`, so a batch refused whole has no `accepted` key at all.
```json title="POST /v1/track, 200"
{
"status": "ok",
"accepted": 1,
"warnings": [
{
"code": "generated_message_id",
"field": "message_id",
"note": "no message_id sent; retries of this event cannot be de-duplicated"
}
]
}
```
A batch reports per-item failures by index into the array you sent, so your retry logic can find the item without matching on its content. The events you sent may have no ids yet, which is half of why they were rejected.
```json title="POST /v1/batch, 200"
{
"status": "ok",
"accepted": 2,
"rejected": 1,
"errors": [{"index": 1, "reason": "unknown_type: \"trak\""}]
}
```
The `reason` string is the full wrapped error text and it includes your own offending value. The stable part is the leading sentinel word, `unknown_type` here. Match on the prefix, not on the whole string.
Content type is `application/json; charset=utf-8` on every response from this host.
Three ingest routes do not use this envelope at all. `POST /v1/bounce/{local}` answers plain text, not JSON. `POST /v1/inbox` and `POST /v1/devices` answer their own shapes, documented on [devices and push](/en/docs/devices) and [on-site and inbox](/en/docs/onsite). `GET /s/{code}` answers a plain-text 404 for an unknown code and a 302 otherwise.
### The management host {#management-envelope}
`https://api.segmentic.net` nests everything under `error`.
```json title="POST /v1/reports/funnel, 429"
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
```
`details` carries the structured part of a validation failure, so you can point at the offending part of your own payload.
```json title="POST /v1/events, 413"
{
"error": {
"code": "batch_too_large",
"message": "a batch may carry at most 500 events",
"details": {"limit": 500, "sent": 501}
}
}
```
`need` appears on a 403 and names the exact permission the key is missing. It is published deliberately: the alternative is opening a support ticket to find out which permission to grant.
```json title="POST /v1/campaigns/41/send, 403"
{
"error": {
"code": "forbidden",
"message": "this key does not carry campaign.send, see GET /v1/whoami for what it does carry",
"need": "campaign.send"
}
}
```
On `POST /v1/events`, `details` is the per-item array instead of an object.
```json title="POST /v1/events, 422"
{
"error": {
"code": "all_events_rejected",
"message": "no event in this batch could be accepted",
"details": [
{"index": 0, "reason": "missing_identity"},
{"index": 1, "reason": "missing_identity"}
]
}
}
```
### The flat shape, on eleven routes {#flat-envelope}
Eleven management routes share their handler with the panel, and the panel's error writer emits a flat string with no code at all.
```json title="GET /v1/segments/7, 404"
{"error":"segment not found"}
```
A client that only reads `body.error.code` gets `undefined` on every one of these. The routes, and the failures that take this shape:
| Route | Failures in the flat shape |
|---|---|
| `GET /v1/schema/events` | 503 `schema unavailable` |
| `GET /v1/schema/traits` | 503 `schema unavailable` |
| `POST /v1/audiences/validate` | 400 `malformed JSON` |
| `POST /v1/audiences/count` | 400 `malformed JSON`, 400 the compiler's own text, 503 `count unavailable` |
| `GET /v1/segments` | 503 `segments unavailable` |
| `GET /v1/segments/{id}` | 400 `invalid segment id`, 404 `segment not found` |
| `GET /v1/campaigns` | 503 `campaigns unavailable` |
| `GET /v1/campaigns/{id}` | 400 `invalid campaign id`, 404 `campaign not found` |
| `POST /v1/reports/funnel` | 400 `invalid_report`, 503 Persian prose |
| `POST /v1/reports/retention` | 400 `invalid_report`, 503 Persian prose |
| `POST /v1/messages` | 400, 409, 429 and 503, all of them |
`invalid_report` is the one exception inside the exception: it is flat but it does carry a code.
```json title="POST /v1/reports/funnel, 400"
{"error":"قیف به دستکم دو مرحله نیاز دارد","code":"invalid_report"}
```
Four refusals from the shared credential guard also reach the management host in the flat shape, with a code and Persian text: `wrong_surface` (401), `impersonation_read_only` (403), `impersonation_forbidden` (403) and `ip_not_allowed` (403). The last is reachable by an ordinary API key, because the account's network allow-list applies to every credential and not only to browser sessions.
> [!warn]
> Neither the ingest host nor the management host honours `Accept-Language`. The language middleware is applied to the panel's own mux only. Every message that comes from the translation catalogue reaches you in Persian regardless of what you ask for. That covers all four `quota_*` messages and `account_locked`.
## Branch on the code, never on the message {#code-not-message}
The code is the contract. The message is not, and it changes whenever the wording improves.
Two messages make this concrete. The `write_key_rejected` message carries the single ellipsis character U+2026 twice, inside `(wk_…)` and `(sk_seg_…)`, not three full stops. The transactional key-format message carries an en dash U+2013 between the 8 and the 200, not a hyphen. An integration matching on either string breaks on a character its author never typed.
## Fix the request {#fix}
Retrying any of these sends the identical payload to the identical refusal. The nested envelope, on the management host.
| Code | HTTP | Endpoints | What it means |
|---|---|---|---|
| `malformed_json` | 400 | any route with a body | The body did not parse, or it was over `8 MiB` |
| `batch_empty` | 400 | `POST /v1/events` | `events` was absent or empty |
| `bad_id` | 400 | `PUT /v1/segments/{id}`, `DELETE /v1/segments/{id}`, `POST /v1/campaigns/{id}/send`, `POST /v1/campaigns/{id}/submit` | The path did not carry a positive integer id |
| `name_required` | 400 | `POST /v1/segments` | `name` was empty or whitespace |
| `invalid_channel` | 400 | `POST /v1/campaigns` | The channel is not one this account can author on. `details` is `{"field":"channel"}` |
| `unknown_endpoint` | 404 | the catch-all | The path is not registered on this host. The message names the method and path and points at `GET /v1/capabilities` |
| `not_found` | 404 | `PUT /v1/segments/{id}`, `POST /v1/campaigns/{id}/send`, `POST /v1/campaigns/{id}/submit` | The id is unknown, or it belongs to another account. The two cases are deliberately not distinguished |
| `batch_too_large` | 413 | `POST /v1/events` | More than 500 events. `details` is `{"limit":500,"sent":N}`. Split the batch |
| `filter_invalid` | 422 | `POST /v1/audiences/validate`, `POST /v1/segments`, `PUT /v1/segments/{id}` | The audience definition did not compile. The message is the compiler's own sentence |
| `campaign_invalid` | 422 | `POST /v1/campaigns`, `POST /v1/campaigns/{id}/submit` | The campaign did not validate |
| `export_kind_invalid` | 422 | `POST /v1/exports` | `kind` was not one of `events`, `messages`, `profiles`, `segment` |
| `all_events_rejected` | 422 | `POST /v1/events` | Every event in the batch failed normalisation. `details` is the per-item array |
Three more are 409, and the fix is a human action rather than a payload edit.
| Code | HTTP | Endpoint | What it means |
|---|---|---|---|
| `approval_required` | 409 | `POST /v1/campaigns/{id}/send` | This account requires a campaign to be approved before it sends. Submit it, then get approval |
| `approval_stale` | 409 | `POST /v1/campaigns/{id}/send` | The campaign changed after it was approved. Submit it again |
| `not_submittable` | 409 | `POST /v1/campaigns/{id}/submit` | Only a draft or a paused campaign can be submitted |
`POST /v1/audiences/validate` answers 422 for an invalid filter, where the panel's own endpoint answers 200 with `valid: false`. The panel is right for a form somebody is typing into and wrong for an integration whose error handling branches on status.
## Fix the credential {#credentials}
Four of these are 401 and they are not interchangeable. Read the code before you go looking for a typo.
| Code | HTTP | What it means |
|---|---|---|
| `unauthenticated` | 401 | No credential on the request, or one that did not resolve. Message: `a valid API key is required` |
| `api_key_required` | 401 | A panel session was offered. This host takes `sk_seg_` keys only |
| `write_key_rejected` | 401 | The token starts `wk_`. That is the public key that ships in your app, and it can read nothing. Mint a management key |
| `key_expired` | 401 | The key resolved and it has passed its expiry. Rotate it |
| `wrong_surface` | 401 | A staff credential on a customer host, or the reverse. Flat shape, Persian text |
| `forbidden` | 403 | The key's role intersected with its scopes does not include the permission. `need` names it. `GET /v1/whoami` returns the effective set |
| `ip_not_allowed` | 403 | The account has a network allow-list and this address is not on it. Flat shape, Persian text |
| `impersonation_read_only` | 403 | A support session, read-only, attempted a mutation. Flat shape |
| `impersonation_forbidden` | 403 | A support session attempted an action support may never take in your account. Flat shape |
`GET /v1/whoami` costs one budget unit, the same as every other trivial route, and answers with the effective permission set, so a well-written client can fail at start-up rather than on the one call a month that needs the permission it lacks. `GET /v1/status` is the only route on the management host that is not metered.
```bash
curl -s https://api.segmentic.net/v1/whoami \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"tenant_id": 42,
"api_key_id": 7,
"role": "analyst",
"permissions": [
"analytics.read", "audit.read", "campaign.read", "data.export",
"event.read", "journey.read", "member.read", "profile.read",
"segment.read", "settings.read", "template.read"
],
"scoped": false
}
```
`permissions` is the effective set and it is sorted alphabetically. The role is one of seven: `owner`, `admin`, `marketer`, `analyst`, `viewer`, `approver`, `finance`. A key with the `owner` role cannot be created at all, so no key ever carries `tenant.transfer` or `tenant.delete`.
`scoped` is `false` on every key the product can mint. The scopes column exists and is read, but no route writes it, so a key always carries the whole of its role. A key narrower than its role cannot be created yet.
On the ingest host, `missing write key` and `invalid write key` are both 401 and both permanent. The second answers identically for an unknown key, a revoked key and a suspended key, so the endpoint cannot be used to find out which keys exist.
## Wait {#wait}
| Code | HTTP | Where | What to do |
|---|---|---|---|
| `budget_exhausted` | 429 | every management route except `GET /v1/status` | `Retry-After: 60`. The minute bucket turns on the wall clock. See [limits](/en/docs/limits#budget) |
| no code, flat `error` | 429 | `POST /v1/messages` | `Retry-After: 60`. The message reads `rate limit exceeded: N requests per minute` |
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json; charset=utf-8
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
```
The transactional 429 is the other shape:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 0
Content-Type: application/json; charset=utf-8
{"error":"rate limit exceeded: 2 requests per minute"}
```
The ingest host never answers 429. It has no rate limiter at all.
## Retry {#retry}
Every one of these is a 503 and every one of them means the fault is ours. Back off and send the same payload again.
| Code | HTTP | Endpoint | Cause |
|---|---|---|---|
| `budget_unavailable` | 503 | every management route except `GET /v1/status` | Redis was unreachable and the budget check fails closed |
| `ingest_unavailable` | 503 | `POST /v1/events` | The queue was unavailable. 503 rather than 500 deliberately: a caller told 500 assumes their payload was the problem and stops |
| `segment_unavailable` | 503 | `POST /v1/segments`, `PUT /v1/segments/{id}`, `DELETE /v1/segments/{id}` | The segment store failed |
| `campaign_unavailable` | 503 | `POST /v1/campaigns`, `POST /v1/campaigns/{id}/send` | The campaign store failed |
| `approval_unavailable` | 503 | send and submit | The approval store failed. It fails closed, so the send did not happen |
| `export_unavailable` | 503 | `GET /v1/exports`, `POST /v1/exports` | The export store failed |
| no code, flat `error` | 503 | `POST /v1/messages` | `message not sent`. Only when no message id came back. See [transactional](#transactional) |
| no code, flat `error` | 503 | schema, count, segments and campaigns reads, and the two report routes | The warehouse or the store was unavailable. On the reports the message is a Persian sentence |
Only one 503 in the whole product carries a `Retry-After`, and it is on the ingest host: the write-key lookup failure sets `Retry-After: 5`. Every other 503 sets none. Choose your own backoff.
## Pay, or open a ticket {#contact}
Retrying these never clears them. Somebody has to pay an invoice, upgrade a plan or grant a permission.
| Code | HTTP | Endpoint | What it means |
|---|---|---|---|
| `quota_cancelled` | 402 | `POST /v1/events` and every ingest route | The subscription is cancelled |
| `quota_trial_over` | 402 | `POST /v1/events` and every ingest route | The trial period has ended |
| `quota_event_cap` | 402 | `POST /v1/events` and every ingest route | The account reached the hard event ceiling on its plan for this Jalali month |
| `quota_message_cap` | 402 | nothing reaches it | The code path exists. No caller passes a message meter to the quota check, so this never fires |
| `account_locked` | 403 | `GET /v1/exports`, `POST /v1/exports`, `POST /v1/reports/funnel`, `POST /v1/reports/retention` | Usage reached three times the allowance, or an invoice is 75 days past due. `details` is `{"reason":"usage_300"}` or `{"reason":"overdue_75"}` |
The `quota_*` messages are always Persian, on both hosts, whatever `Accept-Language` says. The four sentences:
| Code | Message |
|---|---|
| `quota_cancelled` | `اشتراک این حساب لغو شده است` |
| `quota_trial_over` | `دورهٔ آزمایشی به پایان رسیده است` |
| `quota_event_cap` | `سقف رویدادهای این ماه پر شده است` |
| `quota_message_cap` | `سقف پیامهای این ماه پر شده است` |
`account_locked` closes exactly four management routes. Ingest, transactional send, campaign creation, campaign send and segment authoring all stay open while an account is locked, because a hole in your data cannot be filled in afterwards and a debt can be collected afterwards.
The quota verdict knows how far over the account is. That number does not reach you: the refusal carries the sentence and nothing else. Read usage from the panel.
> [!danger]
> A 402 causes every Segmentic SDK to discard the batch it was holding, not to buffer it. All three SDKs treat any 4xx except 429 as permanent. When you hit a hard event ceiling, the events already queued on your users' devices are lost, and they cannot be recovered afterwards. Watch the usage warnings, not the refusal.
## The ingest host has statuses, not codes {#ingest-statuses}
There is no `code` field on `https://in.segmentic.net`. Branch on the HTTP status.
| HTTP | Message | Cause | Do |
|---|---|---|---|
| 400 | `malformed JSON` | The body did not parse | Fix |
| 400 | a sentinel from the table below | One event failed validation | Fix |
| 400 | `batch_empty` | Zero items in `batch` | Fix |
| 400 | `batch_too_large: N items, limit 500` | Over 500 items | Fix, split it |
| 401 | `missing write key` | No `Authorization: Bearer`, no `X-Segmentic-Key`, no `?write_key=` | Fix. Never retry |
| 401 | `invalid write key` | Unknown, revoked or suspended | Fix. Never retry |
| 402 | Persian quota text | A hard ceiling, a cancelled subscription or an ended trial | Do not retry |
| 413 | `request body too large` | Over `5 MiB` | Fix, send less |
| 503 | `cannot verify the write key right now; retry` | Our key lookup failed. Sets `Retry-After: 5` | Retry, keep the events |
| 503 | `temporarily unavailable, please retry` | The bus and the disk buffer both failed | Retry |
Note that the same condition gets different statuses on the two hosts: a batch over 500 items is 400 on `POST /v1/batch` and 413 on `POST /v1/events`.
The channel and on-site routes use the same envelope with their own messages.
| Endpoint | HTTP | Message |
|---|---|---|
| `POST /v1/devices` | 400 | `malformed JSON`, or the normaliser's own text with `warnings` alongside |
| `POST /v1/devices` | 503 | `temporarily unavailable, please retry` |
| `POST /v1/devices/unregister` | 400 | `device_id is required` |
| `POST /v1/webpush/subscribe` | 400 | `user_id and a complete subscription are required` |
| `POST /v1/webpush/unsubscribe` | 400 | `endpoint is required` |
| `POST /v1/messenger/link` | 400 | `user_id, chat_id and a known platform are required` |
| `POST /v1/messenger/unlink` | 400 | `user_id and a known platform are required` |
| `POST /v1/inbox` | 400 | `user_id is required` |
| `POST /v1/inbox` | 403 | `user identity is not verified` |
| `POST /v1/onsite/event` | 400 | `campaign_id and a visitor id are required`, or `unknown action` |
| `POST /v1/onsite/response` | 400 | `unknown campaign`, or the validator's own text |
| `POST /v1/hooks/{source}/{token}` | 401 | `signature mismatch`, or `unauthorized` |
| `POST /v1/hooks/{source}/{token}` | 404 | `unknown webhook` |
| `POST /v1/bounce/{local}` | 400 or 413 | plain text, not JSON |
Two routes never report a failure at all, deliberately. `GET /v1/onsite` answers `200 {"campaigns":[]}` when its store is down, because it runs inside your page load and a slow or failing campaign fetch must not be visible to your visitor. `POST /v1/onsite/event` is always 200, even when the impression write failed. `POST /v1/hooks/...` answers `200 {"status":"ok","accepted":0}` for a payload it cannot transform, because Shopify and WooCommerce retry a non-2xx for days.
## Why one event was refused {#rejection-reasons}
These ten strings are the stable part of `errors[].reason` on the ingest host and of `details[].reason` on `POST /v1/events`. The set is closed. Anything outside it is counted as `invalid`.
| Sentinel | Meaning |
|---|---|
| `unknown_type` | `type` was not one of track, identify, page, screen, alias. Wrapped with your value |
| `missing_identity` | Neither `user_id` nor `anonymous_id` was present |
| `missing_event_name` | `type: "track"` with no `event` |
| `event_name_too_long` | The event name is over 128 bytes |
| `event_name_invalid_chars` | The event name contains a Unicode control character |
| `id_too_long` | `user_id`, `anonymous_id` or `message_id` is over 256 bytes |
| `missing_previous_id` | `type: "alias"` with no `previous_id` |
| `batch_too_large` | More than 500 items. Wrapped: `batch_too_large: N items, limit 500` |
| `batch_empty` | Zero items |
| `timestamp_too_old` | Backfill only. Live ingest clamps the timestamp and warns instead |
## Warnings, which arrive on a 200 {#warnings}
A warning means the event was accepted and something was corrected. It is not an error and it does not change the status. Every warning is `{"code","field","note"}`, and `field` and `note` are omitted when empty.
| Code | Field | What happened |
|---|---|---|
| `generated_message_id` | `message_id` | You sent none, so one was generated. Retries of this event cannot be de-duplicated |
| `timestamp_in_future` | `timestamp` | The device clock is ahead of the server. Clamped to receive time |
| `timestamp_too_old` | `timestamp` | Older than the ingest window. Clamped to its edge |
| `too_many_properties` | `properties` | Over 256 properties. The first 256 were kept |
| `too_many_traits` | `traits` | Over 256 traits. The first 256 were kept |
| `unserialisable_property` | the offending key | The value could not be stored and was dropped |
| `invalid_phone` | `phone` | Not a valid Iranian mobile number. Stored as given |
| `invalid_national_id` | `national_id` | Failed the check digit. Not stored at all |
A batch response stops collecting warnings once it holds fifty, though all of them are counted against your account's data-quality metrics. Watch `generated_message_id`: it is the one warning that costs you something, because it means a retried event will be stored twice.
This table is the ingest host. `POST /v1/events` on the management host computes the same warnings and then discards them, so its response never carries a `warnings` array and you cannot tell from it that a timestamp was clamped or a message id generated. If you use that door to migrate data, expect no signal about the corrections.
## Transactional send errors {#transactional}
`POST /v1/messages` is the oldest handler on the management host and it answers in the flat shape throughout. There is no `code` on any of its failures.
Every one of these is a 400 with the sentinel text as the whole `error` string:
| `error` | Meaning |
|---|---|
| `transactional: user_id is required` | No recipient |
| `transactional: template_id is required` | No template |
| `transactional: idempotency_key is required` | No key, in the body or the `Idempotency-Key` header |
| `transactional: idempotency_key must be 8-200 characters of letters, digits, dot, dash, underscore or colon` | The key failed `^[A-Za-z0-9._:-]{8,200}$`. The wire text has U+2013 between the 8 and the 200 |
| `transactional: unknown channel` | Not a channel this account can send on |
| `transactional: this endpoint does not send marketing; use a campaign` | `category` was `marketing`. This endpoint bypasses frequency caps and quiet hours, so accepting marketing here would be a documented way round your own sending rules |
| `transactional: too many variables` | Over 40 entries in `vars` |
| `malformed JSON: ` | Unparseable, or an unknown field. Unknown fields are refused rather than ignored, so a mistyped `idempotencyKey` is a 400 and not a fresh key on every retry |
One 409 and one 503:
| HTTP | `error` | Meaning |
|---|---|---|
| 409 | `transactional: a message with this idempotency key is already in flight` | Your own earlier attempt is still running. `Retry-After: 1` |
| 503 | `message not sent` | The send failed and no message id came back |
> [!warn]
> A 200 on this endpoint does not mean delivered. Read `reason` and `reason_fa` in the body: they are set when the message was deliberately not sent, for an opt-out, a suppression, or no address on file. And if the send succeeded but recording it failed, the endpoint answers 200 with the result rather than 503, because the message really did go and a caller told otherwise would retry and send a second one.
## Retrying {#retrying}
The rule the SDKs implement, and the one to implement yourself.
| Status | Retry | Why |
|---|---|---|
| 400, 402, 403, 404, 409, 413, 422 | No | The same payload gets the same answer for ever. The exception is 409 on `POST /v1/messages`, which is your own attempt still running |
| 401 | No | The credential will never work. Fix the key |
| 429 | Yes, after `Retry-After` | The window turns |
| 5xx | Yes, with exponential backoff and jitter | The fault is ours |
All three Segmentic SDKs express this as one line: anything in 400 to 499 except 429 is permanently rejected and dropped; everything else is retried with exponential backoff and full jitter, base 1 second, capped at 5 minutes.
The 401 against 503 distinction is the whole reason the ingest host separates them. An SDK reads 401 as "this key will never work", stops, and throws the buffered events away. It reads 503 as "try again later" and keeps them. This host used to answer 401 when the key lookup itself failed, which is our outage and not your key: with the database scaled to zero, eight events out of eight came back 401 and were destroyed at the customer's end, while their logs told them their write key was invalid. That is now 503 with `Retry-After: 5`, and the disk buffer behind the collector exists precisely so an infrastructure failure never costs an event.
The consequence for you: treat any 401 from us as a configuration bug, never as a transient. If you see 401 in bulk on a key that worked yesterday, it is a real credential problem, because the outage case no longer looks like this.
## Where a retry is protected {#idempotency}
| Path | Mechanism | Window |
|---|---|---|
| Every route on the ingest host | `message_id` de-duplication in Redis | 48 hours, `DEDUPE_TTL` |
| `POST /v1/messages` | Your own `idempotency_key`, reserved in the ledger in one round trip | 7 days, `API_IDEMPOTENCY_RETENTION` |
| Inbound webhooks | A deterministic message id, so a platform's retry after a timeout is the ordinary case | 48 hours |
Send a `message_id` on every event. Without one, we generate it, you get the `generated_message_id` warning, and a retry after a network timeout stores the event twice. With one, the retry is free.
That protection does not extend to `POST /v1/events`. That route publishes straight to the bus without going near Redis, so a `message_id` there is only the event's identifier and nothing catches a repeat. A retry through that door stores the event twice even when you sent the same `message_id`. Read before you resend.
A de-duplicated event is reported back as accepted, not as a duplicate. There is no `duplicate` field on the wire and the counts do not distinguish them. This is deliberate: your retry logic should not have to care, and a duplicate is not metered against your quota.
When Redis is unreachable the de-duplication check is skipped and the possible duplicate is accepted, rather than the event being lost.
A replayed transactional send is marked. Read `replayed` in the result to tell "we already did this" apart from "we just did this", which matters when your first attempt timed out and you do not know which happened.
> [!danger]
> No management write other than `POST /v1/messages` accepts an idempotency key. `POST /v1/segments`, `POST /v1/campaigns`, `POST /v1/campaigns/{id}/send`, `POST /v1/exports` and `POST /v1/events` have no such field, so a retried `POST /v1/campaigns` creates a second campaign. Retry those only after a read that confirms the first attempt did not land.
## The trace id to quote in a ticket {#trace-id}
Every response from every host carries `X-Segmentic-Trace`, sixteen hexadecimal characters.
```http
HTTP/1.1 503 Service Unavailable
X-Segmentic-Trace: 4f2a9c81b0d3e756
Content-Type: application/json; charset=utf-8
{"error":{"code":"ingest_unavailable","message":"could not queue these events; retry"}}
```
It is not in the error body. Log it from the header, and quote it in a support ticket: it is what lets us find the exact request in our logs rather than a class of similar ones. If you already have a trace id of your own, send it on the request in the same header: when it is between 8 and 64 hexadecimal characters we honour it, lower-cased, and echo it back. Anything else is discarded and replaced with ours, because this value ends up in our log lines.
The header name is ours rather than W3C `traceparent`, because there is no sampling decision and no span hierarchy behind it, and a header that looks like `traceparent` but is not would mislead the first person who points a tracing tool at it.
## What the error body does not carry {#absent}
Written down because each of these is something a reader looks for and does not find.
- **No `request_id` field.** The trace id is in the header only.
- **No `message_fa`.** The management envelope has `code`, `message`, `details` and `need`, and nothing else. Where a message is Persian it is Persian in `message`.
- **No distinct analytics error codes.** Thirteen separate validation failures in the reports engine all collapse into the single code `invalid_report`. Nine of them put the catalogue's Persian sentence in `error`; the other four, event name too long, too many filters, property key too long and property value too long, fall through the default branch and carry the English text of the error itself, such as `analytics: too many filters`. So the language of `error` is not fixed on these two routes. Nor can you tell "too many funnel steps" from "time range too wide" without reading the string.
- **No `used` or `limit` on a 402.** The quota verdict computes both and discards them before the response is written.
- **No remaining budget in `GET /v1/whoami`.** It returns five fields and none of them is a budget.
- **No `X-RateLimit-*` headers on the management budget.** They exist on `POST /v1/messages` only, and only when a rate limit is configured, which it is not by default. See [limits](/en/docs/limits#budget).
Related: [limits and rate limiting](/en/docs/limits), [the ingest API](/en/docs/api/ingest), [the management API](/en/docs/api/management).
---
# Limits and rate limiting
> Every number the server holds you to, and which of them you can ask the server for at runtime.
> https://segmentic.net/en/docs/limits
Every number on this page comes from the code that enforces it. Where a limit truncates your data instead of refusing it, that is said plainly, because a silent truncation is the failure you find out about months later from a report that makes no sense.
## Read the limits from the server {#ask-the-server}
`GET /v1/capabilities` publishes five of these numbers at runtime. It costs one budget unit and needs no permission.
```bash
curl -s https://api.segmentic.net/v1/capabilities \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"version": "v1",
"features": {
"segments": true,
"campaigns": true,
"analytics": true,
"transactional": true,
"export": true,
"import": true,
"journeys": true,
"ingest": true,
"async_exports": true,
"campaign_approval": true
},
"limits": {
"max_page_size": 100,
"max_preview_rows": 100,
"max_batch_size": 500,
"estimate_sample": 100,
"query_timeout_sec": 30
}
}
```
Read `max_page_size` and `max_batch_size` from here rather than hardcoding 100 and 500. Every `features` flag reports what this deployment actually serves, so a self-hosted install that runs without the analytics engine answers `"analytics": false` and your client can fail at start-up rather than on the call.
Four warnings about this response, all of them things a reader assumes and should not.
- `max_preview_rows` and `estimate_sample` bound nothing on the management host. They belong to two panel endpoints that are not registered here. Ignore them.
- `query_timeout_sec` is 30, and it is the timeout for reads, writes and `POST /v1/audiences/count`. It is not the timeout the two report routes use, which is 45 seconds. See [timeouts](#timeouts).
- `ingest` and `import` are the same boolean under two names, the one that registers `POST /v1/events`.
- `export` controls no route on this host. The two export routes are turned on and off by `async_exports` instead. An account reading `"export": false` may still have both export routes, and the reverse. For exports, look at `async_exports`.
Everything else on this page has to be hardcoded, because there is nowhere to read it from. There is no `GET /v1/reports/limits` and no endpoint that reports your account's event ceiling or its current usage.
## Ingest limits {#ingest-limits}
These apply on both doors: the ingest host and `POST /v1/events` on the management host.
| Limit | Value | Applies to | On breach |
|---|---|---|---|
| Event name length | `128 bytes` | the `event` field on a track | Rejects, `event_name_too_long` |
| Id length | `256 bytes` | `user_id`, `anonymous_id`, `message_id` | Rejects, `id_too_long` |
| Session id length | `256 bytes` | `context.session_id` | Truncates silently |
| Previous id | no limit | `previous_id` on an alias | Checked for presence, never for length |
| Property or trait key | `128 bytes` | each key, after normalisation | Truncates silently |
| Property or trait value | `8192 bytes` | each string value | Truncates silently |
| Properties per event | `256` | `properties` | Keeps the first 256, warns `too_many_properties` |
| Traits per identify | `256` | `traits` | Keeps the first 256, warns `too_many_traits` |
| Events per batch | `500` | `POST /v1/batch` and `POST /v1/events` | Rejects, `batch_too_large` |
| Request body | `5 MiB` | the whole request on the ingest host | Rejects, 413 |
| Page URL | `2048 bytes` | `context.page.url`, `.path` and `.referrer` | Truncates silently |
| Webhook body | `1 MiB` | `POST /v1/hooks/{source}/{token}` | Rejects |
| Bounce report | `1 MiB` | `POST /v1/bounce/{local}` | Rejects, 413 |
Only one of these has a configuration key. `MAX_BODY_BYTES` sets the body cap and defaults to `5242880`. The rest are compiled in, so a self-hosted install cannot raise them either.
Ordering matters. Rejection happens before truncation, and truncation happens before storage, so an event with a 300 byte `user_id` is refused whole rather than stored with a shortened id.
### The silent ceilings {#truncation}
Every other field inside `context` is cut to length with no warning and no error. They are listed here because the alternative is finding out from a segment that does not match the users you expected.
| Bytes | Fields |
|---|---|
| `8` | the `currency` property, uppercased |
| `16` | `context.device.push_provider` |
| `32` | `context.locale`, `context.device.type`, `context.os.name`, `context.os.version`, `context.library.version`, and the browser version derived from the User-Agent |
| `64` | `context.timezone`, `context.ip`, `context.app.version`, `context.device.manufacturer`, `context.network.carrier`, `context.library.name`, `context.location.country`, `context.location.region`, `context.location.city`, `context.campaign.variant_id`, and the browser name and device vendor derived from the User-Agent |
| `128` | `context.device.model`, `context.campaign.token`, and all five `utm` fields: source, medium, name, term and content |
| `256` | `context.session_id`, `context.campaign.message_id` |
| `512` | `context.page.title`, and the `User-Agent` header itself |
Country, region, city, page title and every event name also pass through Persian normalisation before they are truncated, so Arabic ye and kaf become Persian ye and kaf. That is what lets a segment on `city = تهران` match customers whose apps disagree about spelling.
If a value matters to a segment or a report, keep it inside these bounds yourself.
## The timestamp window {#timestamps}
An event carries its own `timestamp`. How far back that may reach is your own account's event retention, not a fixed constant.
| Setting | Value | Behaviour |
|---|---|---|
| Default past window | 30 days | The floor. An account on 30 day retention still gets the full 30 days |
| Future window | 1 hour | Anything further ahead is clamped to receive time and warns `timestamp_in_future` |
| Retention set to "keep for ever" | 3650 days | Ten years, finite despite the name, so a 1970 timestamp from a broken clock is still refused. It is the window an account that never opened the retention screen gets |
On live ingest, a timestamp older than the window is **clamped to the edge of the window** and the event is accepted with the warning `timestamp_too_old`. It is not rejected.
This is the one behaviour on the platform most likely to cost you a week. It used to be that 30 days was the whole window, whatever your retention said, and the symptom was invisible: an account migrating two years of history had every event older than thirty days silently moved to exactly thirty-days-ago, accepted with a warning and a 200. Nothing failed. The data was simply wrong, all of it stacked on one timestamp, and the first sign was a funnel that made no sense months later. The window now comes from your retention policy, which is what the clamp always claimed to enforce.
The retention lookup is cached for one minute and fails soft to 30 days when it cannot be read.
On a backfill the same condition **rejects** with `timestamp_too_old` instead of clamping, because on a backfill moving a timestamp is worse than refusing it. A backfill row with no timestamp at all is also rejected.
> [!danger]
> The window that comes from your retention applies on the ingest host only. `POST /v1/events` on the management host does not read your account's policy and always gets the 30 day default, because it builds its options with no `MaxPast`. Any event older than thirty days sent through that door is silently moved to thirty-days-ago and answered 200, and the response carries no warning either, because that route discards them. Never migrate history through it. The panel's event import refuses instead of clamping, and refusing is what a migration needs.
### Retention is the ingest window, not the lifetime of the data {#storage-ttl}
These are two different numbers and confusing them is expensive. A retention policy validates up to 3650 days, with a floor of 30 days on any non-zero setting. But the ClickHouse events table carries a fixed **400 day** `TTL` on `event_time` that ignores the account's policy and drops every row.
So a ten year retention setting can be stored, and no event survives more than 400 days past its own timestamp. The policy only decides which timestamps we accept on the way in, and which rows are deleted sooner than 400 days. Raising it does not make the data live longer.
If you need a history longer than 400 days, export it yourself before the edge arrives. There is nowhere else to get it back from.
## Management host limits {#management-limits}
| Limit | Value | Applies to |
|---|---|---|
| Request body | `8 MiB` | most routes |
| Request body | `256 KiB` | `POST /v1/messages` |
| Request body | `1 MiB` | `POST /v1/audiences/validate`, `POST /v1/audiences/count`, the two report routes |
| Max page size | `100` | every list route |
| Default page size | `25` | every list route |
| Export kinds | `events`, `messages`, `profiles`, `segment` | `POST /v1/exports` |
| Export format | `ndjson` by default, `csv` the only alternative | `POST /v1/exports` |
| Export file lifetime | `7 days` | published in the 202 body as `expires_after_hours: 168` |
The three different body caps are not a mistake anybody has fixed. `POST /v1/messages` gets 256 KiB because a transactional payload is a template id and a few variables, and the four routes that borrow a panel handler get that handler's 1 MiB.
### Pagination {#pagination}
`limit` is clamped, never rejected. `limit=500` gives you 100. `limit=0` and `limit=banana` both give you 25. You will not get an error telling you the cap; read it from `GET /v1/capabilities`.
A cursor is an opaque base64url string. A cursor that does not decode is silently treated as the start of the list, not as an error, so a client that corrupts its cursor loops over page one for ever rather than failing. Compare the ids you receive against the ones you already have.
> [!warn]
> `GET /v1/exports` accepts `limit` but never returns a cursor. It answers `{"data":[...],"has_more":false}` and `has_more` is `false` even when there are more jobs. There is no way to page past the first response. Ask for the maximum, 100, and take that as the whole list.
`GET /v1/segments` and `GET /v1/campaigns` answer with their panel handler's own shape, `{"segments":[...]}` and `{"campaigns":[...]}`, not with the paged envelope. The `data` and `next_cursor` shape appears on `GET /v1/exports` alone.
> [!danger]
> Both of those routes are cut to 200 rows in the SQL itself: the 200 most recently updated segments, and the 200 most recently updated campaigns. The cap is silent. There is no `has_more`, no `next_cursor`, no count and no warning, and `?limit=` and `?cursor=` are not read here either. An account holding 250 segments sees two hundred of them and has no route on any surface that reaches the other 50, the panel included. If your library goes past that number, keep your own index elsewhere and fetch by id through `GET /v1/segments/{id}`.
## Report limits {#report-limits}
`POST /v1/reports/funnel` and `POST /v1/reports/retention`.
| Limit | Value | What it bounds |
|---|---|---|
| Time range | `730 days` | The widest window either report will scan. Two years for a large account is already hundreds of billions of rows |
| Funnel steps | `12` | One condition is built per step and a human has to read the result |
| Retention periods | `60` | The number of columns in a table somebody has to read. Defaults to 30 when you send zero or less |
| Filters per report | `10` | |
| Property key length | `128` | |
| Event name length | `256` | |
| Property value length | `512` | |
| Path depth | `8` | The paths report, which is not on this host |
| Path rows | `100` | The paths report, which is not on this host |
Every one of these failures comes back as the same code, `invalid_report`. There are thirteen distinct validation errors behind that one code and no way to tell them apart programmatically. Nine of them put the catalogue's Persian sentence in `error`; the bottom four rows of this table, event name length, filter count, property key length and property value length, fall through the default branch and carry the English text of the error itself, such as `analytics: too many filters`. If you need to, match on the string, and accept that neither the string nor its language is a contract.
The paths report exists but is registered on the panel's mux only. It is not reachable with an API key.
## Audience and segment limits {#segment-limits}
These bound the definition you send to `POST /v1/audiences/validate`, `POST /v1/audiences/count`, `POST /v1/segments` and `PUT /v1/segments/{id}`.
| Limit | Value | Error text |
|---|---|---|
| Nesting depth | `8` | `segment: nesting too deep` |
| Conditions | `200` | `segment: too many conditions` |
| Values in one list | `1000` | `segment: list has too many values` |
| Property key length | `128` | |
The condition count includes event-property predicates, not only the nodes of the boolean tree, so a definition that looks like twenty conditions on screen can be well over a hundred here. All four surface as `filter_invalid` with a 422 and the compiler's own sentence as the message.
Validate before you save. `POST /v1/audiences/validate` compiles the definition without touching a database, costs one budget unit, and answers 422 rather than the panel's 200 with `valid: false`.
## Transactional send limits {#transactional-limits}
| Limit | Value | Config key |
|---|---|---|
| Variables per message | `40` | none |
| Idempotency key format | `^[A-Za-z0-9._:-]{8,200}$` | none |
| Idempotency key lifetime | `7 days` | `API_IDEMPOTENCY_RETENTION`, default `168h` |
| Stale reservation timeout | `1 minute` | `API_STALE_RESERVATION` |
| Request body | `256 KiB` | none |
The key pattern is strict deliberately. The key becomes part of the message id, which is written into the ledger, the frequency counter and the provider's own reference, so a key containing a newline or a quote would travel a long way before anything rejected it. The message id is derived rather than generated: `t.`, so the same key produces the same id all the way down.
`category` defaults to `transactional` when you omit it. `marketing` is refused outright with a 400. This endpoint bypasses frequency caps and quiet hours, so accepting a marketing message here would hand you a documented way round your own sending rules, and the first time it mattered would be a 3am promotional SMS to a whole list.
## The request budget {#budget}
Every route on the management host except `GET /v1/status` is metered in weighted cost units. Requests per minute is the wrong unit for a surface where one call reads a struct and the next scans a warehouse.
| Class | Weight | Meaning |
|---|---|---|
| Trivial | `1` | Reads nothing, or reads one row by primary key |
| Query | `5` | One bounded warehouse query |
| Heavy | `25` | A scan whose cost scales with your history |
The cost of every route:
| Route | Cost | Permission |
|---|---|---|
| `GET /v1/whoami` | `1` | none |
| `GET /v1/capabilities` | `1` | none |
| `GET /v1/schema/events` | `5` | `event.read` |
| `GET /v1/schema/traits` | `5` | `event.read` |
| `POST /v1/audiences/validate` | `1` | `segment.read` |
| `POST /v1/audiences/count` | `25` | `segment.read` |
| `GET /v1/segments` | `1` | `segment.read` |
| `GET /v1/segments/{id}` | `1` | `segment.read` |
| `POST /v1/segments` | `1` | `segment.write` |
| `PUT /v1/segments/{id}` | `1` | `segment.write` |
| `DELETE /v1/segments/{id}` | `1` | `segment.delete` |
| `GET /v1/campaigns` | `1` | `campaign.read` |
| `GET /v1/campaigns/{id}` | `5` | `campaign.read` |
| `POST /v1/campaigns` | `1` | `campaign.write` |
| `PUT /v1/campaigns/{id}/recurrence` | `1` | `campaign.send` |
| `DELETE /v1/campaigns/{id}/recurrence` | `1` | `campaign.send` |
| `POST /v1/campaigns/{id}/send` | `1` | `campaign.send` |
| `POST /v1/campaigns/{id}/submit` | `1` | `campaign.write` |
| `POST /v1/events` | `5` | `profile.write` |
| `GET /v1/exports` | `1` | `data.export` |
| `POST /v1/exports` | `25` | `data.export` |
| `POST /v1/reports/funnel` | `25` | `analytics.read` |
| `POST /v1/reports/retention` | `25` | `analytics.read` |
| `POST /v1/messages` | `1` | `campaign.send` |
The mechanics:
| Property | Value |
|---|---|
| Default allowance | `600` units per minute, from `PUBLIC_API_BUDGET_PER_MINUTE` |
| Algorithm | Fixed window, one Redis round trip |
| Window | One calendar minute on the wall clock, not a rolling minute |
| Scope | Per API key, not per account |
| Failure direction | Fails closed. Redis unreachable means 503 `budget_unavailable` |
| Charged | Before the handler runs, and charged even when the handler then fails |
600 units is roughly two dozen heavy reports a minute, or six hundred cheap ones. Exactly: 24 heavy calls go through and the 25th is refused.
The budget is keyed on the API key rather than the account, unlike the rate limit below, and that is deliberate. You issue one narrow key to an agent and keep your own integration key separate, and a runaway agent must not be able to exhaust the budget your order pipeline depends on. See [MCP](/en/docs/mcp).
`POST /v1/messages` costs 1, which looks wrong for a call that can send to a person's phone. It is not: the cost of that call is trivial in query terms and enormous in consequence, and the thing meant to bound it is a recipient budget, which does not exist. See [what has no limit](#no-limit).
> [!warn]
> There are no `X-RateLimit-*` headers on the budget. Not on refusals and not on successes. `GET /v1/whoami` does not report remaining budget either. You cannot see how close you are: count your own spend from the table above, or handle the 429.
The only recovery is time. The Redis key expires 70 seconds after the first debit in the window and the minute bucket turns on the wall clock. There is no reset endpoint, no per-key override, and no way to raise the allowance short of changing the environment variable and restarting the process.
A locked account's refused request still costs its budget units, because the lock check runs after the budget debit.
## The transactional rate limit {#rate-limit}
A second, entirely separate mechanism. It counts requests rather than cost units, it is scoped to the account rather than the key, and it fails in the opposite direction.
| Property | Value |
|---|---|
| Applies to | `POST /v1/messages` and nothing else |
| Algorithm | Fixed window, Redis `INCR` |
| Window | One calendar minute |
| Scope | Per account. Rotating a key buys no fresh allowance |
| Limit source | Your account's own `api_rate_per_minute`, read on every request with no cache |
| Default | `API_RATE_PER_MINUTE`, which ships as `0` |
| `0` means | Metering is off entirely. This is the shipped default |
| Failure direction | Fails open. Redis unreachable means the call proceeds |
It fails open deliberately, and the reason is the traffic it carries. This endpoint sends order receipts and login codes. Refusing them all because a cache is down turns our outage into your checkout failing. A rate limit exists to stop a runaway integration, which is a problem worth being late to.
Because it is a fixed window, a caller can send two windows' worth of messages across a boundary. That is the right trade for a limit whose job is stopping a runaway loop rather than metering to the request.
When a limit is configured, two headers go out on every response, including successes, so you can slow down before you are refused.
```http
HTTP/1.1 200 OK
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
Content-Type: application/json; charset=utf-8
```
`X-RateLimit-Remaining` is never negative. There is no `X-RateLimit-Reset` and no `X-RateLimit-Resource`. With the shipped default of zero, no headers are sent at all.
The ingest host has no rate limiter of any kind. Its only volume controls are the body cap, the batch cap and the quota gate.
## Quotas {#quota}
A quota is a plan ceiling, counted over a Jalali month.
| Meter | Counts |
|---|---|
| `events` | Everything the collector accepted, including de-duplicated replays |
| `profiles` | The high-water mark of identified people in the period, not the sum |
| `messages.email`, `messages.sms`, `messages.push`, `messages.web`, `messages.inapp` | Sends, one meter per channel |
| `messages.messenger` | Bale, Eitaa and Rubika pooled into one meter |
Events are metered after acceptance only, so a rejected payload is not billed and a de-duplicated retry is not either. A retrying SDK costs us a Redis lookup, not an invoice line you would dispute. Messages are metered on a successful send only.
The plan carries two hard ceilings, `max_events` and `max_messages`, which are distinct from the included allowance. Zero means no ceiling: the overage is billed and everything keeps working. Going past the allowance costs money and is a bill. Going past a ceiling stops you and is an outage.
**Only the event ceiling is enforced.** No code path checks a message meter against `max_messages`, so `quota_message_cap` never fires. The field is declared, validated and rendered on invoices, and enforced nowhere.
What happens at the ceiling:
- The request is refused whole with a 402, never partially accepted. A partial accept would leave you unable to tell which events to resend, and you are over the ceiling either way.
- The check runs once per batch, before any per-item work.
- Every SDK discards the batch it was holding, because all three treat any 4xx except 429 as permanent. Those events are gone.
- Retrying changes nothing until somebody pays.
The quota verdict is cached for 15 seconds per account, so you can overshoot a ceiling by whatever you send inside that window. The gate also fails open: if the subscription or usage lookup errors, the traffic is accepted.
The period is a **Jalali month**, not a Gregorian one, and the days inside it are counted in Tehran local time. The period ends exactly where the next one begins, so no instant falls between two periods.
Announcements, which refuse nothing, fire at 300, 150, 125, 100 and 80 per cent of the included allowance, once per meter per Jalali month.
## The soft lock {#soft-lock}
A money control, not a rate limit.
| Trigger | Threshold |
|---|---|
| Overdue invoice | An issued invoice at least `75 days` past due. A declared payment disarms it |
| Usage | Profiles or events at or above `300%` of the included allowance |
Overdue is checked first. The verdict is cached for 60 seconds per account, and it fails open on every path it cannot read. The profile figure lags by up to an hour, because it is written by a background poll.
It closes exactly four routes on the management host: `GET /v1/exports`, `POST /v1/exports`, `POST /v1/reports/funnel` and `POST /v1/reports/retention`.
Everything else stays open, deliberately: ingest, transactional send, campaign creation, campaign send, and segment authoring. A hole in your data cannot be filled in afterwards and a debt can be collected afterwards.
The same lock closes the same two families on the panel. A lock that one credential type honours and another does not is not a lock, it is a detour, and the detour is a script away.
## Timeouts {#timeouts}
| Call | Server-side ceiling |
|---|---|
| `GET /v1/whoami`, `GET /v1/capabilities`, `GET /v1/status` | No database work at all |
| Every read, every write, `POST /v1/audiences/count`, `POST /v1/messages` | `30 s`, the query timeout |
| `POST /v1/reports/funnel`, `POST /v1/reports/retention` | The handler allows `45 s`, but the listener cuts the response at `30 s` |
| Any call on the management host | `WriteTimeout 30 s`, `ReadTimeout 15 s`, `ReadHeaderTimeout 5 s` |
| Any call on the ingest host | `WriteTimeout 30 s`, `ReadTimeout 15 s`, `IdleTimeout 120 s` |
The report row is a real conflict, not a rounding difference. A funnel that takes between 30 and 45 seconds is cut off by the HTTP server, not by the handler, so you see a truncated response rather than a clean error. Narrow the time range or the number of steps.
A client timeout of **35 seconds** covers every ceiling here. Going higher gains nothing, because the listener closes the connection at 30 seconds regardless.
## What has no limit {#no-limit}
Each of these is a limit a reader expects to find. None of them exists.
- **No ceiling on distinct event names, property keys or trait keys.** `max_properties` and `max_traits` bound one payload, not your schema. You can create ten thousand distinct event names and nothing will stop you. Nothing will make them useful either.
- **No row cap on an export.** `POST /v1/exports` takes `kind`, `format` and `spec`, and there is no `max_rows`, no server-side cap and no `expected_count` on the job.
- **No per-IP rate limiting anywhere in the product.** Not on the ingest host, not on the management host.
- **No recipient budget.** Nothing counts how many people a key may send to in a day, which is why `POST /v1/messages` costs one budget unit and can still reach a hundred thousand phones through a hundred thousand calls. Bound it yourself.
- **No message ceiling enforcement.** Covered above.
- **No import endpoint on the management host.** CSV import is a panel feature, capped at 500,000 rows and 64 MiB, and there is no API for it.
- **No `Accept-Language`.** Neither host reads it. Quota and lock messages are always Persian.
Related: [error codes](/en/docs/errors), [the ingest API](/en/docs/api/ingest), [the management API](/en/docs/api/management).
---
# The OpenAPI document
> A machine-readable description of both surfaces, for generating a client, testing, or feeding a tool.
> https://segmentic.net/en/docs/openapi
Both customer-facing surfaces are described in one machine-readable file: 41 paths, 48 operations and 65 schemas, in `OpenAPI 3.1.1`.
## Download {#download}
```bash
curl -O https://segmentic.net/openapi.json
```
The file is rebuilt with every build of this site, from the same repository the API lives in. So the copy you download is always the one these pages describe, rather than a hand-maintained duplicate that was updated once and then fell behind.
> [!note]
> There is no second file with a `.yaml` extension and there does not need to be. `JSON` is a subset of `YAML 1.2`, so any tool that wants `YAML` accepts this file unchanged.
## What is in it {#what}
Every route a customer calls, on both hosts:
- Ingest, `https://in.segmentic.net`: events, batches, device registration, web push, messengers, the inbox, on-site messages, inbound webhooks, and the four routes we put inside messages ourselves.
- Management, `https://api.segmentic.net`: key identity, capabilities, the event and trait schema, audiences, segments, campaigns, server-side ingest, exports, funnel and retention reports, and transactional messages.
Every operation carries a summary, complete request and response schemas with the real field names, and every status code with its error body. Every operation but `POST /v1/messenger/unlink` also carries a description, and every one but the six operations behind the four routes we put inside messages carries at least one worked example taken from the project's own tests.
Two authentication schemes are defined and neither is a document-wide default, so 39 of the 48 operations declare exactly one of them:
| Name in the file | What it is | Where |
|---|---|---|
| `writeKey` | `http` with `scheme: bearer`. The write key, `wk_seg_` plus 43 characters | Ingest host routes |
| `managementKey` | `http` with `scheme: bearer`. The API key, `sk_seg_` plus 43 characters | Management host routes |
The other nine declare `security: []`: `GET /v1/status`, `POST /v1/hooks/{source}/{token}`, `POST /v1/bounce/{local}`, and the six operations behind the four routes we put inside messages.
Three error shapes sit in `components`, because the server genuinely has three: `IngestError`, flat with `status` and `message`, on the ingest host; and `PublicError`, nested, alongside `FlatError` and `FlatCodedError` on the management host. Each response points at the one that actually comes back.
## Two hosts in one document {#two-servers}
One structural point that is confusing if nobody tells you.
Both hosts serve paths under `/v1`, and an `OpenAPI` document keys `paths` by string. So the two surfaces share one `paths` map, and **every operation carries its own `servers` array with exactly one entry**, plus a tag naming its surface.
The one exception is `GET /v1/status`, which genuinely exists on both hosts, so it is a single operation carrying both servers.
If your tool applies the document's first `server` to everything, half your requests go to the wrong host. Tools that read operation-level `servers` (current `openapi-generator`, `Postman`, `Insomnia`, `Bruno`, `Kiota`) handle it correctly.
## Generating a client {#clients}
```bash title="TypeScript client"
npx @hey-api/openapi-ts -i https://segmentic.net/openapi.json -o src/segmentic
```
```bash title="Go, Python or PHP client"
npx @openapitools/openapi-generator-cli generate \
-i https://segmentic.net/openapi.json \
-g go \
-o ./segmentic-client
```
A client generated from this document has two authentication classes, because the document has two. Give the `wk_seg_` key to the ingest operations and the `sk_seg_` key to the management ones. The other way round compiles and fails at runtime with `401`.
## Loading it into a tool {#tools}
`Postman`, `Insomnia` and `Bruno` all import straight from a URL: choose the URL option on import and give it `https://segmentic.net/openapi.json`. Create the environment variables yourself, because no key is in the document and none should be.
For contract testing, `schemathesis` works directly against the ingest surface:
```bash
schemathesis run https://segmentic.net/openapi.json \
--base-url https://in.segmentic.net \
--header "Authorization: Bearer wk_seg_..."
```
> [!warn]
> This writes real events to a real account. Create a separate app with its own key first, or your test data mixes into your real data and separating them afterwards is difficult.
## What is not in it {#gaps}
- **The panel's own API.** That surface is not for customers, is not stable, and changes weekly. What is here is what is promised to be stable.
- **Any route that mints a key.** Those live on a different listener that is not addressable from outside. Get a key from the panel; the [quickstart](/en/docs/quickstart) shows how.
- **Rules that no type can express.** That `context.screen` is accepted and stored nowhere, that a timestamp older than thirty days on `POST /v1/events` is silently pulled to the edge of the window, that an unknown export `format` becomes `ndjson`: all of these live in a field's `description` and no schema can enforce them. A generated client stops none of them.
- **What goes inside an export's `spec`.** `POST /v1/exports` passes that field through untouched and validates nothing in it. Its keys differ per `kind` and no document in this repository enumerates them. That is a real gap rather than an omission in the file.
- **Pagination**, because it does not work, and the document says so. `limit` is read only on `GET /v1/exports`, `cursor` nowhere, and `has_more` is always `false`. The two list routes are cut at 200 rows by the query underneath, ordered by most recently edited, with nothing in the response saying the rest exist.
Four routes are in the file that you never call: the open pixel, the unsubscribe page, the preference centre and the short-link redirect. Those are URLs we put inside messages, opened by a mail client or a handset. They carry `security: []`, which means "no key is needed" rather than "this is open": the signature inside the URL is the credential. Their behaviour is described in [consent and caps](/en/docs/consent).
The pagination point is one of two things the document's own `info` description states plainly. The other is that error shapes are not uniform on the management host: eleven routes reuse the panel's handlers and answer `{"error": "a string"}` rather than the coded envelope, so your client must treat `error` as either a string or an object before reading `error.code`. The details are in [error codes](/en/docs/errors).
## When the API changes {#changes}
Rule 7 of the repository says a change to these two surfaces carries its documentation in the same change, and a check in `CI` enforces it: a route that is registered and not in the reference turns the build red, apart from the four in-message URLs above and four more the script lists by name. The other direction is checked as a code sample rather than as prose, because a page is allowed to tell you a route does not exist and several usefully do. The exact rule is in [versioning and changes](/en/docs/versioning). Because this file is built from the same repository, it cannot fall behind the reference either. The full account is in [versioning and changes](/en/docs/versioning).
---
# The MCP server: connecting Segmentic to an agent
> A secure connection from AI agents to Segmentic data and tools.
> https://segmentic.net/en/docs/mcp
The MCP server lets Claude Code, Claude Desktop, Codex or Cursor read your Segmentic account: which events you actually send, what a filter would match, how a campaign performed. If the key carries write permissions it can also create and edit audiences, draft campaigns, write events and queue exports. If it carries send, it can send a transactional message or put a campaign on the wire.
The quickest route is the one we run. Point your client at `https://mcp.segmentic.net/mcp` with your own `sk_seg_` key in an `Authorization` header and you install nothing at all; the whole client snippet is under [hosted over HTTP](/en/docs/mcp#hosted). If you would rather the process ran on your own machine or inside your own network, you build it from this repository, and both transports are written out below.
> Diagram: How an AI assistant reaches permission-scoped Segmentic operations through MCP and the management API
## What it is {#what-it-is}
Two Go files in `backend/cmd/segmentic-mcp`, reading in `main.go` and writing in `write_tools.go`, about a thousand lines together, next to 666 lines of tests. It declares itself to the client as `segmentic`, version `1.0.0`, and registers at most twenty-one tools.
It speaks the Model Context Protocol over two transports and the choice is yours. **Stdio** is for running an agent on your own machine: one process per person, key from the environment. **HTTP** is for standing it up once and handing everybody else a URL, where each caller's key arrives in their own `Authorization` header.
Every tool is an HTTP call to the management host, `https://api.segmentic.net`, carrying the same `sk_seg_` key you would put in a curl command.
## Why it is a client of the API and not a database connection {#security}
The design comment at the top of the file is the whole argument, and it is worth reading before you decide whether to give an agent a key:
```text
It is a client of the public REST API, not a second way into the database.
That is the whole security design: every permission gate, every tenant scope,
every budget already lives at the HTTP edge, and a tool here cannot reach
past them because it has no other door. An MCP server holding a database
handle would be a second admission path, and the second one is always the one
that forgets a check.
```
The practical consequence: the MCP server grants an agent nothing that the key does not already grant a curl loop. If you want to know what an agent can do, read [what the key carries](/en/docs/api/management), not this page. Revoking the key stops the agent, immediately, because the tools have no other credential.
The reverse consequence is the one people miss. Because a tool is an ordinary API call, an agent holding the key is not confined to the tools. It can call any endpoint the key permits with `curl`, including endpoints the MCP server exposes no tool for. The tool list is a convenience, not a sandbox.
## Getting the binary {#install}
Nothing is published. There is no GitHub release, no image of its own, no npm package, no Homebrew formula, no installer, and no download link in the panel. There is also no pre-built macOS or Windows binary anywhere.
### Building it yourself {#build-it}
You need a copy of the backend repository and Go `1.26.4` or newer.
```bash title="Build the MCP server"
cd backend
go build -o segmentic-mcp ./cmd/segmentic-mcp
```
On Windows, name the output `segmentic-mcp.exe`. To cross-compile for a Mac from a Linux or Windows machine:
```bash title="Cross-compile for Apple silicon"
cd backend
GOOS=darwin GOARCH=arm64 go build -o segmentic-mcp-darwin-arm64 ./cmd/segmentic-mcp
```
Dependencies are vendored, so the build needs no network for modules. It does need the Go toolchain itself, and `proxy.golang.org` answers 403 to toolchain downloads from Iranian addresses, which is why the repository's own Dockerfile sets `GOPROXY=off GOFLAGS=-mod=vendor`. Install the toolchain from a mirror before you try.
There is no Makefile target for this binary. `make build` compiles it and discards the output, which is why the command above passes `-o` explicitly.
### Running it out of the backend image {#from-the-image}
The backend image builds every command directory, so `/usr/local/bin/segmentic-mcp` exists inside `ghcr.io/segmentic1/segmentic-backend:latest`. It got there by a wildcard in the Dockerfile rather than by intent, and it is a static Linux amd64 build, so it will not run natively on macOS or Windows.
It works as an MCP command because the image declares no default command and stdio passes through with `-i`:
```bash title="Run the server from the image"
docker run -i --rm \
-e SEGMENTIC_API_URL=https://api.segmentic.net \
-e SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
ghcr.io/segmentic1/segmentic-backend:latest segmentic-mcp
```
The image is private. You need a `docker login ghcr.io` with a token that can read it.
## The key {#key}
The server needs a management key, `sk_seg_...`. Mint one in the panel at **Settings, Connections and integrations, API keys**, which is `https://app.segmentic.net/en/settings/keys`. Creating one needs the `apikey.write` permission on your own account.
The form has three fields: a name, a role, and how long it stays valid (30 days, 90 days or one year, defaulting to one year). A key with no expiry cannot be created through this route.
The plaintext is shown once, in the response to the create call and in a box on the screen. After that only a SHA-256 hash and a 14 character display prefix exist. Lose it and you mint another one.
```json title="POST /v1/team/keys, 201"
{
"id": 12,
"name": "agent-readonly",
"prefix": "sk_seg_ABCDEF",
"role": "viewer",
"role_label": "Viewer",
"created_by": "Maryam",
"created_at": "2026-08-07T18:00:00Z",
"expires_at": "2027-08-07T18:00:00Z",
"key": "sk_seg_yqk7..."
}
```
The role you choose decides which tools exist, because tools are registered from the key's permissions. Owner is refused outright with `owner_key_forbidden`: no key may ever be an owner, because deleting the account is not something an integration should be able to do at three in the morning. You also cannot mint a key for a role above your own.
| Role you pick | Tools the agent sees | Can it write | Can it send |
|---|---|---|---|
| `viewer` | 11 | no | no |
| `approver` | 11 | no | no |
| `analyst` | 13 | no, but it can export | no |
| `marketer` | 21 | yes | yes |
| `admin` | 21 | yes | yes |
| `finance` | 2, `segmentic_whoami` and `segmentic_capabilities` | no | no |
| `owner` | cannot be minted | n/a | n/a |
These counts are asserted in `cmd/segmentic-mcp/registration_test.go`, so adding or moving a tool turns this table red in the build rather than in a customer's hands.
`analyst` differs from `viewer` by the two export tools, and those two are the only things here that carry email addresses and phone numbers out of the building. If the agent is only meant to read and report, mint a `viewer`.
> [!warn]
> Do not use a write key. A `wk_seg_` key ships inside your JavaScript bundle and your app, so it is public by construction, and it can only write events. The server refuses to start on one and says so before making a single request.
## The key decides, not a flag {#read-only}
The tool list is built from the key's permissions. A `viewer` key gets eleven reading tools and nothing else. A key with `segment.write` also gets the tools that create and edit audiences. A key with `campaign.send` gets the two that reach real people.
There is no `--allow-write` flag and no `--allow-send` flag, deliberately: the decision belongs to whoever holds the account, not to a command line an operator can mistype or leave switched on.
This section used to say "read only by default". That was true of the tool list and was never true of the credential, and the difference is the thing this page has to be blunt about:
> [!warn]
> `campaign.send` is carried only by `marketer` and `admin`, and both also carry `segment.write`, `campaign.write`, `journey.publish`, `template.write` and `profile.write`. So **an unnarrowed send-capable key is a fully write-capable credential**, whether or not the MCP server offers tools for it: all of it is reachable with curl. Withholding the tools never withheld the capability. It only moved the work to a path with no argument validation, no confirmation on the irreversible calls, and no record of what was attempted.
### Scoped keys, which can now actually be minted {#scoped-keys}
The fix is to narrow the key, not to hide the tools. The `scopes` column has been on `api_keys` since migration 016, and the read path has always intersected it into the effective permission set, but nothing ever wrote it. Now it does:
```json title="POST /v1/team/keys"
{
"name": "agent-authoring",
"role": "marketer",
"scopes": ["segment.read", "segment.write", "event.read"]
}
```
That key creates and edits audiences and can never send, through the MCP or through curl. Its effective permissions are the role intersected with this list, so a scope can only ever take away: a permission the role does not hold cannot be scoped in, and the server refuses it with `scope_exceeds_role`.
| What you send | What happens |
|---|---|
| no `scopes` field | the key carries its whole role, which is the behaviour every existing key has |
| a list of permissions inside the role | the key carries exactly those |
| an empty list `[]` | refused with `scopes_empty`. A key that can do nothing is indistinguishable from a mistake |
| a permission that does not exist | refused with `unknown_permission`, and the offending string is named |
| a permission outside the role | refused with `scope_exceeds_role` |
`segmentic_whoami` reports `scoped` and the effective `permissions`, so the agent knows what it can do before it tries.
The panel's key form has no permission picker yet, so a scoped key is minted from the API for now. Until that form ships, a `curl` with your own session is the shortest path.
## Configuration {#configure}
The binary reads two environment variables and takes three flags. A flag wins over the environment variable, because the environment value is only the flag's default.
| Flag | Environment variable | Default | What it is |
|---|---|---|---|
| `-api` | `SEGMENTIC_API_URL` | `http://localhost:8082` | Base URL of the management API. Trailing slashes are stripped. |
| `-key` | `SEGMENTIC_API_KEY` | none | The `sk_seg_` key. Absent is fatal. |
| `-timeout` | none | `30s` | Per-request HTTP timeout. There is no environment variable for this one. |
Prefer the environment variable over `-key`. A flag value sits in the process table where every other process on the machine can read it.
Set `-timeout` to `60s` if you run reports, and know that it is only half the fix. The report handlers allow themselves 45 seconds, but the HTTP server's own `WRITE_TIMEOUT` defaults to 30 seconds and the client defaults to 30 as well, so a heavy retention query is cut off from two directions while it keeps running server-side and the 25 budget units are already spent. Raising the client timeout helps only if whoever operates your deployment also raises `WRITE_TIMEOUT`.
### Claude Code {#claude-code}
Project file `.mcp.json` at the root of your repository:
```json title=".mcp.json"
{
"mcpServers": {
"segmentic": {
"command": "/absolute/path/to/segmentic-mcp",
"args": ["-timeout", "60s"],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
```
The key under `mcpServers` becomes the namespace, so the tools appear as `mcp__segmentic__segmentic_whoami` and so on. Keep it `segmentic`, matching the name the server declares.
The same thing from the command line:
```bash title="claude mcp add"
claude mcp add segmentic --scope project \
--env SEGMENTIC_API_URL=https://api.segmentic.net \
--env SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
-- /absolute/path/to/segmentic-mcp -timeout 60s
```
Everything after `--` is the command and its own arguments, so `-timeout` reaches the binary rather than the CLI.
To run it from the image instead of a local binary:
```json title=".mcp.json, from the container image"
{
"mcpServers": {
"segmentic": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "SEGMENTIC_API_URL",
"-e", "SEGMENTIC_API_KEY",
"ghcr.io/segmentic1/segmentic-backend:latest",
"segmentic-mcp"
],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
```
### Claude Desktop {#claude-desktop}
`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows.
```json title="claude_desktop_config.json"
{
"mcpServers": {
"segmentic": {
"command": "/absolute/path/to/segmentic-mcp",
"args": ["-timeout", "60s"],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
```
On Windows the path is absolute and the backslashes are doubled:
```json title="claude_desktop_config.json on Windows"
{
"mcpServers": {
"segmentic": {
"command": "C:\\Program Files\\Segmentic\\segmentic-mcp.exe",
"args": [],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
```
Claude Desktop does not inherit your shell `PATH`, so `command` must be an absolute path.
### Codex {#codex}
`~/.codex/config.toml`:
```text title="~/.codex/config.toml"
[mcp_servers.segmentic]
command = "/absolute/path/to/segmentic-mcp"
args = ["-timeout", "60s"]
[mcp_servers.segmentic.env]
SEGMENTIC_API_URL = "https://api.segmentic.net"
SEGMENTIC_API_KEY = "sk_seg_REPLACE_ME"
```
### Cursor {#cursor}
`.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` globally.
```json title=".cursor/mcp.json"
{
"mcpServers": {
"segmentic": {
"command": "/absolute/path/to/segmentic-mcp",
"args": ["-timeout", "60s"],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
```
### Hosted over HTTP {#hosted}
Stand it up once and nobody else installs anything:
```bash
segmentic-mcp -http :8090 -api https://api.segmentic.net
```
`-http` and `-key` are mutually exclusive and passing both is refused. That is
design rather than fussiness: one process serves many accounts, so the credential
has to arrive per request and the tool list has to be built from THAT key's
permissions. A `-key` on a hosted server is one account's credential answering
for everybody.
On the client side, a URL and a key instead of a command:
```json title="client configuration"
{
"mcpServers": {
"segmentic": {
"url": "https://mcp.segmentic.net/mcp",
"headers": { "Authorization": "Bearer sk_seg_..." }
}
}
}
```
Four things make this safe to host, and each is worth saying:
- **The process holds no credential of its own.** There is nothing in it to steal.
- **The key is read from the header only, never from the query string.** A key in
a URL lands in every access log and every referrer header.
- **SDK write keys are refused.** A `wk_` key ships inside every customer's
JavaScript bundle, so accepting one would mean an agent could be pointed at a
credential lifted from a page source.
- **Permissions are enforced by the API on every call.** A key's identity is
cached for about two minutes, and that cache only decides which tools are
LISTED; a revoked key fails on its very next call with the API's own 401.
There is a `GET /healthz`, deliberately not behind the key: a probe that needs a
credential is a probe somebody turns off.
### Local development {#local}
The default base URL is `http://localhost:8082`, which is the port the public API listens on, so locally you can omit it:
```json title=".mcp.json against a local stack"
{
"mcpServers": {
"segmentic-local": {
"command": "/absolute/path/to/segmentic-mcp",
"env": { "SEGMENTIC_API_KEY": "sk_seg_LOCAL_KEY" }
}
}
}
```
> [!warn]
> `PUBLIC_API_ADDR` is empty by default, and an empty value means the public API is not served at all. An install that has not decided to expose one should not be exposing one. If you start the API without setting it, nothing listens on 8082 and the MCP server exits with a connection refused message. The dashboard's own port, 8081, is not a substitute: it is a different mux and it does not carry `/v1/whoami` in this shape.
## Verifying an installation without an agent {#verify}
The server speaks JSON-RPC over stdio, so you can prove the key and see the tool count with no client at all. Run it in a terminal:
```bash title="Start it by hand"
SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
SEGMENTIC_API_URL=https://api.segmentic.net \
./segmentic-mcp
```
It calls `GET /v1/whoami` before registering anything, then prints one line to standard error and waits for a client on standard input:
```text
2026/08/07 21:04:11 segmentic-mcp 1.0.0, tenant 7, role viewer, 8 permissions
```
Failing at startup rather than on the first tool call is deliberate: an agent that discovers its credential is wrong halfway through a conversation reports it as "the tool is broken", and the human never sees the real reason.
A bad key exits 1 with one of these on standard error:
```text
segmentic-mcp: no API key: set SEGMENTIC_API_KEY or pass -key
```
```text
segmentic-mcp: that is an SDK write key (wk_…), which cannot read anything.
This needs a management key (sk_seg_…) from Settings → API keys.
```
```text
segmentic-mcp: could not reach the Segmentic API: unauthenticated: a valid API key is required
```
The write-key guard fires on the prefix `wk_`, before any request goes out. The comment explains why it is a separate message rather than a 401: a write key ships inside every customer's JavaScript bundle, so accepting one here would mean an agent could be pointed at a credential lifted from a page source, and failing now with a sentence that explains the difference beats failing later with a 401.
## The tools {#tools}
The exact set depends on the key's permissions. Each subsection gives the permission that gates a tool, the arguments, the endpoint it calls, the budget it spends, and what comes back.
Results are always one text block containing indented JSON. There is no structured output schema, so a client that expects `structuredContent` gets none. Failures come back as tool content with `isError: true`, not as a protocol error, so the model reads the reason and can act on it.
| Tool | Permission | Endpoint | Budget |
|---|---|---|---|
| `segmentic_whoami` | none | none, cached at startup | 0 |
| `segmentic_capabilities` | none | `GET /v1/capabilities` | 1 |
| `segmentic_describe_data` | `event.read` | `GET /v1/schema/events` and `GET /v1/schema/traits` | 10 |
| `segmentic_ingest_quality` | `event.read` | `GET /v1/ingest/quality` | 5 |
| `segmentic_get_audience` | `segment.read` | `GET /v1/segments/{id}` | 1 |
| `segmentic_create_audience` | `segment.write` | `POST /v1/segments` | 1 |
| `segmentic_update_audience` | `segment.write` | `PUT /v1/segments/{id}` | 1 |
| `segmentic_delete_audience` | `segment.delete` | `GET` then `DELETE /v1/segments/{id}` | 2 |
| `segmentic_create_campaign` | `campaign.write` | `POST /v1/campaigns` | 1 |
| `segmentic_submit_campaign_for_approval` | `campaign.write` | `POST /v1/campaigns/{id}/submit` | 1 |
| `segmentic_set_campaign_recurrence` | `campaign.send` | `GET` then `PUT /v1/campaigns/{id}/recurrence` | 6 |
| `segmentic_clear_campaign_recurrence` | `campaign.send` | `DELETE /v1/campaigns/{id}/recurrence` | 1 |
| `segmentic_send_campaign` | `campaign.send` | `GET` then `POST /v1/campaigns/{id}/send` | 6 |
| `segmentic_ingest_events` | `profile.write` | `POST /v1/events` | 10 |
| `segmentic_queue_export` | `data.export` | `POST /v1/exports` | 25 |
| `segmentic_list_exports` | `data.export` | `GET /v1/exports` | 1 |
| `segmentic_list_audiences` | `segment.read` | `GET /v1/segments` | 1 |
| `segmentic_describe_audience` | `segment.read` | `POST /v1/audiences/validate` | 1 |
| `segmentic_count_audience` | `segment.read` | `POST /v1/audiences/count` | 25 |
| `segmentic_list_campaigns` | `campaign.read` | `GET /v1/campaigns` | 1 |
| `segmentic_campaign_report` | `campaign.read` | `GET /v1/campaigns/{id}` | 5 |
| `segmentic_funnel_report` | `analytics.read` | `POST /v1/reports/funnel` | 25 |
| `segmentic_retention_report` | `analytics.read` | `POST /v1/reports/retention` | 25 |
| `segmentic_send_transactional_message` | `campaign.send` | `POST /v1/messages` | 1 |
### segmentic_whoami {#whoami}
Registered always, even for a key that carries nothing.
No arguments. It makes no HTTP call: the answer was fetched once at startup and is returned from memory, so it costs nothing and cannot fail. It also cannot notice that the key was revoked five minutes ago.
```json title="What it returns"
{
"tenant_id": 7,
"role": "analyst",
"permissions": [
"analytics.read", "audit.read", "campaign.read", "data.export",
"event.read", "journey.read", "member.read", "profile.read",
"segment.read", "settings.read", "template.read"
],
"scoped": false
}
```
`permissions` is the effective set, sorted. It is eleven long and not four, because the analyst role reads everything; the set is in `backend/internal/auth/role.go`. The tool list you see is shorter than that, because the MCP server has only written tools for some of those permissions. `scoped` is meant to say the key was narrowed below its role, but it is `false` on every key this product can mint: the scopes column exists on `api_keys` and no route writes it. No email address, no person's name, no tenant slug, and no key id: the API returns `api_key_id` and this tool drops it.
It does not report remaining budget, deployment capabilities, or a server mode. Those do not exist in the response.
### segmentic_describe_data {#describe-data}
Gate `event.read`. No arguments. Two sequential GETs, so one call spends 10 budget units; if the first fails the second is never made and the whole tool errors.
This is the tool that exists because of the single most expensive mistake an agent makes here. From the file:
```text
It invents identifiers. describe_data exists so a segment id or an event
name can be looked up rather than guessed; a guessed event name compiles
cleanly and returns an empty audience, which reads as "nobody matches"
rather than as "that event does not exist".
```
The two server payloads are nested one level deeper than their own bodies:
```json title="What it returns"
{
"events": {
"events": [
{
"name": "order_completed",
"volume": 184203,
"prop_keys": ["revenue", "order_id", "currency"],
"last_seen": "2026-08-06"
}
]
},
"traits": {
"traits": ["city", "order_count", "plan"],
"schema": [
{"name": "city", "kind": "string", "users": 91204},
{"name": "order_count", "kind": "number", "users": 58110}
]
}
}
```
`last_seen` is the column worth reading. An event with a large volume and a last seen date three weeks ago is an integration that broke, and no other figure says so, because volume alone looks healthy for a month afterwards: the window is ninety days.
`prop_keys` and `last_seen` are omitted when empty. `kind` is `string` or `number`; a trait sent as both appears once, as `number`. `users` is how many profiles carry the trait. The tool's own description says "with volumes" for traits, which is loose: trait rows carry a profile count, not an event volume.
An account that has sent nothing gets `{"events":[]}` and `{"traits":[]}`.
### segmentic_list_audiences {#list-audiences}
Gate `segment.read`. Cost 1.
Despite the name, this returns saved segments, the objects the panel calls سگمنت. It calls `GET /v1/segments`, which is registered only when the deployment serves segments.
It takes no arguments.
> [!warn]
> What comes back is not every segment. The store query is `ORDER BY updated_at DESC LIMIT 200`, so it is the 200 most recently updated and nothing else. The cap is silent: no count, no `has_more`, no warning, and an account holding 250 segments has no route on any surface to the other 50. The tool's own description now tells the model exactly this, and draws the conclusion: on a large account, treat a name you cannot find here as unknown rather than as absent. Two hundred whole filter trees can still be large; if the payload exceeds the client's 4 MiB response cap the tool reports `unreadable response (200)` with the first 200 characters.
>
> Until recently this tool took `limit` and `cursor`, and neither did anything: the handler never reads the query string, and no response ever carries a `next_cursor`. An argument that does nothing is worse than no argument, because the model believes it and asks for page two. Both were removed.
```json title="What it returns"
{
"segments": [
{
"id": 41,
"name": "Recent purchasers",
"kind": "dynamic",
"definition": {
"version": 1,
"root": {
"kind": "event",
"event": "order_completed",
"window": {"kind": "last", "amount": 30, "unit": "day"}
}
},
"description_fa": "کاربرانی که شهرشان تهران است",
"last_size": 18422,
"last_computed_at": "2026-08-06T21:00:00Z",
"updated_at": "2026-08-01T11:12:00Z"
}
]
}
```
`kind` is `dynamic`, `static` or `realtime`. `definition` is the whole filter tree, not a summary. `description_fa` is Persian, always, on every response from this API: the public routes carry no locale middleware, so `Accept-Language` changes nothing and the MCP server sends no such header anyway.
### segmentic_describe_audience {#describe-audience}
Gate `segment.read`. Cost 1. Calls `POST /v1/audiences/validate`.
One required argument, `filter`, and it must be a whole definition, `{"version": 1, "root": {...}}`, not a bare condition. The tool description sells it as the cheap rehearsal before an expensive count: it runs no query, returns nothing about people, and tells you in a sentence what the filter says.
```json title="Arguments"
{
"filter": {
"version": 1,
"root": {"kind": "trait", "trait": "city", "operator": "eq",
"value": {"type": "string", "str": "تهران"}}
}
}
```
```json title="200"
{
"valid": true,
"description_fa": "کاربرانی که شهرشان تهران است"
}
```
Two fields, and that is all. There is no English description, no machine-readable problem list, and no report of how far back the filter reaches.
An invalid filter is 422, not a 200 with `valid: false`. The dashboard answers 200 with `valid:false`, which is right for a live-typing form and wrong for an integration whose error handling branches on status.
```json title="422"
{"error": {"code": "filter_invalid", "message": "segment: unknown node kind"}}
```
That one the model sees in full, as `filter_invalid: segment: unknown node kind`.
### segmentic_count_audience {#count-audience}
Gate `segment.read`. Cost 25, the heaviest weight there is. Calls `POST /v1/audiences/count` with the same `filter` argument as above.
```json title="200"
{
"count": 18422,
"approximate": false,
"description": "کاربرانی که شهرشان تهران است",
"took_ms": 812
}
```
A number, a Persian sentence and a duration. Nothing else, and the tool description says so in capitals so a model does not go looking for a list.
At the default budget of 600 units per key per minute this is 24 counts in a minute before the API answers `budget_exhausted`. That ceiling is the only brake. There is no refusal for an event condition with no time window, and no per-account concurrency limit on counting.
### segmentic_list_campaigns {#list-campaigns}
Gate `campaign.read`. Cost 1. Calls `GET /v1/campaigns`.
It takes no arguments. Exactly as with `segmentic_list_audiences`, the store query carries a fixed `LIMIT 200`: the 200 most recently updated campaigns, with no `next_cursor` and nothing in the response saying a 201st exists. The tool's description now states that cap to the model outright. It used to claim "Paginated; the page size is capped by the server" and to take two arguments that did nothing; the second half of that sentence was true and the first was not.
```json title="What it returns"
{
"campaigns": [
{
"id": 812,
"name": "July win-back",
"channel": "sms",
"status": "finished",
"estimated": 60000,
"processed": 60000,
"sent": 41230,
"scheduled_at": "2026-07-14T06:30:00Z",
"updated_at": "2026-07-14T09:11:00Z"
}
]
}
```
No recipient identifiers of any kind.
### segmentic_campaign_report {#campaign-report}
Gate `campaign.read`. Cost 5. Calls `GET /v1/campaigns/{id}` with one required argument, `campaign_id`, whose schema description tells the model to take it from `segmentic_list_campaigns` rather than invent it.
The payload has five sections: the campaign itself including its variants, progress, a reach breakdown, a delivery breakdown, engagement per channel, and the uplift against the control group.
```json title="Engagement and uplift, the parts that need care"
{
"engagement": [
{
"channel": "sms",
"channel_fa": "پیامک",
"issued": 41230,
"withheld": 180,
"measurable_open": 0,
"measurable_click": 41230,
"opened": 0,
"clicked": 5120,
"opened_unmeasurable": 41230,
"clicked_unmeasurable": 0,
"why_open": "پیامک رسید خواندن ندارد"
}
],
"uplift": {
"verdict": "positive",
"goal": "order_completed",
"treated_users": 41230,
"treated_conversions": 2110,
"control_users": 4581,
"control_conversions": 190,
"lift": 0.117,
"lift_low": 0.041,
"lift_high": 0.194,
"extra": 1420,
"extra_low": 812,
"extra_high": 2030,
"money_known": true,
"computed_at": "2026-07-21T09:30:00Z"
}
}
```
Every rate arrives as a numerator with a named denominator. `opened` is meaningless without `measurable_open`, and for SMS `measurable_open` is zero, because an SMS has no read receipt. A campaign whose message carried no link is not a campaign with a zero click rate. The tool description instructs the model to quote both numbers and never to collapse them into one percentage, for exactly this reason.
`lift` on its own is not a measurement, it is the middle of a range. `lift_low` and `lift_high` are the range, and a report that quotes the point estimate alone has thrown away the only thing that says whether the campaign worked.
A finished campaign whose seven-day measurement window has not closed returns an uplift section with `verdict: "too_early"` and little else filled in. It is synthesised on purpose, because otherwise the section simply vanishes for a week after every send, which reads as a missing feature rather than as "we are still counting".
The description calls one of these "the A/B verdict". That is loose. `uplift.verdict` is the holdout verdict, treated against control. Variants are present inside `campaign.variants`, but there is no per-variant conversion comparison in this payload and there is no separate experiment tool.
An unknown id and another account's id both give 404, which the model sees as bare `http 404`.
### segmentic_funnel_report {#funnel-report}
Gate `analytics.read`. Cost 25. Behind the billing lock, so an account past its allowance is refused here with `account_locked` as well as in the panel.
Its arguments are `steps` (an array of step objects), `from` and `to` (RFC3339, both required), `window` (required) and `strict` (optional). The server wants between 2 and 12 steps and a range of at most 730 days.
`window` is how long somebody has to finish the whole funnel, as a duration string: `7d`, `24h`, `30m`. The server has no default for it and refuses a request without one, for the plain reason that a funnel measured over thirty days and the same funnel over one hour are different questions. It must not be longer than the range itself.
`strict` requires the steps with no other event in between. It is off by default, which is what "who viewed a product and bought it" usually means.
> [!warn]
> Until recently this tool could not succeed at all: its argument struct had no `window` field, so the body never carried one and the server rejected every request. The model was told only `http 400`, with no message, after the 25 units were already spent. If your compiled copy is old, rebuild it from the repository; the other route is `POST /v1/reports/funnel` directly, documented in [Reports](/en/docs/reports).
### segmentic_retention_report {#retention-report}
Gate `analytics.read`. Cost 25. Behind the same billing lock. Calls `POST /v1/reports/retention`.
Its arguments are `from` and `to` (required) and four optional ones: `start`, `return`, `granularity` and `periods`.
`start` and `return` are separate because "came back" rarely means "did the same thing again". A shop wants to know who signed up and then **bought**; asking whether they opened the app flatters the number and answers nothing. Leaving either empty means "any activity".
`granularity` is one of `day`, `week` or `month`. Weeks start on Saturday and months are Jalali. It defaults to `day`.
> [!warn]
> Until recently this tool answered a different question from the one it was asked, and did not error: it sent an `event` field, the server's retention request has `start` and `return` and ignores an unknown field rather than refusing it. So whatever event the model named was silently dropped and every call measured "any activity, then any activity again". It never sent `granularity` or `periods` either. A plausible but wrong answer is worse than an error. If your compiled copy is old, rebuild it from the repository.
### segmentic_send_transactional_message {#send-message}
Gate `campaign.send`. Cost 1 against the request budget. Calls `POST /v1/messages`, which is registered only when the deployment has a sending path configured.
This is the only tool that reaches a person. Its description leads with that, in capitals, and then names the alternative in the same breath:
```text
Send ONE message to ONE named person, immediately, an order update, a delivery
notice, a login code. THIS REACHES A REAL PERSON'S PHONE OR INBOX AND CANNOT BE
RECALLED. Marketing is refused: use a campaign, which a human schedules. The
idempotency_key must identify the real-world event (for example
'order-8821-shipped'), so that retrying is safe; never invent a random one,
because a fresh key on a retry sends a second message.
```
| Argument | Required | Notes |
|---|---|---|
| `user_id` | yes | The person, by the id their app reports |
| `channel` | yes | See the warning below |
| `template_id` | yes | A stored template. Message text cannot be passed inline |
| `idempotency_key` | yes | Matches `^[A-Za-z0-9._:-]{8,200}$` |
| `category` | no | `transactional` (the default) or `critical`. `marketing` is refused |
| `vars` | no | At most 40 keys, all string values |
```json title="Arguments"
{
"user_id": "u_9137",
"channel": "sms",
"template_id": 42,
"vars": {"code": "8391"},
"idempotency_key": "order-8821-shipped"
}
```
```json title="200"
{
"message_id": "t7.order-8821-shipped",
"status": "sent",
"sent_at": "2026-08-07T18:22:31.114Z"
}
```
> [!warn]
> The channel list in the tool's schema is wrong in one place. It offers `web`, and this endpoint does not accept `web`. The accepted values are exactly `push`, `sms`, `email`, `webpush`, `inapp`, `bale`, `eitaa` and `rubika`, compared literally with no alias resolution. A model that follows the description and sends `web` gets `400 transactional: unknown channel`, which it sees as bare `http 400`.
> [!warn]
> A 200 does not mean a message went out. When `status` is `suppressed` and `reason` is set, the message was deliberately not sent: an unsubscribe, a channel opt-out, a suppression, or no address on file. The tool's description does not say this, so an agent will report a success. Check `status` and `reason` before telling anybody the notice was delivered.
```json title="200, and nobody was messaged"
{
"message_id": "t7.order-8821-shipped",
"status": "suppressed",
"reason": "channel_opt_out",
"reason_fa": "این کانال را خاموش کرده است",
"sent_at": "2026-08-07T18:22:31.114Z"
}
```
`replayed: true` marks a response served from the idempotency ledger rather than a fresh send. It is how you tell "we already did this" apart from "we just did this", which matters when the first attempt timed out and you do not know which happened.
The same key in flight gives 409 with `Retry-After: 1`. A message that was sent but whose ledger row failed to write still returns 200, because a 5xx would make the caller retry and send a second one, which is worse than the lost row being reported.
What guards this call, exactly: the permission on the key, the four required arguments the protocol enforces before the handler runs, an explicit refusal of an empty idempotency key, and the server's own idempotency ledger and marketing refusal. There is no two-phase confirmation, no confirm token, no per-session send cap, and no per-key recipient budget. The model authors the idempotency key itself.
## The authoring tools {#write-tools}
Every one of these calls a public API route. The recurrence tools and their routes were added together, while the other tools expose routes that were already present.
Three rules hold across this half. **One**, the body is built from the server's own request type rather than a hand written map, so renaming a field in the backend stops this file compiling. The class of bug that broke two of the reporting tools is not available here. **Two**, validation runs twice on purpose, once locally so the model gets a sentence it can act on and once at the server, which is where the decision actually is. **Three**, the two irreversible acts ask for the object's own name back.
### segmentic_create_audience and segmentic_update_audience {#write-audiences}
`segment.write`. Creating one sends nothing: an audience is a saved question, and campaigns and journeys point at it later.
The filter must be a whole definition, `{"version": 1, "root": {...}}`. Pass a bare condition and the tool refuses before it reaches the server, and tells you how to wrap it. That is the commonest mistake, and the server's own answer to it (`unknown node kind: ""`) tells nobody what to do.
> [!warn]
> An update replaces, it does not merge. The whole definition becomes what you send, so read it first with `segmentic_get_audience`. Every campaign and journey pointing at that audience uses the new filter from that moment.
### segmentic_delete_audience {#delete-audience}
`segment.delete`. Reads the audience first, compares `confirm_name` against its real name, and does nothing if they differ. A journey or campaign that points at it is not deleted with it.
### segmentic_create_campaign {#create-campaign}
`campaign.write`. **Always creates a draft**, whatever else you pass. Creating and sending are two calls because one of them is reversible.
The audience is either `segment_id` or `filter`, and passing both or neither is refused here: two audiences on one campaign is not something the server can resolve, and guessing which was meant is how the wrong people get messaged. `template_id` is required, because message text cannot be passed inline.
### segmentic_submit_campaign_for_approval {#submit-campaign}
`campaign.write`. On accounts that require approval, puts a draft into the review queue. Nothing is sent. The approval is fingerprinted against the campaign as it stands, so editing it afterwards means submitting again.
### segmentic_set_campaign_recurrence {#set-campaign-recurrence}
`campaign.send`. Starts or replaces a campaign's repeat schedule. It reads the campaign first and requires its exact current name in `confirm_name`. A schedule can create future campaigns for the whole audience, so the same protection used for an immediate send applies here.
The `recurrence` object uses `daily`, `weekly` or `monthly` cadence. `hour` and `hours` are Tehran hours. Saturday is weekday `0`, and monthly dates are Jalali. The tool validates all of this locally before calling the API. The read costs 5 units and the write costs 1, for a total of 6.
### segmentic_clear_campaign_recurrence {#clear-campaign-recurrence}
`campaign.send`. Stops future automatic repeats. It does not delete or change campaigns already created by the schedule. This call costs 1 unit and needs only a `campaign_id` from `segmentic_list_campaigns`.
### segmentic_send_campaign {#send-campaign}
`campaign.send`. This reaches **everyone** in the campaign's audience and cannot be recalled.
It reads the campaign first and compares its name to `confirm_name`. If they differ, nothing is sent. If the name cannot be read at all, nothing is sent either: the failure direction is deliberately closed, because the campaign will still be there in five minutes and the messages will not come back.
> [!danger]
> Before this call, get the recipient count with `segmentic_count_audience` and tell the person who asked. The difference between four hundred people and four hundred thousand is one wrong filter.
### segmentic_ingest_events {#ingest-events}
`profile.write`. The same door a customer's own backend posts to, which makes it the way to get facts in without a browser SDK.
A 200 means the request was accepted, not that every event was. The `rejected` array names each refused item by its index in your batch and why. Give every event a stable `message_id` derived from the real world fact, or a retry counts it twice.
### segmentic_queue_export and segmentic_list_exports {#exports}
`data.export`. The only tools whose output leads to personal data, meaning email addresses and phone numbers. The file is collected from the panel rather than from here, so queueing one produces something a person has to come and fetch. Both are behind the soft lock.
## Why a missing permission hides a tool instead of refusing it {#registration}
Tools are registered from what the key can do, not from what the API offers:
```text
Registered conditionally on what the KEY can do, not on what the API
offers. A tool an agent can see is a tool it will try, and a refusal it
cannot fix reads to the model as a fault worth retrying, so the honest
move is not to offer it.
```
Three things follow, and all three surprise somebody eventually.
1. The tool list is fixed when the process starts. It is built from the `whoami` answer fetched before the server was created. Revoking a permission, or the whole key, does not change the advertised list until you restart the process; the calls simply start failing at the API.
2. Absence, not refusal. A permission the key lacks means the tool never appears in `tools/list`, so the model cannot see it, cannot try it, and cannot mistake a 403 for something worth retrying.
3. The gate is the key, not the deployment. The server never asks `GET /v1/capabilities`. A key with `campaign.read` on an install that does not serve campaigns still gets `segmentic_list_campaigns`, and the tool answers `unknown_endpoint: no such endpoint: GET /v1/campaigns, see GET /v1/capabilities`.
`segmentic_whoami` sits outside every condition, so a key scoped to nothing still exposes exactly one tool.
## What the tools never return {#never-returned}
No tool returns an email address or a phone number. Not one.
| Tool | Personal data in its response |
|---|---|
| `segmentic_whoami` | none. No email, no name, no key id, no account slug |
| `segmentic_describe_data` | aggregates: event names, volumes, property keys, trait names and counts. Never a property value |
| `segmentic_ingest_quality` | counts of what ingest refused or corrected, by day, code, app and client library. Never a value, an identifier or an error string |
| `segmentic_list_audiences` | segment names, descriptions, filter trees and sizes. A filter can embed a literal a marketer typed, a city or a plan name, but no person is named |
| `segmentic_describe_audience` | one boolean and one Persian sentence. It runs no query |
| `segmentic_count_audience` | a number, a sentence, a duration |
| `segmentic_list_campaigns` | campaign metadata and totals |
| `segmentic_campaign_report` | aggregates only: every row is a count grouped by a reason or a channel |
| `segmentic_funnel_report` | level counts |
| `segmentic_retention_report` | a grid of counts |
| `segmentic_send_transactional_message` | echoes the `user_id` you supplied. No address, no rendered body |
The stronger guarantee is not a filter, it is absence. There is no tool for a profile, no timeline tool, no segment preview tool, no export tool and no search. The one internal handler that returns names, email addresses, phone numbers and cities for real people is registered on the dashboard's own listener and is not reachable from this API at all. The MCP server has no enumeration primitive of any kind, because the endpoints that would give it one are simply never called.
> [!warn]
> One thing is not guaranteed. Text your own team wrote reaches the model unfenced: segment names, campaign names and the Persian descriptions arrive as plain JSON strings with no untrusted-content wrapper. If somebody names a segment with an instruction aimed at a model, the model reads it as ordinary text. Treat the panel's free-text fields as an input channel to your agent.
## What an agent gets wrong, and what the descriptions do about it {#steering}
The file names three failure modes. Two of the countermeasures are real and one is not, and it is worth knowing which is which.
**It invents identifiers.** Countered by wording, only. `segmentic_describe_data` says ALWAYS call this first and explains the consequence. `segmentic_list_audiences` says find an id rather than guessing one. `segmentic_funnel_report` says step names must come from `segmentic_describe_data`. The `campaign_id` and `filter` argument descriptions repeat it. Nothing enforces any of it: a filter naming an event that does not exist compiles cleanly and returns zero, which looks exactly like a real audience of zero. There is no session state, no precondition, and no did-you-mean suggestion.
**It reads more than it needs.** The stated countermeasure is server-side paging with a ceiling the model cannot raise. Half of that is in the code. The ceiling is real and the model genuinely cannot raise it, because it is a fixed `LIMIT 200` in SQL, but the paging is not, and there is no route to the 201st row. What was done about it was to stop the tools pretending otherwise: the arguments that did nothing were removed, and both tool descriptions now state the ceiling. The countermeasure that is real is the second half of the sentence, that no tool returns contact data, and it is real because those tools were never written.
**It loops.** This one holds. The budget is weighted and enforced server-side, per key and per minute, so a model retrying a heavy report is refused by the API rather than by good manners in the client. The budget is per key on purpose: a customer issues one narrow key to an agent and keeps their integration key separate, and a runaway agent must not be able to exhaust the budget their order pipeline depends on. The descriptions push the same way, telling the model to count once rather than count variations in a loop, to spend one call per question rather than one per hypothesis, and to read a whole retention grid rather than ask cohort by cohort. `segmentic_describe_audience` is advertised as free precisely so the model rehearses before it spends.
Error text is passed through rather than paraphrased, for the same reason:
```text
The API's own code and message, passed through rather than
paraphrased. "budget_exhausted" tells a model to wait; a rewritten
"something went wrong" tells it to retry immediately, which is the
opposite of what the server just asked for.
```
## Budget {#budget}
Every call the server makes spends from the same per-key request budget as any other API client: 600 units per key per minute by default, in a fixed window. Startup spends 1 unit on `whoami` before any tool runs.
Exhaustion is 429 with `Retry-After: 60` and the code `budget_exhausted`, which reaches the model intact. If the budget meter itself is unreachable the request is refused, not allowed: this one fails closed.
The transactional send endpoint has a second, separate limiter, counted per account rather than per key, and that one fails open when its cache is down, because the endpoint carries order receipts and login codes and refusing them all because a cache is down turns our outage into your checkout failing. It is off by default.
Per-key daily recipient limits and personal-data row limits do not exist. The columns are in a migration and nothing reads or writes them.
## What the model sees when a call fails {#errors}
Failures come back as tool content with `isError: true`, so the model reads the text and can act on it. What the text says depends on which part of the server produced the error, and this is the roughest edge in the whole binary.
The client understands one error shape, the one the public edge writes: `{"error": {"code": ..., "message": ...}}`. Several handlers behind that edge write a different shape, where `error` is a plain string. The client cannot read those, so it reports the status number and nothing else.
Survives intact, with code and message:
| Code | Status |
|---|---|
| `unauthenticated` | 401 |
| `write_key_rejected` | 401 |
| `key_expired` | 401 |
| `forbidden`, naming the permission in `need` | 403 |
| `account_locked` | 403 |
| `filter_invalid` | 422 |
| `budget_exhausted` | 429 |
| `budget_unavailable` | 503 |
| `unknown_endpoint` | 404 |
Degrades to a bare status number:
| What actually failed | What the model is told |
|---|---|
| A filter that would not compile, on count | `http 400` |
| Every funnel and retention validation error | `http 400` |
| Every caller error on a send, including the wrong channel | `http 400` |
| The send rate limiter | `http 429` |
| A campaign id that does not exist | `http 404` |
| The schema, segment list, campaign list or count query being unavailable | `http 503` |
So the error the design most wants the model to read correctly, `budget_exhausted`, arrives whole. The errors a model most needs in order to fix its own input do not. When an agent reports `http 400` and cannot say why, make the same call with curl and read the body.
The response body is read up to 4 MiB. Anything larger is truncated, then fails to parse, and the tool reports `unreadable response` with the first 200 characters.
## What this server deliberately does not do, and what is simply missing {#absent}
Deliberate:
- No tool that returns a person. No profile lookup, no timeline, no segment preview, no search. The only way personal data leaves through this server is an export, which is itself behind `data.export` and the soft lock.
- No flag to change what a key can do. Neither `--allow-write` nor `--allow-send` exists.
- No database handle. Every gate stays at the HTTP edge where it is already tested.
- No irreversible act without a name to confirm. Sending a campaign and deleting an audience both read the object first, and do nothing if they cannot read its name.
Missing, and you will want at least one of these:
- **Journey and template tools.** Neither has a route on the public API, so the MCP cannot create or edit a scenario or a template. Note that `GET /v1/capabilities` reports `journeys`, which is true of the installation and not of this API surface. It has a card of its own.
- Any published build. No release, no image of its own, no macOS or Windows binary. You compile it.
- A permission picker in the panel. Scoped keys can be minted now, but from the API; the dropdown is not on the keys screen yet.
- Paging on the two list endpoints. Each is cut at the 200 most recently updated rows and nothing in the response says it was cut. The tools at least no longer offer paging arguments and state the ceiling in their descriptions, but the 201st row is still out of reach.
- Readable validation errors, per the table above.
- Per-tool logging. The binary writes one line at startup and nothing after it. The account audit log is a separate mechanism and does not record reads, so there is no per-call trail of what an agent looked at.
- Server-level MCP instructions. All steering lives inside individual tool descriptions, which a client may or may not show the model in full.
- Test coverage beyond request shape. The tests today hand the body each tool builds to the server's own request type and run the same compile function the HTTP handler calls, which is what caught the two broken tools. End-to-end behaviour against a real API is still untested.
If you need something on the second list today, use the [management API](/en/docs/api/management) directly. Everything the MCP server does is one HTTP call, and there is nothing in it you cannot do with curl.
---
# Handing this documentation to Claude or Codex
> The single-file copy of these docs, and a ready prompt for saying «connect this service for me».
> https://segmentic.net/en/docs/ai
Most people integrating Segmentic now do it with an agent sitting next to them. This page is what to hand that agent, and what it will get wrong if you do not.
If you want the agent to query your account rather than write code against it, that is a different thing and it is on [the MCP server page](/en/docs/mcp).
## The three files, and which one to use {#files}
The documentation is written once, as Markdown, and every artefact below is a view of the same source. A page that renders is a page that exports, so none of these can drift from what you are reading.
| File | What it is | When to use it |
|---|---|---|
| [/llms.txt](/llms.txt) | The index. One line per page, with its URL and a one-sentence description, plus a short statement of what Segmentic is. A few kilobytes. | When the agent can fetch URLs. It reads the index, then fetches the two or three pages it needs. |
| [/llms-full.txt](/llms-full.txt) | Every English page, concatenated, with a header naming the hosts and the key types. | When the agent cannot fetch URLs, or when the job is "connect this service" and it needs the whole thing at once. |
| [/docs-en.md](/docs-en.md) | The same text as `llms-full.txt`, served as a file with a name and a download disposition. | When you want a file to attach to a message, keep in the repository, or read offline. |
| [/docs-fa.md](/docs-fa.md) | Every Persian page as one downloadable file, `segmentic-docs-fa.md`. | Same job, in Persian, which is the original language of these pages. |
| [/openapi.json](/openapi.json) | Both HTTP surfaces, machine readable. Valid YAML as well as JSON. | Generating a client, or feeding a tool that wants a schema rather than prose. |
```bash title="Fetching them"
curl -s https://segmentic.net/llms.txt
curl -s -o segmentic-docs.md https://segmentic.net/docs-en.md
curl -s -o segmentic-docs-fa.md https://segmentic.net/docs-fa.md
curl -s -o segmentic-openapi.json https://segmentic.net/openapi.json
```
`llms.txt` is in English even though the Persian pages are the original. The reader of that file is a model choosing which page to fetch, the convention's own examples are English, and every model in use reads an English index more reliably than a Persian one. The pages it points at exist in both languages and the Persian set is linked at the foot of it.
There is no `llms-full-fa.txt`. The convention names one file, and a second one under an invented name would be found by nothing. The Persian bundle is `/docs-fa.md`.
`llms-full.txt` carries a header before the first page, stating the two hosts and the two key types in the imperative. That header is not decoration. An agent handed the pages without it answers questions about hosts and keys from whatever it remembers about other analytics products, and the two most expensive mistakes it makes are using the wrong host and using the wrong kind of key.
## A prompt you can paste {#prompt}
Copy this as it is. It is written to be the first message of a session, and everything in it is there because an agent got it wrong without it.
```text title="Give this to Claude Code or Codex"
Connect my product to Segmentic, an event collection and messaging platform.
First, read https://segmentic.net/llms-full.txt in full. That is the entire
documentation. Do not answer from memory of other analytics products: the
hosts, the key names, the error codes and the event model are not the same,
and a plausible guess here costs me an afternoon.
There are two hosts and two kinds of key. They are not interchangeable.
Sending events, from my website or my mobile app:
host https://in.segmentic.net
key wk_seg_... public by construction, it ships inside the client
bundle, and it can only write events
Reading and managing audiences, campaigns and reports, from my backend:
host https://api.segmentic.net
key sk_seg_... secret, server side only, carries permissions
The panel a human uses: https://app.segmentic.net
Rules I want you to hold to, without me asking again:
1. Never put an sk_seg_ key anywhere that reaches a device. Not in browser
code, not in a mobile app, not in an environment variable named
NEXT_PUBLIC_ANYTHING or VITE_ANYTHING, not in a client-side config file.
If a feature needs one, the call happens on my server.
2. Before you choose any event name, read the event design page and the
event dictionary. The server does not enforce a naming convention; it
accepts almost any string. So a bad name is not an error, it is history.
3. Never guess the name of an event or a trait that already exists. Confirm
it against the schema endpoints, with the sk_seg_ key:
GET https://api.segmentic.net/v1/schema/events
GET https://api.segmentic.net/v1/schema/traits
A name that does not exist is accepted everywhere and matches nobody,
which looks identical to a real audience of zero. If you cannot reach the
schema endpoint, stop and ask me rather than guessing.
4. Before you assume a management call will work, call
GET https://api.segmentic.net/v1/whoami. It returns the exact permission
list that key carries. Call GET /v1/capabilities for which features this
deployment serves and the limits it publishes, and use those numbers
rather than hardcoding your own.
5. Read the errors page before you write any retry logic. 401 means the
credential is wrong and retrying will never fix it. 429 with the code
budget_exhausted means wait, and Retry-After says how long. 503 is
transient and worth a backoff.
6. Anything that sends a message to a real person: show me the exact call
before you make it, and do not make it until I say yes.
Start by proposing the list of events my product should send, and one line
for each saying which question it answers. Do not write any code until we
have agreed on that list.
```
Two things you may want to add, depending on the job. If the agent is writing a backend integration, tell it which language and framework, because the documentation shows curl and it will otherwise pick for you. If you already send events and are adding messaging, tell it to start from `GET /v1/schema/events` and work from the names that are already there.
## What agents get wrong here specifically {#pitfalls}
These are not general warnings about language models. Each one is a failure this API produces, and most of them are named in the comments of the MCP server, which was written after watching agents use it.
**It invents identifiers, and nothing tells it off.** A filter naming an event that does not exist compiles cleanly and returns zero. To a model that reads as "nobody matches", not as "that event does not exist", so it reports the number and moves on. This is the single most expensive mistake here, and the reason the schema endpoints are in the prompt above.
**It reads far more than it needs, and then believes it saw all of it.** `GET /v1/segments` and `GET /v1/campaigns` accept `limit` and `cursor` and ignore both. Each returns the 200 most recently updated rows, including the whole filter tree of every segment, so an agent that asks "what audiences do we have" pulls those two hundred into its context. The cap is silent: no `next_cursor`, no `has_more`, no count. On an account with 250 segments the agent reports a partial list as a complete one, confidently, and there is no route to the other 50.
**It loops on expensive calls.** Counting an audience costs 25 units of a 600 unit per-minute budget, so 24 counts in a minute exhausts it. An agent trying filter variations will hit `budget_exhausted` inside a minute. Give the agent its own key: the budget is counted per key, so a runaway agent then cannot starve the key your order pipeline depends on.
**It collapses rates into one percentage.** SMS has no read receipt, so the open count for an SMS campaign is zero and it means nothing. Every rate in a campaign report arrives as a numerator with a named denominator, `measurable_open` and `measurable_click` beside `opened` and `clicked`. An agent that divides `opened` by `issued` reports a zero percent open rate for SMS, which is confidently wrong. Tell it to quote both numbers.
**It quotes the middle of a range as if it were a measurement.** The uplift section carries `lift`, `lift_low` and `lift_high`. The point estimate on its own is not a measurement, it is the middle of a range, and an agent that reports "the campaign lifted conversions by 11.7 percent" has thrown away the only figure that says whether the campaign worked at all.
**It invents an idempotency key.** On a transactional send the key must identify the real-world event, for example `order-8821-shipped`. An agent that generates a random one gets a fresh key on every retry, and a retry then sends a second message to a real phone.
**It reads a 200 as delivered.** A transactional send can answer 200 with `status: "suppressed"` and a `reason`, which means the message was deliberately not sent: an unsubscribe, a channel opt-out, a suppression, no address on file. Check `status` before telling anybody the notice went out.
**It cannot read most validation errors.** The public API answers with a code and a message for authentication, permission, budget and filter errors. Several handlers behind it answer with a plainer shape that clients cannot parse, so an agent reports `http 400` and cannot say why. When that happens, make the same call yourself with curl and read the body. The full table is on [the errors page](/en/docs/errors).
**It trusts text your own team wrote.** Segment names, campaign names and the Persian descriptions reach a model as plain text with no untrusted-content wrapper. If somebody names a segment with an instruction aimed at a model, the model reads it as an instruction. Treat the free-text fields of the panel as an input channel into your agent, because that is what they are.
## Checking what the agent built {#checking}
Four commands, in the order worth running them.
```bash title="What does this key actually carry"
curl -s https://api.segmentic.net/v1/whoami \
-H "Authorization: Bearer sk_seg_REPLACE_ME"
```
```json title="Response"
{
"tenant_id": 7,
"api_key_id": 3,
"role": "analyst",
"permissions": [
"analytics.read", "audit.read", "campaign.read", "data.export",
"event.read", "journey.read", "member.read", "profile.read",
"segment.read", "settings.read", "template.read"
],
"scoped": false
}
```
```bash title="What does this deployment serve, and what are its limits"
curl -s https://api.segmentic.net/v1/capabilities \
-H "Authorization: Bearer sk_seg_REPLACE_ME"
```
```json title="Response"
{
"version": "v1",
"features": {
"segments": true,
"campaigns": true,
"analytics": true,
"transactional": true,
"export": false,
"import": true,
"journeys": true,
"ingest": true,
"async_exports": true,
"campaign_approval": true
},
"limits": {
"max_page_size": 100,
"max_preview_rows": 100,
"max_batch_size": 500,
"estimate_sample": 100,
"query_timeout_sec": 30
}
}
```
Do not read the `features` values above as a sample to copy. Each flag is exactly whether that subsystem is wired on that deployment, so it differs per install, and reading it is the whole point of it. Three of them, `export`, `import` and `journeys`, gate no route on this mux at all: `true` does not give you a public endpoint.
Those limits are published rather than documented so that no client and no agent hardcodes a number we later change. If the agent wrote `500` into a batching loop instead of reading `max_batch_size`, that is a review comment.
```bash title="Did the event actually arrive, under the name you expect"
curl -s https://api.segmentic.net/v1/schema/events \
-H "Authorization: Bearer sk_seg_REPLACE_ME"
```
Read `last_seen` on every row, not just `volume`. An event with a large volume and a last seen date three weeks ago is an integration that broke, and volume alone looks healthy for a month afterwards, because the window is ninety days.
Then grep what ships to the browser. This is the check nobody runs and the one that matters most:
```bash title="A management key must never reach a device"
grep -r "sk_seg_" ./dist ./build ./.next ./public 2>/dev/null
grep -rn "NEXT_PUBLIC_.*SEG\|VITE_.*SEG" ./src 2>/dev/null
```
Anything the first command finds is a secret you now have to revoke, from **Settings, Connections and integrations, API keys** in the panel. Revocation takes effect on the next request.
---
# Inbound webhooks and other services
> Taking events from a service that has no SDK, and the integrations that are ready.
> https://segmentic.net/en/docs/webhooks
## What an inbound webhook is {#what-this-is}
A way to take events from a service that does not have our SDK and is not going to get it.
A seller who only sells on Digikala has no site for an SDK to sit on. A shop running WooCommerce does, but installing a plugin and redeploying the site happens weeks after the decision. A webhook removes that gap: you paste a URL into that service's admin panel and the orders start arriving. The SDK becomes an upgrade later rather than a prerequisite.
There is no write key here. Your account is identified by the URL itself plus a signature, because there is nowhere in that platform's admin form to put a header.
## The address and the token {#endpoint}
```text
POST https://in.segmentic.net/v1/hooks/{source}/{token}
```
`{source}` is one of the seven listed in [The sources](/en/docs/webhooks#sources), and `{token}` is a string the server mints when the connection is created.
The body is capped at one megabyte, exactly `1048576` bytes. A Shopify order with two hundred line items is about two hundred kilobytes, so this is generous and still small enough that a malicious POST cannot make us buffer a gigabyte.
The raw bytes are kept and the signature is verified against them **before any parsing**. This is the single most common webhook bug there is: decoding and re-encoding changes key order, whitespace and numeric formatting, and the signature then fails against a payload that was perfectly genuine.
The route is registered only when your installation has connections enabled.
### Creating a connection in the panel {#creating-a-connection}
> Diagram: The verified data loop between commerce systems, Segmentic profiles, automations and outbound relays
Three management routes, all on the control plane, which is the API the panel talks to. There is no connection management route on the management host.
> [!danger]
> **The control plane is not routed from the internet.** In the reference deployment, `api.segmentic.net` serves the management API on its own listener and the control plane's listener is deliberately not published. These three paths on `https://api.segmentic.net` fall to the catch-all and answer `404 unknown_endpoint`. The only public path to them is the panel's own server-side proxy at `https://app.segmentic.net/api/proxy/v1/...`, which authenticates with the signed-in user's session cookie and answers `401` without one. An `sk_seg_` key does not reach it, so creating a connection is something a person does on the panel's integrations screen.
| Method and path | Permission |
| --- | --- |
| `GET /v1/integrations` | `settings.read` |
| `PUT /v1/integrations` | `settings.write` |
| `POST /v1/integrations/{source}/enabled` | `settings.write` |
```http title="Creating or updating a connection"
PUT /api/proxy/v1/integrations
Content-Type: application/json
{"source":"woocommerce","label":"فروشگاه اصلی","secret":"a-shared-secret"}
```
```json title="Response"
{
"id": 3,
"source": "woocommerce",
"label": "فروشگاه اصلی",
"token": "yVJk3rQx7Pd1wNfZ0aLbCsTu",
"has_secret": true,
"enabled": true,
"received": 0,
"accepted": 0,
"rejected": 0,
"source_label": "ووکامرس",
"webhook_url": "https://in.segmentic.net/v1/hooks/woocommerce/yVJk3rQx7Pd1wNfZ0aLbCsTu",
"healthy": false
}
```
Things to note about that response:
- `secret` is write-only and is never returned. Only `has_secret` says that a value is stored. That is a decision rather than a shortfall: the secret is sealed with the installation's key and no route hands it back, so a leaked management key does not carry your webhook secrets with it. If you leave `secret` empty on a later save, the stored one is left alone, so renaming the `label` does not require knowing the secret again.
- `token` is minted by the server on the first save: twenty-four random bytes, base64url with no padding. **Later saves do not rotate it**, because that token is pasted into another platform's admin and changing it silently breaks a working connection the moment somebody merely renames it.
- `webhook_url` is assembled server side so nobody has to build it from a token and a base and get it wrong. **If the environment variable `EMAIL_TRACK_BASE` is not set on the API service, the field comes back as an empty string rather than being absent, and the panel has no URL to copy.** A client that tests for the key being missing takes the wrong path; test the value.
- `healthy` is `enabled && last_error == "" && accepted > 0`. So a brand-new connection that has not received anything yet reads as unhealthy. That is deliberate, not a bug.
- `source` must be one of the seven known values, otherwise the answer is `400`.
`last_error` is kept only for a failure, cleared on the next success, and truncated to 500 bytes. The field is `omitempty`, so a connection with no failure on record carries no `last_error` key at all, which is why the response above has none. The `received`, `accepted` and `rejected` counters are updated after the events are published, fire and forget.
Turning a connection off without deleting it:
```http title="Turning it off"
POST /api/proxy/v1/integrations/woocommerce/enabled
Content-Type: application/json
{"enabled":false}
```
A disabled connection answers `404` to every delivery, exactly the answer an unknown token gets.
## Signature verification {#signatures}
The scheme is the same everywhere: `base64(HMAC-SHA256(secret, rawBody))`, compared in constant time against the trimmed header value. Constant time because anybody can call this endpoint, and a byte-at-a-time comparison leaks one character of the expected value every few thousand requests.
| Source | Signature header | Topic header |
| --- | --- | --- |
| `shopify` | `X-Shopify-Hmac-Sha256` | `X-Shopify-Topic` |
| `woocommerce` | `X-WC-Webhook-Signature` | `X-WC-Webhook-Topic`, falling back to `X-WC-Webhook-Resource` |
| `segment` | None, see [Segment](/en/docs/webhooks#segment) | None |
| `digikala`, `basalam`, `torob`, `zarinpal` | **No header is read**, see below | None |
If no `secret` is stored, every delivery gets `401`. Segment is the only exception.
The WooCommerce fallback is for older plugins: when `X-WC-Webhook-Topic` is empty, the topic is built from `X-WC-Webhook-Resource` plus a dot plus `X-WC-Webhook-Event` (or `updated` if that is absent too). That is what a shop running a two-year-old plugin actually sends.
### The four Iranian sources whose signature cannot be verified over HTTP {#iranian-signature-gap}
> [!danger]
> **Digikala, Basalam, Torob and ZarinPal do not work today over `POST /v1/hooks/{source}/{token}`.** This is a known defect and it is written down here so nobody spends half a day looking for a misconfiguration that does not exist.
The mechanism: the function that decides which header to read the signature from returns the correct header for Shopify and for WooCommerce, and **the empty string for every other source**. No branch was written for these four. The signature verifier takes that empty string, compares a real base64 MAC against it, never matches, and the result is:
```json title="What actually comes back"
{"status":"error","message":"signature mismatch"}
```
with status `401`. At the same time the connection's `last_error` is set to `bad signature` and the `rejected` counter increments. If you have not configured a secret either, you get exactly the same `401`, the same body and the same `bad signature`: the handler writes both constants for every verification failure whatever its cause. The reason `no shared secret configured` goes to the server log only, so no amount of reading the panel's last error tells the two apart.
Note that the transform library itself does support these four: its signature verifier accepts the same HMAC-SHA256 over the raw body for them, and the transform for all four is written and tested. **What does not exist is a header being read on the HTTP side.** Until that is added, contact us if you need one of these four sources.
## Responses {#responses}
| Case | Status | Body |
| --- | --- | --- |
| Unknown source, unknown token, or a **disabled** connection | `404` | `{"status":"error","message":"unknown webhook"}` |
| The body could not be read | `400` | `{"status":"error","message":"unreadable body"}` |
| Signature did not match (any source but Segment) | `401` | `{"status":"error","message":"signature mismatch"}` |
| Segment credential did not match | `401` | `{"status":"error","message":"unauthorized"}` |
| The body could not be transformed | `200` | `{"status":"ok"}` |
| The transform produced no events | `200` | `{"status":"ok"}` |
| The bus and the disk buffer both failed | `503` | `{"status":"error","message":"temporarily unavailable, please retry"}` |
| Success | `200` | `{"status":"ok","accepted":3}` |
Three things in that table are deliberate.
The `404` is the same for all three not-found cases, so the endpoint cannot be probed to discover which tokens exist.
A body that cannot be used gets `200` rather than an error. These platforms retry any non-`2xx` for days and that body will never change. The reason for the failure is recorded in the connection's `last_error` so the panel screen can say why nothing arrived.
`accepted` is absent from the response when it is zero, because the field is `omitempty`. So `{"status":"ok"}` means zero events.
**Webhook events are metered and billed**, one per accepted non-duplicate event. Unlike on-site interactions, which are not counted at all. There is no quota gate on this path.
## Deduplication {#deduplication}
Every transform derives a deterministic `message_id` from the source platform's own identifier. All of these platforms retry, several of them aggressively, and without it a customer's revenue is counted twice the first time their network blinks, on the number they look at most.
| Source | Identifier shape |
| --- | --- |
| Shopify order | `shopify::` |
| Shopify customer | `shopify:identify:` |
| WooCommerce | `woocommerce:::` |
| Segment | Its own `messageId`, and a derived one if that is absent |
| The four Iranian sources | `::`, where `key` is the first present of `order_id`, `ref_id` or `click_id`, falling back to the user id |
The order status is inside the WooCommerce identifier so that an order moving through three states is three events, while a retry of any one of them is still caught.
An identifier of `""` or `"0"` produces an empty `message_id`, meaning that event does not benefit from duplicate detection.
Duplicates are not counted in `accepted` and are not an error: a retry after a timeout is the ordinary case, not an anomaly.
## The sources {#sources}
Seven sources, in the order they appear in the panel. The four Iranian platforms lead deliberately: a picker that starts with Shopify, which an Iranian merchant cannot use at all, reads as a product built for somewhere else.
### Digikala {#digikala}
The seller-panel order webhook. A shop that only sells on Digikala has no site for an SDK, so this is its only path to order data.
Accepted topics: `order.created`, `order.confirmed` and an empty topic map to `order_completed`; `order.cancelled` and `order.canceled` to `order_cancelled`; `order.returned` and `order.refunded` to `order_refunded`. Anything else is skipped and the answer is `200`.
```json title="A body that is accepted"
{
"order_id": 88213445,
"status": "confirmed",
"created_at": "2026-08-06 11:42:00",
"total_price": 2450000,
"customer": { "id": 5512, "mobile": "09123456789", "name": "علی" },
"items": [
{ "product_id": 771, "title": "کفش رانینگ", "quantity": 1, "price": 2450000 }
]
}
```
Properties on the resulting event: `order_id`, `source`, `currency` which is always `IRR` and is never converted, `revenue`, `item_count` which here is a number, and `product_id` and `product_name` from the first line.
### Basalam {#basalam}
The handmade and local-goods marketplace. Topics: `order.paid`, `order.created` and an empty topic map to `order_completed`; `order.cancelled` and `order.canceled` to `order_cancelled`.
```json title="A body that is accepted"
{
"id": 90112,
"status": "paid",
"created_at": "2026-08-06T11:42:00",
"amount": 780000,
"customer": { "id": 341, "mobile": "09123456789", "username": "ali_b" },
"product": { "id": 4471, "title": "شمع دستساز" }
}
```
Properties: `order_id`, `source`, `currency` of `IRR`, `revenue`, and `product_id` and `product_name` when present.
### Torob {#torob}
The price comparison engine. It sends clicks rather than orders, which makes it the top of the funnel for a shop whose traffic arrives through comparison.
Topics: empty, `click` or `referral`.
```json title="A body that is accepted"
{
"click_id": "trb_88a1",
"product_id": 771,
"title": "کفش رانینگ",
"price": 2450000,
"user_id": "trb_u_5512",
"mobile": "09123456789",
"created_at": "2026-08-06 11:42:00"
}
```
The event produced is always `product_viewed` and is **never an order**. Reporting a referral as a purchase inflates every conversion number a comparison-driven shop looks at. Properties: `source`, `click_id`, and `product_id`, `product_name` and `price` when present.
### ZarinPal {#zarinpal}
A payment gateway callback. Worth having even for a shop that already sends `order_completed`: the gateway is the only party that knows a payment actually settled, and "order placed" and "money received" differ by the abandonment rate, which in Iranian e-commerce is not small.
Topics: empty, `payment` or `verify`.
```json title="A body that is accepted"
{
"authority": "A00000000000000000000000000123456789",
"ref_id": 77112233,
"amount": 245000,
"status": "OK",
"email": "ali@example.ir",
"mobile": "09123456789",
"order_id": "ORD-9",
"created_at": "2026-08-06 11:42:00"
}
```
**Only a settled payment produces an event.** After trimming and upper-casing, `status` must be empty, `OK`, `100` or `SUCCESS`. Anything else is skipped as `payment not settled: ` and the answer is `200`. ZarinPal reports the failures too, and recording a failed payment as an order is exactly how a revenue report ends up above what the gateway settled.
`order_id` may be a string or a number, because a merchant's order id is whatever their own checkout produces: `"ORD-9"` for one shop and `55123` for the next. If the structure accepted only one of the two, the whole body would fail to decode and a settled payment would be lost.
**`amount` is in toman on ZarinPal's v4 API.** We record both: `revenue` is `amount * 10` in rial, and `amount_toman` is the raw `amount`. A report that mixes the two is off by a factor of ten and looks plausible either way. The other properties: `source`, `ref_id`, `authority`, `currency` of `IRR`, and `order_id` when its value is neither empty nor `0`.
Identity for all four Iranian sources is picked in this order: **phone first**, normalised to E.164, then email lower-cased, then the platform's own internal id. The phone number is the identifier an Iranian shop's own systems key on and is most likely to match a profile already here. `name` becomes the `first_name` trait. With no identity at all the whole body is skipped as `no identity` and the answer is still `200`.
Timestamps for these four are parsed **in Tehran**, not in UTC. These platforms report local time with no offset, and reading it as UTC puts every order three and a half hours early, landing a morning order in the previous day's report. The accepted formats are RFC3339, `2006-01-02T15:04:05`, `2006-01-02 15:04:05` and `2006-01-02`. An unreadable timestamp falls back to now rather than rejecting the event: an event with a slightly wrong timestamp is worth far more than one that never arrived.
### WooCommerce {#woocommerce}
Only two topics are read: `order.created` and `order.updated`.
**The order status decides the event, not the topic.** WooCommerce sends one webhook per status change, and reading the topic alone turns an order that moves from `pending` to `processing` to `completed` into three purchases.
| `status` | Event |
| --- | --- |
| `processing`, `completed` | `order_completed` |
| `cancelled`, `failed` | `order_cancelled` |
| `refunded` | `order_refunded` |
| `pending`, `on-hold` | `checkout_started` |
| Anything else | Skipped as `order.` |
Identity: `billing.email` then `billing.phone`. With neither, the body names nobody. Persian digits inside a phone number are folded to ASCII. `billing.city` becomes the `city` trait. `date_created_gmt` is a timestamp with no zone and is read as such.
Properties: `order_id`, `order_number` (falling back to `order_id`), `status`, `source`, and when there are line items `product_id`, `product_name` and `item_count`, which here is a **string**. When `total` is above zero, `revenue` and `currency` are added, with `IRR` as the default currency.
### Shopify {#shopify}
| Topic | Event |
| --- | --- |
| `orders/create`, `orders/paid` | `order_completed` |
| `orders/cancelled` | `order_cancelled` |
| `refunds/create` | `order_refunded` |
| `checkouts/create`, `checkouts/update` | `checkout_started` |
| `customers/create`, `customers/update` | An identify |
| Anything else | Skipped, answer `200` |
Identity order: **email first, then phone, then the platform's own id.** Email is what the customer's other systems key on; the platform id is stable but only means anything inside that platform, so putting it first makes the same person two profiles the day they also arrive through the SDK. A guest checkout with no email and no phone produces no event and the answer is `200`.
Properties: `order_id`, `order_number`, `source`, `product_id` (the first line's SKU, falling back to its product id), `product_name`, `item_count` which is a **string**, `product_ids` which is the SKUs joined by commas, `checkout_url` when the body carries `abandoned_checkout_url`, and, when the amount is positive, `revenue` and `currency` defaulting to `IRR`.
An order with three line items produces **one** `order_completed` event, not three. A per-line event would triple every revenue number.
### Segment {#segment}
Segment does not sign. Instead the `Authorization` header is compared against the stored secret in constant time, an optional `Bearer ` prefix is tolerated, and **the check runs only when a secret is configured**, so a customer who has not set one is not locked out of their own connection.
Both a single object and the `{"batch":[...]}` form are accepted, and the batch form is what Segment actually sends at volume.
| `type` | Result |
| --- | --- |
| `track` | A track event with the same `event` |
| `identify` | An identify |
| `page` | A page with `event` set to `name` or `page` |
| `screen` | A screen with `event` set to `name` or `screen` |
| Anything else | That item is dropped |
A body with neither `userId` nor `anonymousId` is dropped. One bad item does not lose the batch: Segment sends hundreds at a time, and rejecting the whole batch for one malformed row costs the customer the other ninety-nine. Segment's own `messageId` is kept as the deduplication key, so a replay reaching us through both paths is one event rather than two.
```bash title="Sending an event through a Segment connection"
curl -s -X POST https://in.segmentic.net/v1/hooks/segment/yVJk3rQx7Pd1wNfZ0aLbCsTu \
-H "Authorization: Bearer a-shared-secret" \
-H "Content-Type: application/json" \
-d '{
"type": "track",
"event": "order_completed",
"userId": "u_9137",
"messageId": "seg_0f21",
"timestamp": "2026-08-06T11:42:00Z",
"properties": { "revenue": 2450000, "currency": "IRR" }
}'
```
```json title="Response"
{"status":"ok","accepted":1}
```
## Event relays: data going out {#relays}
The outbound twin of the inbound webhook. A relay POSTs events to your address as they arrive, so your CRM or fulfilment system does not have to poll our API every thirty seconds.
These five routes are on the control plane too, alongside the connection routes above, and there is no relay route on the management host.
> [!danger]
> **The control plane is not routed from the internet.** These five paths on `https://api.segmentic.net` fall to the catch-all and answer `404 unknown_endpoint`. The only public path to them is the panel's own server-side proxy at `https://app.segmentic.net/api/proxy/v1/...`, on the signed-in user's session cookie. An `sk_seg_` key does not reach it, so a relay is created and retried on the panel's relay screen. What a relay then does, delivering to your endpoint, needs no API of ours at all.
| Method and path | Permission |
| --- | --- |
| `GET /v1/relays` | `settings.read` |
| `PUT /v1/relays` | `settings.write` |
| `DELETE /v1/relays/{id}` | `settings.write` |
| `GET /v1/relays/{id}/deliveries` | `settings.read` |
| `POST /v1/relays/{id}/retry` | `settings.write` |
```http title="Creating a relay"
PUT /api/proxy/v1/relays
Content-Type: application/json
{
"name": "انبار",
"url": "https://ops.example.ir/hooks/segmentic",
"events": ["order_completed"],
"filters": [{ "prop": "revenue", "op": "gte", "value": "5000000" }],
"enabled": true,
"secret": "a-relay-secret"
}
```
```json title="Response"
{"id":4}
```
**An empty `events` list means every event.** The default is deliberately the noisy one, because the alternative, empty meaning nothing, creates a relay that looks configured and silently does nothing, which takes a support ticket to discover.
Filters are conditions on the event's properties, shaped `{"prop","op","value"}`. The operators are `eq`, `ne`, `contains`, `prefix`, `gt`, `gte`, `lt`, `lte`, `exists`, `missing`. Eight filters at most, because past that the rule is a segment definition, and a relay is a firehose with a tap rather than a query engine. **Every filter must match, not any of them.** A comparison against a property the event does not carry is false, not an error. A number that arrived as text still compares as a number.
Validation failures get `400`: an empty name, an empty URL, an empty event name, an unknown operator, too many filters. If the installation has no sealing key the answer is `503`. The audit line records the destination and never the secret.
The body your endpoint receives is deliberately small and stable, and is not our internal structure. The receiving system is somebody else's code that we do not get to redeploy, so every field here is a promise:
```json title="What your endpoint receives"
{
"event": "order_completed",
"user_id": "u_9137",
"anonymous_id": "a_9f21c4",
"timestamp": "2026-08-02T12:00:00Z",
"properties": { "revenue": 2500000, "city": "تهران" },
"message_id": "shopify:order_completed:450789469"
}
```
`user_id`, `anonymous_id` and `properties` are absent when empty. The two property maps we keep separate internally are merged back into one here; handing you two maps to reassemble would be our storage layout leaking into your code.
Fan-out runs **after** the events are durable, never before. A relay fired for an event that then failed to write would tell the customer's fulfilment system about an order this platform has no record of.
### Relay signing {#relay-signing}
If you have stored a secret, every request carries these two headers:
```http title="Signature headers"
X-Segmentic-Timestamp: 1786032120
X-Segmentic-Signature: 4f1c9a2b...
```
The signature is `hex(HMAC-SHA256(secret, timestamp + "." + payload))`. The timestamp is inside what gets signed, so a captured body cannot be replayed against your endpoint a week later and still verify.
The other headers: `Content-Type: application/json` and `User-Agent: Segmentic-Relay/1`.
### Retries {#relay-retries}
One delivery times out after fifteen seconds. Ten attempts at most, backing off exponentially from ten seconds and capped at an hour, spanning a little under two hours in total. The cap matters more than the curve: without it the tenth attempt would be two days out, and a delivery that arrives two days late is worse than one that never arrives, because the customer's system has already reconciled without it.
- `2xx` succeeds.
- `408`, `429` and any `5xx` are retried: the endpoint is up and having a bad time.
- **Every other `4xx` is permanent.** Retrying a `401` ten times does not make the credential valid, and it does make the customer's error log ten times noisier.
- A URL that will not parse, and an address the SSRF guard refuses, are permanent too. The guard runs before the request and **again at every redirect hop**, because a public hostname that redirects to `127.0.0.1` defeats a check done only on the first URL.
The first 500 bytes of a failure body are kept for the delivery log screen, because an endpoint that answers `400` usually says why in the body, and that sentence is the whole value of the screen.
`POST /v1/relays/{id}/retry` puts every dead delivery back in the queue and returns `{"requeued": N}`. An endpoint misconfigured for an hour leaves a pile of dead deliveries, and the alternative is asking the customer to replay the events from their own side, which they cannot, because the events were ours.
## Short links {#short-links}
For one channel and one reason: an SMS is billed by the part, a Persian part is seventy characters, and a campaign URL routinely takes sixty of them. The link is often the single most expensive thing in the message, and a sentence one word too long doubles the bill for the whole audience.
The path is `GET /s/{code}`. The prefix is deliberately one character: it is in every SMS the shortener rewrites, and each character in the prefix is one fewer available to the message.
`code` is seven characters from a Crockford-style base32 alphabet: `0123456789ABCDEFGHJKMNPQRSTVWXYZ`. The letters I, L, O and U are deliberately absent. A short link is read aloud, typed from a screenshot and dictated over the phone, and every one of those four is a support ticket that begins "it says page not found".
The code is **derived, not issued**:
```text title="The code formula"
code = base32(sha256(tenant_id || 0x00 || target))[:7]
```
Two consequences of that design are spelled out in the code. The same URL in the same account always shortens to the same code, so the table holds one row per link rather than one per send, and a campaign re-rendered after a crash produces byte-identical messages. And the account id is inside the hash, so two accounts shortening the same URL get different codes and neither can discover the other's by guessing.
The rewrite is applied only on the SMS path, and even there:
- **Never on a service line.** There the text must match a pattern registered with the operator, and rewriting any part of it makes the gateway reject the send.
- The code-to-target mapping is stored **before** the message goes out, and a failure to store it leaves the long link in place. A code printed in somebody's inbox that resolves to nothing is worse than a link that costs an extra part.
- Only `http` and `https`. A deep link like `myapp://order/12345` is parsed by the app itself and a web redirect breaks the routing it exists to drive.
- And only when the result is actually shorter. For a customer whose own domain is already short, the rewrite would make the message longer.
Storing the mapping is `ON CONFLICT DO NOTHING` and deliberately **does not update the target**: letting a later send repoint an existing code would silently change where a link already sitting in somebody's inbox goes.
Setup: set `SHORT_LINK_BASE` and point that domain at the collector, because `/s/{code}` is served there. Until it is set the shortener is off and messages go out exactly as they do today.
### What a click records {#click-tracking}
The redirect is `302` and **never `301`**. A permanent redirect is cached by the handset and every follow after the first never reaches us, which turns the click count into a count of first-time followers without anything saying so.
The order of work: one primary-key read, then the redirect, and the click is counted **after** the response is written, in a goroutine on a context detached from the request with a five second timeout. The request's own context is cancelled the moment the response is written, and the update would be rolled back almost every time. A failure to count is logged at warn only.
An unknown code, and a lookup error too, get a plain `404` rather than an error page. An unknown code is almost always a mistyped one, and there is nothing useful to say to somebody who read seven characters off a screenshot.
What is recorded is exactly this and no more:
```sql title="Everything that is written"
UPDATE short_links SET clicks = clicks + 1, last_click = $2 WHERE code = $1
```
A counter and a last-click timestamp, on the link's own row. **No person, no IP, no user agent.**
There is no token here and there cannot be one. Every other tracking endpoint is reached with `sg_mid` and a signature and is refused without them, because it is reached by software we gave the link to. This one is reached by a phone carrying nothing but the seven characters printed in the message.
So the attribution model here is **per link and per campaign, never per person**. Sixty thousand recipients of one campaign share one code. For push and email the per-person model is still in place and is better: `sg_mid` and `sg_t` are stamped onto the link, the person lands on the customer's own site, and the SDK already there reports the arrival. For email there is no click redirect endpoint at all, because a hop of ours could fail, would add a domain that can be blocklisted, and, worst, would count every click a corporate mail scanner makes while checking the message, which on a business list can be most of them.
> [!danger]
> **No endpoint and no screen shows you this counter.** The function that reads a campaign's clicks exists in the store layer and has no caller anywhere in the tree; no API route reads it and no panel screen displays it. The clicks are counted into a column that cannot be read today.
## What is not there today {#not-built}
Only things that change your code belong here: something you would look for and not find, or something you have to do another way. Deliberate decisions, such as the absent email click redirect and the secret that never comes back, are explained where the decision is made.
- **Signature verification for Digikala, Basalam, Torob and ZarinPal over HTTP.** See [The four Iranian sources](/en/docs/webhooks#iranian-signature-gap).
- **Reading short-link clicks.** The counter exists and there is no way to see it.
- **Rotating a webhook token.** Saving again does not change it, and there is no other route that changes it. If a token leaks, disable the connection and contact us.
- **The connection and relay management routes on the management host.** They are registered on the dashboard's control plane only, and that listener is not routed from the internet, so an `sk_seg_` key reaches none of them. The panel's own proxy is the way in.
---
# Personal data: export and deletion
> What to do when a person asks to see or delete their data, and what Segmentic does on its own.
> https://segmentic.net/en/docs/privacy
An erasure request cannot be filed over the API. Every `/v1/privacy/*` route and `/v1/settings/retention` lives on the panel's control-plane listener, which is deliberately not addressable from outside the overlay network. There is no privacy endpoint on `api.segmentic.net` at all. If you wanted your users' requests to reach Segmentic automatically, that route does not exist today and a person has to file them in the panel.
> Diagram: The Segmentic customer data boundary across deployment, access, erasure and audit operations
In the legal relationship you are the controller and Segmentic is the processor. This page states exactly what the platform does and does not do, so that your own privacy policy does not promise something the code will not perform.
## When somebody asks what you hold about them {#access}
There is no "give me everything about this person" endpoint. Assembling an answer to a subject access request means five separate routes, all of them `profile.read`:
| Route | What it returns | Cap |
|---|---|---|
| `GET /v1/profiles/{user_id}` | the profile: traits and computed columns | one profile |
| `GET /v1/profiles/{user_id}/timeline` | the person's own events | the 100 most recent |
| `GET /v1/profiles/{user_id}/messages` | what we sent, and what we deliberately did not send | the 50 most recent |
| `GET /v1/profiles/{user_id}/devices` | installs and their push state | all |
| `GET /v1/profiles/{user_id}/preferences` | channel and topic settings | all |
The cap of 100 means the timeline is not the full history. If that person has a thousand events, this route does not answer a subject access request completely, and there is no other way to fetch the rest through the API.
An export of `kind` `profiles` covers the whole account, not one person. It carries no filter for a single id. So building a subject access response is manual work: open those five views and read them.
The "not sent" rows in the message history are the valuable half of that list. "We sent nothing because you unsubscribed from this topic on the 3rd" is the answer to the complaint itself, and it exists only because the send path records refusals as carefully as it records sends.
## Filing an erasure {#erasure}
The permission split here is sharper than on any other screen, because these are the only two endpoints on the platform that destroy a customer's data on purpose.
| Route | Permission |
|---|---|
| `GET /v1/privacy/erasures` | `profile.read` |
| `GET /v1/privacy/erasures/{id}` | `profile.read` |
| `POST /v1/privacy/erasures` | `profile.write` |
| `POST /v1/privacy/erasures/{id}/reject` | `settings.write` |
| `GET /v1/settings/retention` | `settings.read` |
| `PUT /v1/settings/retention` | `settings.write` |
Filing an erasure names a person, so it takes the same pair of permissions that guards every other route to an individual. Retention is different in kind: it is one number that decides how much of the account's own history survives, so it is `settings.write`, held by owners and admins and nobody else. A marketer who can send to two million people still cannot decide that last year's events stop existing.
Rejecting a request is `settings.write` as well, because a refusal to honour a statutory request is a decision about the account rather than about the person.
### The request {#erasure-request}
You cannot issue these with `curl`. What follows is the request the panel makes on the control plane, shown here so you know exactly what is recorded and what comes back.
```http
POST /v1/privacy/erasures HTTP/1.1
Content-Type: application/json
{"kind": "phone", "identifier": "+98 912 345 6789"}
```
`kind` is one of `user_id`, `email` or `phone`, and omitting it means `user_id`. The identifier is normalised before it is hashed rather than trusted as sent: two spellings of one number that hashed differently would be two obligations for one person, with two deadlines, and the second would never be found by a search for the first.
- `user_id` is only trimmed. It is your own key and case may well be significant in it.
- `email` is lowercased and must contain an `@` that is neither the first nor the last character.
- `phone` keeps only its digits, and Persian and Arabic digits count. The four shapes `09123456789`, `+989123456789`, `00989123456789` and `9123456789` all resolve to one. An erasure that missed three of the four would report success while leaving the person perfectly reachable.
The response is a 202:
```json
{
"id": 41,
"subject_hash": "Q1p4bF9y...",
"subject_ref": "09123456789",
"subject_kind": "phone",
"status": "pending",
"requested_by": "member:12",
"requested_at": "2026-08-07T09:20:00Z",
"due_at": "2026-09-06T09:20:00Z",
"report": {
"profiles": 0, "events": 0, "messages": 0, "devices": 0, "consent": 0,
"journey_state": 0, "identities": 0, "suppressions_kept": 0,
"segment_membership": 0
},
"attempts": 0,
"status_label": "در صف",
"kind_label": "شمارهٔ همراه",
"overdue": false,
"removed_total": 0,
"days_remaining": 30,
"requested_label": "۱۶ مرداد ۱۴۰۵"
}
```
The failures:
| Condition | Status |
|---|---|
| the identifier is unreadable, or `kind` is unknown | 400 |
| an outstanding request already exists for this person | 409 |
| no hashing key is configured on this install | 503 |
That 503 is deliberate. The hash is the whole record; without a key there is nowhere to put the permanent proof, so the request is refused rather than filed as something that could never afterwards be reported on. Reading the queue still works in that state: an install that cannot file new requests must still be able to show the ones already on the books.
### The queue and the deadline {#erasure-queue}
`due_at` is written at insert as thirty days after `requested_at`, so a policy change later does not move deadlines that were already running. `overdue` is computed on the server, not in the browser: "has the statutory month elapsed" must not depend on the clock of whichever laptop is looking at the screen.
```http
GET /v1/privacy/erasures?limit=50&offset=0 HTTP/1.1
```
```json
{"erasures": [], "total": 0, "overdue": 0}
```
`overdue` is surfaced separately so the screen can lead with it. A list sorted by date buries the one request that has blown its deadline. `limit` runs from 1 to 200 and anything outside that falls back to 50.
Five statuses exist: `pending`, `running`, `completed`, `failed`, `rejected`. The janitor looks at the queue every minute and drains up to 50 in a row, so a support desk that filed forty requests on a Sunday does not wait forty minutes to start the fortieth. A failing request is retried up to 5 times.
If nobody matches the identifier the request is recorded as **completed**, not failed: there is nothing of theirs to remove, and a request that retried forever against an id that was never here would sit in the queue past its statutory deadline looking like a breach.
The janitor is deliberately its own binary. Everything it does is either a ClickHouse mutation or a large Postgres delete, and both compete for exactly the disk and merge capacity the ingest path needs. It is also the binary a nervous operator can stop without halting sending.
### Rejecting a request {#erasure-reject}
```http
POST /v1/privacy/erasures/41/reject HTTP/1.1
Content-Type: application/json
{"reason": "این حساب تحت نگهداشت قانونی پروندهی فلان است"}
```
`reason` has to be at least 10 characters or it is a 400. A refusal with no stated reason is the one an auditor asks about first, and "the operator did not say" is not an answer a controller can give.
Only a `pending` or `failed` request can be rejected; anything else is a 409. Rejecting clears `subject_ref` as well: a refused request still must not become a store of the identifier. The refusal itself is recorded rather than deleted, because a refusal that leaves no trace is indistinguishable from a request nobody read.
```json
{"id": 41, "status": "rejected"}
```
## What is deleted {#what-is-deleted}
The order is the contract:
1. The email address is added to the suppression list first, so a failure part way through leaves the person protected rather than merely half deleted.
2. Postgres next, in one transaction, so the operational state is either all gone or all present.
3. ClickHouse last, because a mutation is asynchronous and cannot be part of anybody's transaction.
From Postgres, these tables are cleared on `user_id`: `devices`, `inapp_messages`, `journey_instances`, `journey_timers`, `campaign_timers`, `message_log`, `messenger_identities`, `topic_consent`, `user_consent`, `webpush_subscriptions`. Device tokens go first because they hang off `device_id` rather than the user, and once the devices are gone there is nothing left to join them to, so the tokens would sit there being pushed to forever.
That list is written out by hand rather than discovered from the catalogue, because `information_schema` would also return `audit_log`, `memberships` and `sessions`, whose `user_id` is an integer naming a member of your own staff. An erasure that matched on column name would delete the account of whichever employee happened to share an id with the erased customer.
From ClickHouse: `profiles`, `events`, `segment_members`, `daily_user_stats`, `engagement`, `message_touch`. `daily_user_stats` holds only counts, but it is keyed on `user_id`, so a row in it is a statement that this person existed on this day, which is exactly what an erasure is supposed to remove. `engagement` and `message_touch` are the attribution ledgers: "this person opened that message" is as much a fact about them as the purchase was.
Then the pre-login history: events carrying an empty `user_id`, reachable only through the anonymous ids in `identity_map`. That happens **before** `identity_map` is cleared, and the order is the whole of it. The first version of this code deleted the map first, so the subquery matched nothing, the delete removed nothing and reported success: the person's browsing history from before they signed in, which is a large part of what "forget me" means, would have survived every erasure on the platform silently. An integration test caught it.
`identity_map` goes last, because it is the index into everything above.
Every mutation runs with `mutations_sync = 2`, which waits for every replica to finish before returning. Slower, and the only setting under which "completed" means completed; the default returns as soon as the mutation is **queued**, which would have the report succeed while the rows are still there.
The report is counts, not rows. A report that listed what it deleted would be a copy of the deleted data, sitting in the table whose whole purpose is to record that the data is gone.
No shipped install runs without a warehouse: the API and the janitor both open a ClickHouse connection at startup and refuse to start if it does not answer. The store does carry a no-warehouse branch, and it is silent rather than self-describing. It skips the ClickHouse step and leaves `profiles`, `events`, `identities` and `segment_membership` at zero, which is the same report a warehouse that was searched and held nothing would produce. The report is nine counters with no field for the difference, so whether that step ran is a fact about the deployment, not something the report can be read for.
## What is kept, and why {#what-is-kept}
> [!danger]
> An erasure deletes `user_consent` and `topic_consent`. That means the person's push, SMS and topic opt-outs go with them. Only email is protected, by a permanent suppression row. Re-import the same phone number tomorrow and that person is reachable by SMS again, unless they had previously replied «لغو», which is recorded against the number itself and is left untouched.
After a completed erasure, these remain on the system:
- **The request row itself.** `subject_hash`, `subject_kind`, `requested_by`, the dates and the report counts all survive; `subject_ref`, the readable identifier, is cleared the moment the work finishes. Holding the identifier after the work is done means the table recording the deletion is itself a record of the person who asked to be deleted.
- **That person's email address, forever.** The suppression row is written with the reason `erasure` and an expiry of `NULL`, whatever it was before. We state this plainly because it matters in a legal document: after an erasure, Segmentic still holds the email address. Without it a soft-bounce row for the same address would eventually age out and re-permit mail to somebody who asked to be forgotten.
- **A number that previously replied «لغو».** The SMS opt-out table is keyed by the number itself, not by user id, and an erasure does not touch it. That is deliberate: the reply arrives from a handset, and the person behind it may match no profile, may match several, or may match one that gets deleted and re-imported tomorrow. Deleting that row means the suppression evaporates in all three cases.
- **These tables, which an erasure never reaches:** the engagement score and the churn score, both in Postgres and in their ClickHouse mirrors; on-site survey responses; the relay delivery queue, which keeps a JSON copy of the event itself. None of them clears the erased user's row, and none of them appears in the report counts.
- The RFM scores and the journey trigger memberships are rewritten wholesale on each recomputation, so the person's row disappears on the next run by itself.
- **The daily active-user meter for Tehran**, which is an aggregate state rather than rows about people. It is not rewritten, so an erased person is still inside the distinct count for past days.
- **Export files already built.** An erasure does not touch them. They expire on their own seven-day clock. If somebody has already downloaded one onto a laptop, that copy is beyond our reach.
The application log line records the request id and the role of whoever filed it, and **never the identifier itself**. That line is the audit trail, and one that named the person would be a copy of exactly what is about to be deleted.
The hash is an HMAC over the account id and the normalised identifier, keyed from the install's secret. Being keyed is the whole point: an email address carries far too little entropy to survive SHA-256 on its own, and a table of plain hashes is reversible with a wordlist in minutes, so it would be a table of email addresses wearing a hat. The account id is inside the MAC, so the same address at two customers produces two different tokens; without that, a leaked hash table would let one customer test whether a given person is also a customer of another.
## Retention {#retention}
Erasure is owed to one person who asked. Retention is owed to everybody, whether or not anyone asks, and a platform that implements only the first passes the audit question and still holds four years of somebody's browsing history.
```http
GET /v1/settings/retention HTTP/1.1
```
```json
{
"policy": {
"events_days": 365,
"messages_days": 180,
"bounces_days": 90,
"inactive_profile_days": 0,
"last_swept_at": "2026-08-06T02:14:00Z",
"updated_at": "2026-05-02T11:00:00Z",
"updated_by": "member:1"
},
"min_days": 30,
"max_days": 3650
}
```
```http
PUT /v1/settings/retention HTTP/1.1
Content-Type: application/json
{"policy": {"events_days": 365, "messages_days": 180,
"bounces_days": 90, "inactive_profile_days": 0}}
```
| Field | What it removes |
|---|---|
| `events_days` | raw behavioural events |
| `messages_days` | the send ledger: who was messaged, when, and why they were not |
| `bounces_days` | bounce and complaint reports |
| `inactive_profile_days` | profiles nobody has seen since |
**Zero means keep forever, on every field.** That is the right default for a platform that must not delete a customer's data because somebody left a box empty: deletion is opt-in, always, and an unset policy is not one that deletes nothing by accident, it is one that deletes nothing on purpose.
Any non-zero value has to be at least 30 days and at most 3650. A value outside that is **refused with a reason rather than clamped**. Silently clamping is how somebody comes to believe they set a limit they did not, and here the belief is about how long their customers' data survives.
The floor of 30 days is not there because a shorter window is technically hard. Below roughly a month an account cannot answer "what happened last month", every month-on-month report is empty, and, the part that actually causes support tickets, a campaign's own attribution window outlives the events it is measured against, so the campaign reports zero conversions from real purchases. The ceiling of 3650 days is the outer edge of any Iranian commercial record-keeping obligation; past it the number is not a policy, it is a forgotten field.
The janitor looks for an account to sweep every 15 minutes, sweeps any one account at most once every 24 hours, and touches at most 5 accounts per pass. A failed sweep does not write `last_swept_at`, so the next pass retries it; marking a failed sweep as done is how an account's retention silently stops working.
On events, any wholly expired month goes with `DROP PARTITION`, which unlinks files and costs almost nothing. The events table is partitioned by (account, month) and the account leads deliberately so that this is possible. Only the month straddling the cutoff takes a real mutation, and that one is exact, so the policy means the number of days it says rather than somewhere between N and N plus thirty.
The email suppression list is deliberately absent from this policy and is never swept. A block list that expires re-permits mail to an address that bounced, complained, or belongs to somebody who asked to be forgotten, which is the single failure most likely to cost a sending domain its reputation, and the one an operator is least likely to notice.
## The platform's own fixed limits {#hard-limits}
Independent of your policy, a few numbers sit in the warehouse schema itself:
| What | How long | Independent of your policy |
|---|---|---|
| the `events` table | 400 days | yes |
| the `engagement` ledger | 400 days | yes |
| rejected claims in `engagement` (a bad signature and the like) | 30 days | yes |
| an export file | 7 days | yes |
| the event debugger's recording window | 30 minutes | yes |
So a retention policy of 3650 days on events does not give you 3650 days of events. The table itself drops rows after 400.
In the other direction, these tables have **no TTL and the retention sweep does not touch them**: `daily_user_stats`, `segment_members`, `identity_map`, `message_touch`. A ninety-day events policy therefore still leaves one row per user per day in `daily_user_stats`, forever. That row is not the event itself, it is that person's daily counts, but it still says that person was active that day.
## The open pixel and click tracking {#tracking}
**Email clicks pass through no redirect of ours.** Links inside a message carry two parameters, `sg_mid` and `sg_t`, and the recipient lands directly on your own site, where the SDK that is already there reports the arrival. The reason is that a corporate mail gateway follows every link in every message while scanning it for malware, so a counting redirect would report most of a business list as having clicked.
What that means for your privacy policy: Segmentic sees a click only when your own site or app sends the event. If the landing page has no SDK there is no click data at all. That event is recorded by your system, with whatever your system attaches to it.
An open is different, because it has no landing page. It needs a pixel, and that pixel is served on the ingest host:
```http
GET /e/o?sg_mid=c104.u_9137&sg_t=8mBv2h7oQ1w HTTP/1.1
Host: in.segmentic.net
```
The response is always a 1 by 1 transparent GIF, 43 bytes. That holds for a forged token, for a message we have no record of, and for our own database being down. A broken image in the middle of a marketing email is the most visible defect a recipient can see, and none of those failures is theirs.
When the signature verifies and the recipient resolves, a `message_opened` event is recorded carrying:
- the recipient's user id and the message id.
- **the IP address of whatever fetched the image**, and the country, region and city derived from it.
- the User-Agent string of that request, which is parsed for browser, operating system and whether it is a bot.
- the time, from our clock and never from a header. The lag between send and open is one of the few honest numbers email has, and a value the claimant controls is not a measurement.
The pixel is served with `Cache-Control: no-store` and `Pragma: no-cache`, because Gmail and the other large providers proxy and cache remote images. Without those headers the proxy fetches once, serves its copy forever, and every reopen after the first is invisible.
Open tracking is only enabled where there is a signing token. Without one the pixel is not injected at all: anyone can fetch a URL, so counting an unsigned hit would inflate the one number this channel is judged on.
Unsubscribe is on the same host, and the thing to know is that **unsubscribing with a GET does not unsubscribe**. `GET /e/u` renders a page with a button that POSTs. Security scanners at most Iranian banks and large retailers follow every link in every incoming message before the recipient ever sees it; a GET that opted people out would unsubscribe an entire company list the moment the campaign arrived, silently, and the customer's first sign of it would be a reach report that collapsed. `POST /e/u` is the RFC 8058 one-click path the mailbox providers themselves call, and it has no confirmation step by design. `GET /e/p` is the recipient's own settings screen.
What you have to disclose in your own privacy policy is at least this: marketing email contains a tracking image; the fact and time of an open are recorded along with the IP address and the mail client's user agent; links in the message carry identifiers that connect later activity on your own site to that message; and this is done through a processor.
## What every event stores {#event-data}
**The IP address is stored in full.** There is a column called `ip` on the events table, and no masking, no truncation and no hashing is applied to it. Where the install is configured to trust proxy headers, the leftmost value of `X-Forwarded-For` is taken; otherwise the address of the connection itself.
From that IP the country, region and city are derived and stored in separate columns, unless the SDK supplied a location of its own.
The rest of what an event can carry into the warehouse: the page URL, path, title and referrer, the `utm_*` parameters, the device type, model and manufacturer, the operating system name and version, the browser name and version, the network carrier, the locale, the timezone, the app version, and any property you set on the event yourself.
The raw User-Agent string is **not** stored on the events table; only what is extracted from it survives, plus the bot flag.
Profiles hold the email address, the mobile number, first and last name, gender, date of birth, national id, city, region and country, language, timezone, and your own map of traits. The profiles export deliberately carries neither `ip` nor `national_id`; see [reports and exports](/en/docs/reports).
The event debugger in the panel shows a live tail of raw incoming payloads: email addresses, order contents, and sometimes a phone number in a property nobody meant to include. That is why it takes `profile.read`, why its response sets `Cache-Control: no-store`, and why its recording window is 30 minutes.
What it records is single calls only, `POST /v1/track` and its siblings, plus webhook deliveries. The batch path is not recorded at all, and every shipped SDK batches, so an SDK install shows nothing on that screen. To confirm an app's events are arriving, the connect screen for that app is the tool that works.
## Anonymisation {#anonymisation}
There is no anonymisation endpoint. There is no mode anywhere that keeps a row and blanks its identifier. What exists is two things: deleting one person, and expiring by policy.
The only anonymisation primitive in the whole platform is the HMAC that records an erasure, described under [what is kept](/en/docs/privacy#what-is-kept). Its key is derived from the install's secret, and that secret has to be at least 32 characters or no key is built at all and filing an erasure returns a 503.
> [!warn]
> **An anonymous visitor who never identified themselves cannot be erased by any request.** Their events carry an empty `user_id`, and the only route to them is the anonymous ids that `identity_map` ties to a user id. If that browser was never connected to a user, there is no identifier for a request to name. Those events go when the retention policy or the table's own 400-day TTL takes them, not when somebody asks to be forgotten.
Bots are not deleted either. They are flagged with `is_bot` and left out of reports.
## What does not exist {#gaps}
The honest list, because a gap you know about is worth more than a plausible sentence you do not:
- No privacy endpoint on `api.segmentic.net`. Not filing an erasure, not reading the queue, not reading or changing the retention policy. All panel only.
- No "everything about this one person" export. The timeline caps at 100 events and the profiles export has no single-person filter.
- No callback and no webhook when an erasure completes. You re-read the queue.
- No way to erase an anonymous visitor who was never connected to a user id.
- No per-person retention. The policy is account-wide only.
- No anonymisation mode as an alternative to deletion.
- An erasure does not reach the engagement scores, the churn scores, the survey responses or the relay delivery queue. If your undertaking is complete removal, those four are manual work today.
- No suppression row is written for an erased person's mobile number. Only email gets that protection.
- The counts inside an erasure report cover only the tables the erasure touched, and nothing else.
For how a person changes their own channels and topics, see [consent and unsubscribing](/en/docs/consent). For what goes into an event in the first place, see [events](/en/docs/events).
---
# API versioning and changes
> What can change without notice, what cannot, and how a change to the API updates its own documentation.
> https://segmentic.net/en/docs/versioning
## The version is a path segment {#prefix}
The version today is `v1` and it sits in the URL path. It is the same on both hosts: `https://in.segmentic.net/v1/batch` and `https://api.segmentic.net/v1/whoami`.
The version is kept nowhere else. No version header is read, no custom `Accept` is read, no version query parameter is read, and there is no date-based revisioning. If you send a version anywhere other than the path, it is silently ignored.
The reason is simple: a path segment is visible in a log, in an error report and in a proxy configuration. A header is not, and it gets lost in transit. When you are chasing a failed request at midnight, the path is the one thing that is everywhere.
There is no `/api` segment in the path. The correct path is `/v1/events` on the host `api.segmentic.net`, not `/api/v1/events`. An address carrying that extra segment gets a `404` with the code `unknown_endpoint`, and the message names the method and path you sent.
Only one version has ever been published. There is no `v2` and no date has been announced for one.
The house rule for changing the version is one sentence: the version changes only when the shape of a response changes in a way an existing integration would notice. The test is not the size of the change on our side. The test is whether code that worked yesterday still works today. Rewriting an internal subsystem entirely, while the response keeps its shape, does not change the version. Removing one small key does.
---
## What counts as breaking {#breaking}
These are breaking and are not done without a version change:
- removing a key from a response
- changing the type of a key, for instance from a string to a number or from a scalar to an object
- removing an endpoint or moving its path
- making a field mandatory that was optional yesterday
- narrowing the set of accepted values for an input
- changing the meaning of a key while its name and type stay where they are
- changing the default of a parameter so that the output differs
- changing the status code for a case that already existed
The most dangerous item on that list is the sixth. Removing a key fails loudly and you find out the same day. Changing the meaning of a key fails quietly and may surface in your reporting weeks later. That is why we treat it as the equal of a removal.
When we want to give a fuller answer, the fuller answer goes under a new key beside the old one and the old key's shape is not touched. Suppose `count` is a number and it later becomes necessary to return the breakdown behind it. What we do not do is turn `count` into an object. What we do is add `count_breakdown` beside it. `count` stays the same number, with the same meaning.
Most of the pressure that looks like "we need a new version" is really "we need to return more", and more fits beside the old thing. The long life of `v1` is the result of that one rule.
We do not hide what the rule costs. Responses get larger and busier over time and a few keys in them are relics. We have accepted that against breaking a customer's connection. There is an example in the product today: `GET /v1/schema/traits` still returns `traits` as a bare array of names, because something out there iterates it as strings, and the fuller answer sits beside it in a second key called `schema`.
---
## What does not count as breaking {#not-breaking}
These are done at any time, without notice and without a version change:
- adding a new key to a response
- adding a new optional field to an input
- adding a new endpoint
- adding a new value to the set a field may hold
- adding a new header to a response
- changing the order of keys in a JSON object
- fixing a bug so that the response matches the documentation
- changing response times and internal implementation detail
Two items on that list usually catch integrations out: a new value in an enumerated field, and a new key in a response. Both are non-breaking, so your code has to tolerate both.
A live example from this product: the status of an export today is one of `queued`, `running`, `ready`, `failed` or `expired`. If a sixth status is ever added, that is an additive change and it gets no advance notice. Code that switches on those five values with no default branch falls over that day.
---
## Writing a client that survives our ordinary changes {#tolerant}
If you implement only one thing from this page, make it this: **ignore an unknown key, and send an unknown value to the default branch.** The rest of this page says what we do; this section is the only part that is yours.
Concretely:
- in Go, do not turn on `DisallowUnknownFields` for our responses
- in Java with Jackson, leave `FAIL_ON_UNKNOWN_PROPERTIES` off
- in any other language, keep strict response-schema validation off the main path
```go title="whoami.go"
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
)
// Only the three fields this program uses. A key we add next month is
// decoded into nothing and the program carries on. A strict decoder would
// return an error instead, and the caller would read that as "the API is
// down" on the day we shipped a harmless addition.
type Whoami struct {
TenantID uint32 `json:"tenant_id"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
}
func whoami(ctx context.Context, key string) (Whoami, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.segmentic.net/v1/whoami", nil)
if err != nil {
return Whoami{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return Whoami{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Whoami{}, fmt.Errorf("whoami: http %d", res.StatusCode)
}
var out Whoami
// No DisallowUnknownFields here, deliberately.
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return Whoami{}, err
}
return out, nil
}
func main() {
me, err := whoami(context.Background(), os.Getenv("SEGMENTIC_API_KEY"))
if err != nil {
fmt.Println("could not read whoami:", err)
os.Exit(1)
}
fmt.Println(me.Role, me.Permissions)
}
```
Three other habits make a connection brittle, and none of them is something we can compensate for from our side:
- relying on the order of keys in JSON
- relying on the absence of a key rather than checking its value
- reading a date by slicing the string rather than parsing it
One caveat that causes confusion if it goes unsaid: `POST /v1/messages` rejects unknown fields on the way **in**. That does not contradict the rule above, which is about reading our **responses**. That route is strict about its input because a caller who mistyped `idempotency_key` would otherwise get a brand new key on every retry and send a message per attempt. No other route on either host rejects an unknown input field.
---
## Capabilities, the runtime answer {#capabilities}
The question "can this install do X" has a runtime answer, not a documented one:
```bash
curl -s https://api.segmentic.net/v1/capabilities \
-H "Authorization: Bearer sk_seg_..."
```
```json
{
"version": "v1",
"features": {
"segments": true,
"campaigns": true,
"analytics": true,
"transactional": true,
"export": false,
"import": true,
"journeys": true,
"ingest": true,
"async_exports": true,
"campaign_approval": true
},
"limits": {
"max_page_size": 100,
"max_preview_rows": 100,
"max_batch_size": 500,
"estimate_sample": 100,
"query_timeout_sec": 30
}
}
```
This is our only forward-compatibility mechanism. Rather than hardcoding the list of capabilities in your own code, ask for it here. A capability added later appears in this response without a version change, and your integration sees it without a code change.
Two Segmentic installs genuinely differ. Most capabilities register their routes only when their configuration exists, so a client that assumed the whole surface would be writing against a fiction. A `features.transactional` of `false` means `POST /v1/messages` answers `404` on that install, not `403`.
Limits are published rather than merely documented, so that no client and no agent hardcodes a number we later change. If `max_batch_size` ever rises above 500, a client that read it from here sends larger batches on its own.
Two notes about it:
- Read this response at start-up and hold it for a while. Reading it before every call is pointless load, and it also spends a unit of your [budget](/en/docs/api/management#budget).
- This endpoint announces capability, not data shape. It is not version negotiation and it does not change the shape of any other endpoint's response.
And one necessary honesty: two keys in `features` turn no route on the management host on or off. `export` and `journeys` only report that the subsystem is configured; the two export routes are controlled by `async_exports` rather than by `export`, and no journey route is registered on this host for `journeys` to control. `ingest` and `import` are the same boolean under two names, the one that registers `POST /v1/events`. The values in the sample above are one deployment's, not a promise: each flag means "this subsystem is configured here", so read it against your own install. The full table of which key controls which route is in [the management API reference](/en/docs/api/management#capabilities-features).
---
## How you hear about a change {#notice}
Every breaking change is announced at least **six months** before it takes effect.
The announcement goes out two ways: a changes page in this documentation, and an email to the technical contact of every account that called that endpoint during the period. So if you do not use a part of the API, you do not get an email about it.
Fixing a security vulnerability can shorten that notice. When it does, the reason and the scope of the change are written into the same announcement.
> [!warn]
> Neither of those two channels is automated today, and we write that plainly, because not saying it is worse than the gap itself. There is no changes page in this documentation yet. And no field on an account names a technical contact, so nothing in the product picks the recipients of that email. Until both are built, the reliable way to see that something changed is `GET /v1/capabilities` and this documentation, which ships in the same change as the code.
A change to the policy document itself is announced at least 60 days before it takes effect, and previous versions are archived so it is possible to see what we had committed to on the day an integration was written. The full commitment is in the [API versioning policy](/api-policy). If anything on this page conflicts with that document, that document is the commitment and this page is the description.
---
## No sunset header is sent today {#headers}
No machine-readable deprecation header is sent today. Not `Sunset`, not `Deprecation`, not `Warning` on the response of an endpoint on its way out.
In their place are the two channels above: the changes page and the email. There is no other signal.
We write this down explicitly because the opposite belief is expensive. If somebody writes code assuming the system sends a machine-readable warning before anything is switched off, that warning never arrives and their monitoring never fires. On the day it matters, the first sign is a `404` on a path that worked for years.
If these headers are ever added, that is itself an additive change, it happens without a version change, and this page is updated with it.
---
## How long an old version lives {#old-version}
After a new version ships, the previous one stays alive and answering for at least **one year**.
During that window the old version gets bug fixes and security fixes only. New capability does not appear on it. New capability only lands on the current version, and that on its own is the reason to migrate.
After the window ends, a call to the retired version's path is refused with `410`. It is deliberately not redirected to the new version: silently redirecting an old call to a differently shaped answer is worse than an error. You see an error at once; you may never see malformed data.
Because only `v1` has ever shipped, no version has been retired and no route on either host answers `410` today. If you get a `410`, it is not from Segmentic; it is from a proxy between you and us.
---
## Documentation ships with the change {#docs}
Part of this product is the product and part of it **describes** the product: this API reference, the in-panel help, the landing copy, the privacy policy, the system emails. Descriptions are always downstream of features and are never visible inside the feature's own card.
So this happens: a field gets added to the API, the tests are green, the work looks finished, and at that moment the API reference does not mention the field. Nothing turns red and nobody did anything wrong.
The fourth rule of the Segmentic repository exists for exactly that: **a description of the product ships in the same change as the product.** It is not a style preference, it is an acceptance condition, alongside the three other rules, which are about security and about being bilingual. The rule's own words are that a description which disagrees with the product is worse than no description, because somebody trusts it and is misled by it.
For the API, the relevant row in that rule's table reads: a new endpoint or field in the API means the API reference and its code sample change in the same commit.
### What is checked automatically {#docs-automated}
Three things are automated. One of them runs before a build, the other two in `CI`:
- the documentation guard, which runs before every site build and fails it with exit code 1. It is the only one of the three attached to a build.
- `npm run check:locales` in the panel package, which puts every display string against its English twin. That row belongs to rule 2. It is a step in `CI` and a command you can run yourself; nothing in the panel's build calls it.
- `scripts/check-api-is-documented.mjs`, its own step in `CI`, which is rule 7. It runs in two directions, described below.
The first direction: a route registered on the collector or the management API and missing from that surface's reference turns the build red. Eight routes are exempt, and the script names each one with its reason: the open pixel, the two unsubscribe routes, the two preference-centre routes, the short-link redirect, the bounce intake and the CORS preflight. None of those is a route a customer calls.
The second direction is narrower than it sounds. On any page, a code sample whose request line names `in.segmentic.net` or `api.segmentic.net` with a path that host does not serve turns the build red. A request line written **without** a host is not checked, and that is deliberate: it is the shape the honest pages use to show a call the customer cannot make. [Privacy](/en/docs/privacy) prints `POST /v1/privacy/erasures` under a sentence saying you cannot issue it with `curl`, and [transactional messages](/en/docs/transactional) lists the template routes under a warning that the management host serves none of them. A check that failed those would teach everybody to delete the explanation instead of fixing anything.
The documentation guard catches these:
- a page written in only one language. A page that exists in Persian and not in English is unfinished work, not finished work awaiting translation.
- two languages that do not share their anchors. Switching language mid-page has to keep your place, and it only keeps working if something checks.
- an em dash and its two relatives, which is rule 1.
- the marks of machine-written Persian: harakat, a rightward arrow in right-to-left prose, a middle dot, Arabic-Indic digits, Arabic ye and kaf.
- a link to a page or an anchor that does not exist.
- Latin digits in Persian prose, as a warning.
### What is not checked automatically {#docs-manual}
There is no script that catches all of it and we do not pretend there is.
**Nothing compares a documented field name against the code.** If a field is removed from a response tomorrow and this page still names it, the guard stays green and the build passes. What catches that is a person reading the rule's table from top to bottom before opening a pull request and asking which row this change touches.
So when this documentation disagrees with the API's actual behaviour, **the API is right**. Tell support about it; a wrong page is exactly the thing this rule was written to not have.
---
## What is true today {#today}
The honest summary of the current state, for somebody writing a production integration:
| Thing | Today |
|---|---|
| Published versions | `v1` only |
| `v2` | does not exist, no date announced |
| Where the version lives | a path segment, on both hosts |
| Changes page | not built yet |
| Change-announcement email | no field on an account names a technical contact |
| `Sunset` and `Deprecation` headers | not sent |
| A route that answers `410` | none, because no version has been retired |
| Version negotiation by header | not read |
Three things you can rely on today:
1. `GET /v1/capabilities` at runtime, to know what this install has and what its ceilings are.
2. This documentation, which ships with the change under rule 4. The machine-readable description of the same surface is in [the OpenAPI document](/en/docs/openapi).
3. The [API versioning policy](/api-policy) page, which is our written commitment and whose previous versions are archived.
And one thing not to rely on today: no machine-readable signal arrives before a breaking change. If you are building monitoring, build it on error codes and on `GET /v1/status`, not on a header that does not come.
---