Reference: the management API
Stable management routes for audiences, campaigns, journeys, reports and messages, with each required permission.
The host and the key
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
OPTIONSfrom a browser reaches the catch-all and gets a404. - 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
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 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 and the lock column in the soft lock.
Authorisation
The credential is read from three places, in this order:
- The header
Authorization: Bearer <token>(the scheme name is case-insensitive) - The header
X-Segmentic-Key: <token> - The cookies
__Host-segmentic_sessionand thensegmentic_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.
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 |
{
"error": {
"code": "write_key_rejected",
"message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
}
}
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.
{
"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
403with the codeip_not_allowed. - A Segmentic staff credential on this host gets
401with the codewrong_surface.
Both answer in the panel's flat envelope, not this API's. See the error envelope.
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
No permission, cost 1. Any valid key gets an answer. This and 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.
curl -s https://api.segmentic.net/v1/whoami \
-H "Authorization: Bearer sk_seg_..."
{
"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.
The remaining budget is not in this response. There is no way to ask how much budget is left. See the request 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
No permission, cost 1. This response says what this install serves today and which ceilings it enforces.
curl -s https://api.segmentic.net/v1/capabilities \
-H "Authorization: Bearer sk_seg_..."
{
"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
| 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
| 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
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
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
"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/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:
{"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.
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.
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
Read this section in full, because what the code does differs from what you expect.
The page envelope looks like this:
{
"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.
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
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:
{
"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_lockedmeans "reports and exports are closed", not "the account is off".POST /v1/events,POST /v1/messagesand 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
Unauthenticated, no cost, no database. The only route that still answers when everything else is answering 503.
curl -i https://api.segmentic.net/v1/status
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
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
curl -s https://api.segmentic.net/v1/schema/events \
-H "Authorization: Bearer sk_seg_..."
{
"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.
GET /v1/schema/traits
curl -s https://api.segmentic.net/v1/schema/traits \
-H "Authorization: Bearer sk_seg_..."
{
"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
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:
{
"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
What we refused from you, and what we corrected, per day.
curl -s "https://api.segmentic.net/v1/ingest/quality?days=7" -H "Authorization: Bearer sk_seg_..."
{
"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
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:
{
"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. 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
Permission segment.read, cost 1. It does not touch a database.
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":"تهران"}}}}'
{
"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:
{
"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
Permission segment.read, cost 25. The count is exact, not sampled.
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":"تهران"}}}}'
{
"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.
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
A segment is the saved object. It has five routes, all registered only when features.segments is on.
GET /v1/segments
Permission segment.read, cost 1.
curl -s https://api.segmentic.net/v1/segments \
-H "Authorization: Bearer sk_seg_..."
{
"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}
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
Permission segment.write, cost 1. Body cap 8 MiB.
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": "تهران" }
}
}
}'
{
"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.
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}
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:
- The definition is validated before the existence check. An invalid filter on an id that does not exist is still a
422. - The segment is read first. An unknown id, or another account's, is a
404with the codenot_found, rather than a write that silently creates a new segment. - A blank or omitted
namekeeps 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}
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.
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
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
Permission campaign.read, cost 1. Not paginated, and capped at the 200 most recently updated campaigns with nothing in the response saying so.
{
"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}
Permission campaign.read, cost 5. This is the campaign report, not just the record.
{
"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:
reachanswers 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
engagementships 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, andmeasurable_clickis what says so. A single blended percentage is the number a customer cannot reproduce. percentis 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
Permission campaign.write, cost 1.
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"
}'
{ "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.
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
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.
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.
{ "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
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.
curl -s -X DELETE https://api.segmentic.net/v1/campaigns/5/recurrence \
-H "Authorization: Bearer sk_seg_..."
{
"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
Permission campaign.send, cost 1. No body is read. This is the irreversible call.
curl -s -X POST https://api.segmentic.net/v1/campaigns/5/send \
-H "Authorization: Bearer sk_seg_..."
{ "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.
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
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.
{
"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.
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
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.
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" }
}
]
}'
{ "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:
{
"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
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 |
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
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
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
template.read, cost 1.
curl -s https://api.segmentic.net/v1/templates \
-H "Authorization: Bearer sk_seg_..."
{
"templates": [
{ "id": 42, "name": "SMS welcome", "channel": "sms", "category": "marketing",
"title": "", "body": "Hello {{name}}, welcome." }
]
}
GET /v1/templates/{id}
template.read, cost 1. The whole template, plus three things the list does not carry:
{
"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
template.write, cost 1. Body limit 8 MB.
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."
}'
{ "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
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.
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" } }'
{
"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
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
journey.read, cost 1. The journeys with their status, their published version and how many people are inside them right now.
curl -s https://api.segmentic.net/v1/journeys \
-H "Authorization: Bearer sk_seg_..."
{
"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}
journey.read, cost 5. Returns the published version, plus a counter per node.
{
"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
journey.write, cost 1. Saves a draft. Nothing is published.
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 } }
]
}
}'
{ "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
journey.read, cost 1. The saved draft plus two lists:
{
"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
journey.read, cost 1. Checks a graph you have not saved. Nothing is stored and nothing changes.
{ "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
journey.publish, cost 1. Publishes a new version and returns its number.
{ "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}
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 |
{ "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
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
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.
curl -s "https://api.segmentic.net/v1/exports?limit=50" \
-H "Authorization: Bearer sk_seg_..."
{
"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.
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
Cost 25.
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" }
}'
{
"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.
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
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.
POST /v1/reports/funnel
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"
}'
{
"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:<key> |
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
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
}'
{
"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
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.
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"
}'
{
"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}$ |
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
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.
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
This route is metered twice and has two different refusals with two different bodies:
- The request budget, one unit, per key. Refusal:
429with the codebudget_exhaustedin this API's envelope. - The message rate limiter, one request, per account per calendar minute. Refusal:
429withRetry-After: 60and 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
All of them in the flat envelope, not this API's:
| Status | Body |
|---|---|
400 | {"error":"malformed JSON: <detail>"} 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
The envelope looks like this:
{
"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:
{
"error": {
"code": "unknown_endpoint",
"message": "no such endpoint: POST /v1/team/keys, see GET /v1/capabilities"
}
}
The envelope is not one shape
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":"<string>"} or {"error":"<string>","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":"<Persian>","code":"invalid_report"} and {"error":"<Persian>"} |
POST /v1/reports/retention | 400 and 503 | the same |
POST /v1/messages | every failure | a flat {"error":"<string>"} |
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.
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
| 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 page carries this same list with a suggested action for each.
What this API does not do
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 anext_cursorkey. PATCHon anything. OnlyPUTwith a whole-object replace, with noIf-Matchand no version token.- Idempotency on any route other than
POST /v1/messages. A retried timeout onPOST /v1/segmentscreates 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/pathsis 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.