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

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

Twenty-two authenticated routes, plus a status probe and a catch-all. Nothing else exists on this host.

Method and pathPermissionCostSoft lockRegistered when
GET /v1/statusnonenonenoalways
GET /v1/whoaminone1noalways
GET /v1/capabilitiesnone1noalways
GET /v1/schema/eventsevent.read5noalways
GET /v1/schema/traitsevent.read5noalways
GET /v1/ingest/qualityevent.read5noalways
POST /v1/audiences/validatesegment.read1noalways
POST /v1/audiences/countsegment.read25noalways
GET /v1/segmentssegment.read1nosegments
GET /v1/segments/{id}segment.read1nosegments
POST /v1/segmentssegment.write1nosegments
PUT /v1/segments/{id}segment.write1nosegments
DELETE /v1/segments/{id}segment.delete1nosegments
GET /v1/campaignscampaign.read1nocampaigns
GET /v1/campaigns/{id}campaign.read5nocampaigns
POST /v1/campaignscampaign.write1nocampaigns
PUT /v1/campaigns/{id}/recurrencecampaign.send1nocampaigns and campaign_recurrence
DELETE /v1/campaigns/{id}/recurrencecampaign.write1nocampaigns and campaign_recurrence
POST /v1/campaigns/{id}/sendcampaign.send1nocampaigns
GET /v1/templatestemplate.read1notemplates
GET /v1/templates/{id}template.read1notemplates
POST /v1/templatestemplate.write1notemplates
POST /v1/templates/rendertemplate.read1notemplates
GET /v1/journeysjourney.read1nojourneys
GET /v1/journeys/{id}journey.read5nojourneys
POST /v1/journeysjourney.write1nojourneys
GET /v1/journeys/{id}/draftjourney.read1nojourneys
POST /v1/journeys/validatejourney.read1nojourneys
POST /v1/journeys/{id}/publishjourney.publish1nojourneys
POST /v1/journeys/{id}/{action}journey.write1nojourneys
POST /v1/campaigns/{id}/submitcampaign.write1nocampaigns and campaign_approval
POST /v1/eventsprofile.write5noingest
GET /v1/exportsdata.export1yesasync_exports
POST /v1/exportsdata.export25yesasync_exports
POST /v1/reports/funnelanalytics.read25yesanalytics
POST /v1/reports/retentionanalytics.read25yesanalytics
POST /v1/messagescampaign.send1notransactional

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:

  1. The header Authorization: Bearer <token> (the scheme name is case-insensitive)
  2. The header X-Segmentic-Key: <token>
  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.

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

CodeWhat happenedMessage
unauthenticatedNo credential at all, or one that did not resolve (revoked key, unknown key, suspended account)a valid API key is required
api_key_requiredA valid session was offered, as a cookie or as a bearer tokenthis API accepts sk_seg_ keys only; session credentials are not valid here
write_key_rejectedA token starting with wk_, refused before any database lookupthe body below
key_expiredThe key's expires_at has passedthis 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_…)"
  }
}

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.

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.

Shell
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
}
FieldTypeAlways presentMeaning
tenant_idnumberyesthe account id
api_key_idnumberyesthis key's id. The name on the wire is api_key_id, not key_id
rolestringyesone of the seven roles
permissionsarray of stringsyes, and empty is [] rather than nullthe effective set, sorted alphabetically
scopedbooleanyeswhether 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.

Shell
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

KeyWhich routes it turns on
segmentsthe five /v1/segments routes
campaignsthe seven /v1/campaigns routes
campaign_recurrencePUT and DELETE /v1/campaigns/{id}/recurrence
analyticsPOST /v1/reports/funnel and POST /v1/reports/retention
transactionalPOST /v1/messages
exportnothing on this host. It is the panel's CSV exporter. Informational only
importnothing on this host. It is the panel's CSV upload
journeysnothing on this host. Journeys have no public route
ingestPOST /v1/events
async_exportsGET /v1/exports and POST /v1/exports
campaign_approvalPOST /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

KeyValueWhat it actually bounds
max_page_size100the ceiling on ?limit= on GET /v1/exports, the only route that reads it
max_preview_rows100the panel's segment preview. No route on this host honours it, because there is no public preview endpoint
max_batch_size500the most events in one POST /v1/events
estimate_sample100the sampling rate of the panel's live counter. No route on this host uses it
query_timeout_sec30the 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.

PermissionWhat it opens on this host
segment.readPOST /v1/audiences/validate, POST /v1/audiences/count, GET /v1/segments, GET /v1/segments/{id}
segment.writePOST /v1/segments, PUT /v1/segments/{id}
segment.deleteDELETE /v1/segments/{id}
profile.readnothing
profile.writePOST /v1/events
event.readGET /v1/schema/events, GET /v1/schema/traits
campaign.readGET /v1/campaigns, GET /v1/campaigns/{id}
campaign.writePOST /v1/campaigns, POST /v1/campaigns/{id}/submit, DELETE /v1/campaigns/{id}/recurrence
campaign.sendPUT /v1/campaigns/{id}/recurrence, POST /v1/campaigns/{id}/send, POST /v1/messages
campaign.approvenothing. There is no public approve route
analytics.readPOST /v1/reports/funnel, POST /v1/reports/retention
data.exportGET /v1/exports, POST /v1/exports
journey.readnothing
journey.writenothing
journey.publishnothing
template.readnothing
template.writenothing
member.readnothing
member.writenothing
apikey.readnothing
apikey.writenothing
settings.readnothing
settings.writenothing
billing.readnothing
billing.writenothing
audit.readnothing
tenant.transfernothing, and no key can hold it
tenant.deletenothing, 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.

RoleWhat it can do on this host
ownerall twenty-two routes. But an API key is never created with this role
adminall twenty-two routes
marketerall twenty-two routes. It holds all ten permissions this host uses
analystthe event and trait schema, validate and count an audience, read segments, read campaigns, reports, exports
viewerthe same as analyst, minus exports
approverthe same as viewer. It also holds campaign.approve, which opens no door here
financenothing. 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:

ClassUnitsMeaning
trivial1reads nothing, or reads one row by primary key
query5one bounded warehouse query
heavy25a 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.

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:

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.

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 valueWhenMeaning
overdue_75An issued invoice is 75 or more whole days past its due datesomebody has to pay an invoice
usage_300Usage reached three times the plan's included profiles or included events, whichever is higherthe 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

Unauthenticated, no cost, no database. The only route that still answers when everything else is answering 503.

Shell
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

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

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

#GET /v1/schema/traits

Shell
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

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:

TraitWhat it answers
has_pushwhether the person can receive a push at all. An audience without it is mostly people who will never see the message
days_until_birthdaydays 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_seena 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.

Shell
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

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

Shell
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

Permission segment.read, cost 25. The count is exact, not sampled.

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

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.

Shell
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}

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.

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

StatusCodeWhen
400malformed_jsonthe body is not valid JSON
400name_requiredthe name is blank. Message: a segment needs a name
422filter_invalidthe definition did not validate
503segment_unavailablethe 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:

  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}

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.

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

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}

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

Permission campaign.write, cost 1.

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

FieldTypeRequiredRule
namestringnonot validated at all. An empty name is accepted
channelstringin effect yesmust be in the list below. An empty string means "the template decides"
template_idnumberyeszero produces campaign: a template is required
channelsarraynothe whole chain in order, each {channel, template_id}, and its first entry must be channel itself. See below
segment_idnumbernozero means the inline definition is the audience
definitionobjectnonot validated here, unlike a segment
topic_idnumbernozero means no subscription topic
scheduled_atRFC3339noan unparseable value is silently dropped, not refused
use_local_timebooleannodefaults to false
local_hournumbernovalidated to 0 through 23 only when use_local_time is on
throttle_minutesnumbernonot validated
control_group_pctnumbernomust be between 0 and 100
audience_pctnumbernothe pilot slice. Must be between 0 and 100, and 0 means everybody, not nobody
goal_eventstringnoempty 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.

StatusCodeWhen
400malformed_jsonthe body is not valid JSON
400invalid_channelmessage unknown channel "bogus", with details of {"field":"channel"}
422campaign_invalidthe campaign validation message
503campaign_unavailablethe 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.

Shell
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" }
StatusCodeWhen
400bad_id or malformed_jsonthe id or body cannot be read
422recurrence_invalidcadence, hour, weekday or month day is invalid
503recurrence_unavailablethe 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.

Shell
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

Permission campaign.send, cost 1. No body is read. This is the irreversible call.

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

StatusCodeWhen
400bad_ida non-positive id in the path
409approval_requiredapproval is required and there is none, or it was rejected, or it is not yet approved
409approval_stalethe campaign changed after it was approved. Submit it again
404not_foundthe campaign could not be read
503approval_unavailablereading the rule or the approval state failed. Fails closed
503campaign_unavailablescheduling 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.

JSON
{
  "approval_id": 88,
  "state": "pending",
  "fingerprint": "3f1a9c02b7de4415aa0e8c1d2f6b3790"
}

Status 202. The state values are pending, approved, rejected and stale.

StatusCodeWhen
400bad_ida non-positive id
404not_foundthe campaign does not exist
409not_submittablethe campaign is not draft or paused
422campaign_invalidthe campaign did not validate
503approval_unavailablethe 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.

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

StatusCodeWhen
400malformed_jsonthe body is not valid JSON
400batch_emptythe events array is empty
413batch_too_largemore than 500 events. details is {"limit":500,"sent":501}
402quota_cancelled, quota_trial_over or quota_event_capa commercial ceiling. The whole batch is refused
422all_events_rejectedno item could be accepted. details is the array of rejections
503ingest_unavailablethe 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 hostPOST /v1/events here
Credentialpublic wk_seg_...secret sk_seg_...
The array key in the bodybatchevents
Success status200202
rejecteda count, with the array under errorsthe array itself
Warningsreturned, bounded to 50discarded entirely
De-duplication by message_idyesno. A retried batch is counted twice here
IP and User-Agentread, and used for geographydeliberately 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 windowfrom that account's own retention policynot 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.

Shell
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}

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

template.write, cost 1. Body limit 8 MB.

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

StatusCodeWhen
400name_requiredthe name is empty after trimming
400content_requiredneither a title nor a body
400invalid_channelthe channel was not recognised
503template_unavailableit 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.

Shell
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

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.

Shell
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}

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

journey.write, cost 1. Saves a draft. Nothing is published.

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

StatusCodeWhen
400name_requiredno name
400graph_requiredno graph
409name_takenthis account already has a journey with that name
503journey_unavailablethe 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:

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

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

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}

journey.write, cost 1. Three actions, and no others:

ActionStatus afterWhat it means
pausepausednew entries stop. Everybody already inside stays where they are
resumeactiveentries start again
archivearchivedit 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

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.

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

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.

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

FieldRequiredRule
kindyesone of events, messages, profiles, segment
formatnoanything that is not exactly csv becomes ndjson, including a typo and including unknown formats
specnopassed 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.

StatusCodeWhen
400malformed_jsonthe body is not valid JSON
422export_kind_invalidmessage: kind must be one of events, messages, profiles, segment
503export_unavailablethe 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

Shell
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": "..."
}
FieldRequiredRule
stepsyesbetween 2 and 12 steps
steps[].nameyesthe event name, non-blank, at most 256 characters
steps[].labelnothe chart label
steps[].filtersnoat most 10 filters per step
steps[].filters[].opyestext: eq, ne, contains, prefix. Numeric: gt, gte, lt, lte, num_eq, num_ne
range.from and range.toyesRFC3339, from before to, span at most 730 days
windowyes, and it must be greater than zerolike "7d", "1.5d", "36h". It must not exceed the range
strictnodefaults to false
split_bynofrom 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

Shell
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": "..."
}
FieldRequiredDefaultRule
startnoemptyan empty name means "any activity"
returnnoemptythe same
rangeyesnonespan at most 730 days
granularitynodayone of day, week, month
periodsno30at 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.

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

FieldRequiredRule
user_idyesnon-empty
channelyesone of push, sms, email, webpush, inapp, bale, eitaa, rubika
categorynotransactional (the default) or critical. marketing is refused
template_idyesnon-zero. An inline message body is not accepted at all
varsnoat most 40 keys
idempotency_keyyesthe 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.

CaseAnswer
The first call200 with the result
A repeat after the first finished200 with the stored body, plus "replayed": true
A repeat while the first is still running409 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 dispatchallowed 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: 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

All of them in the flat envelope, not this API's:

StatusBody
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"}
200the 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:

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

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.

RouteWhich failureThe actual body
GET /v1/schema/events503{"error":"schema unavailable"}
GET /v1/schema/traits503{"error":"schema unavailable"}
POST /v1/audiences/validate400 bad JSON{"error":"malformed JSON"}
POST /v1/audiences/count400 and 503{"error":"segment: ..."} and {"error":"count unavailable"}
GET /v1/segments503{"error":"segments unavailable"}
GET /v1/segments/{id}400 and 404{"error":"invalid segment id"} and {"error":"segment not found"}
GET /v1/campaigns503{"error":"campaigns unavailable"}
GET /v1/campaigns/{id}400 and 404{"error":"invalid campaign id"} and {"error":"campaign not found"}
POST /v1/reports/funnel400 and 503{"error":"<Persian>","code":"invalid_report"} and {"error":"<Persian>"}
POST /v1/reports/retention400 and 503the same
POST /v1/messagesevery failurea 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

CodeStatusMeaning
unauthenticated401no credential, or one that did not resolve
api_key_required401a session was offered
write_key_rejected401a wk_ token was offered
key_expired401the key has expired
forbidden403a permission is missing. Read need
account_locked403the soft lock. Read details.reason
budget_exhausted429the minute's budget is spent. Wait, then retry
budget_unavailable503the budget counter is unreachable. Transient
unknown_endpoint404wrong path or wrong method
malformed_json400the body is not JSON
bad_id400the path id is not a positive integer
not_found404the segment or campaign does not exist
filter_invalid422the segment definition was refused
name_required400the segment name is blank
segment_unavailable503the segment store failed
invalid_channel400the campaign channel did not resolve
campaign_invalid422campaign validation failed
campaign_unavailable503the campaign store failed
not_submittable409the campaign is not draft or paused
approval_required409approval is required and there is none
approval_stale409the campaign changed after it was approved
approval_unavailable503reading or writing an approval failed
export_kind_invalid422kind is not in the list
export_unavailable503the export queue failed
batch_empty400the events array is empty
batch_too_large413more than 500 events
all_events_rejected422no event could be accepted
ingest_unavailable503the ingest queue is unavailable
quota_cancelled402the subscription is not serving
quota_trial_over402the trial has ended
quota_event_cap402a hard event ceiling
quota_message_cap402declared 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 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.

PreviousIngest endpointsNextErrors

On this page

  • The host and the key
  • Authorisation
  • GET /v1/whoami
  • GET /v1/capabilities
  • Permissions
  • The request budget
  • Pagination
  • The soft lock
  • GET /v1/status
  • The data schema
  • Audiences without saving
  • Segments
  • Campaigns
  • POST /v1/events
  • Message templates
  • Journeys
  • Exports
  • Reports
  • POST /v1/messages
  • The error envelope
  • What this API does not do

Segmentic

This page is written from the code