API reference
Two surfaces, two kinds of key, two hosts. This page says which job belongs to which.
Segmentic answers on two hosts. They share nothing: not a credential, not a request shape, not an error shape. A request sent to the wrong one fails with an authentication error that says nothing about the mistake, so which host a job belongs to is the first thing to get right.
The two surfaces
| Ingest | Management | |
|---|---|---|
| Host | https://in.segmentic.net | https://api.segmentic.net |
| Key | wk_seg_... | sk_seg_... |
| Where the key lives | inside your app: a JavaScript bundle, an APK, an IPA | on your own server, in an environment variable |
| Who calls it | your users' devices | your backend, a script, an agent |
| What it does | writes events, devices and addresses | reads and changes what is in the account |
| Reference | Ingest endpoints | Management API |
The panel, https://app.segmentic.net, is a third host and it is a website, not an API. The API it talks to runs on a listener that is not routed from the internet at all, which is how the two routes that return a plaintext credential in their body are kept unreachable rather than merely forbidden.
Every path on both surfaces is under /v1/. There is no /api segment and no other prefix.
The two keys
Both keys are thirty-two bytes from the operating system's random source, base64url encoded without padding, with a prefix in front. So a real key is wk_seg_ or sk_seg_ followed by forty-three characters. Only the SHA-256 hash of a key is stored, which is why neither can be shown to you a second time: a leaked database dump must not be a pile of working credentials.
The two prefixes differ so that a leak can be triaged in one glance. A wk_ in a public bundle is working as designed. An sk_ in the same place is an incident.
Write key wk_seg_ | Management key sk_seg_ | |
|---|---|---|
| Identifies | one app inside one account | one key inside one account, carrying a role |
| Permissions | none, and none are possible | whatever its role grants |
| Can read a person's data | no, except that person's own in-app inbox, which needs a second credential | yes, if the role allows it |
| Public | yes, deliberately | no. It is a secret |
| Expiry | none | 365 days by default, chosen at creation |
| Revocation | per app, in the panel | per key, in the panel |
A write key resolves to an account, an app, and the app's environment (development, staging or production). That is all it carries. It cannot read a profile, list a segment, count an audience or send a message, and there is no setting that would let it.
A management key resolves to a role: admin, marketer, analyst, viewer, approver or finance. The role decides the permissions and the permissions decide the routes. GET /v1/whoami returns the effective list, and a 403 names the exact permission you were missing in a need field, so nobody has to open a support ticket to learn which one to grant.
Getting each key
A write key comes from the panel, on the SDK Setup screen. Choose the app, create a key, copy the plaintext. Each app carries its own key so that revoking one does not silence the others, and the plaintext is on the screen once and never recoverable afterwards.
A management key comes from the panel, under Settings, API keys. You choose the role at creation and the key holds it for life. Three rules the screen enforces: the owner role is refused, so no key can ever transfer or delete an account; a key cannot outrank the person creating it; and a blank expiry becomes 365 days rather than never.
Neither key can be created through the management API. Both creation routes live on the dashboard's own listener, which is not routed publicly, so minting a credential is something a signed-in person does in the panel. This is deliberate and it is not going to be relaxed.
Scoped keys do not work. The database carries a scopes column on every key, the lookup reads it, and nothing in the product ever writes it, so every key holds its whole role and GET /v1/whoami always answers "scoped": false. The narrowest key you can actually create is the narrowest role. The per-key recipient and PII budgets in the same table are in the same state: columns exist, no code reads them.
The management key never reaches a client
The write key is public on purpose. It ships in your JavaScript, anybody can read it out of the page source, and that is acceptable because the worst a stranger can do with one is add noise to your own data, which is visible and repairable.
The management key is the opposite. It can count your audiences, export your customers' phone numbers and send messages to all of them. It goes on your server, in an environment variable, and it goes nowhere else. Not in a mobile app, where it can be extracted from the binary. Not in a browser, where it is one view-source away. Not in a mobile app's build config, which is the same thing with an extra step.
Swapping the two is common enough that both directions have a defined answer, and only one of them tells you what you did wrong.
A wk_ key sent to the management host is 401 with its own code:
{
"error": {
"code": "write_key_rejected",
"message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
}
}
The refusal happens before any database lookup, on the prefix alone.
An sk_ key sent to the ingest host is 401 with the ordinary refusal:
{"status":"error","message":"invalid write key"}
The collector never looks at the prefix. It hashes whatever it was given and looks the hash up in the write-key table, where a management key is simply not present, so it is indistinguishable from a key that was revoked or never existed. That collapsing is deliberate: the endpoint must not become a way to test which keys exist. The cost is that this direction gives you no hint, and if you are staring at invalid write key with a key you are certain is valid, check its prefix first.
Which job belongs to which host
| Job | Host | Route |
|---|---|---|
| Record what a person did | ingest | POST /v1/track |
| Set traits on a profile | ingest | POST /v1/identify |
| Record a page or screen view | ingest | POST /v1/page, POST /v1/screen |
| Attach an anonymous history to a signed-in person | ingest | POST /v1/alias |
| Send many events at once from a device | ingest | POST /v1/batch |
| Send events from your own server | management | POST /v1/events |
| Register a device for push | ingest | POST /v1/devices |
| Subscribe a browser to web push | ingest | POST /v1/webpush/subscribe |
| Link a Bale, Eitaa or Rubika chat | ingest | POST /v1/messenger/link |
| Read a signed-in user's in-app inbox | ingest | POST /v1/inbox |
| Fetch the on-site campaigns for a page | ingest | GET /v1/onsite |
| Validate or count an audience | management | POST /v1/audiences/validate, POST /v1/audiences/count |
| Create, change or delete a segment | management | POST /v1/segments, PUT /v1/segments/{id}, DELETE /v1/segments/{id} |
| Create a campaign, then send it | management | POST /v1/campaigns, POST /v1/campaigns/{id}/send |
| Run a funnel or retention report | management | POST /v1/reports/funnel, POST /v1/reports/retention |
| Send one transactional message | management | POST /v1/messages |
| Queue an export | management | POST /v1/exports |
| Ask what a key may do | management | GET /v1/whoami |
| Ask what this deployment serves | management | GET /v1/capabilities |
| Check a host is up | both | GET /v1/status |
POST /v1/batch on the ingest host and POST /v1/events on the management host both take an array of events and they are not interchangeable. The array key is batch on one and events on the other. The ingest one answers 200 and de-duplicates by message_id; the management one answers 202 and does not de-duplicate at all, so a retried batch double-counts. The management one also clamps any timestamp older than thirty days to exactly thirty days ago instead of refusing it, which silently ruins a historical migration. Both are documented in full on their own pages.
Shared conventions
JSON both ways. Every response on both surfaces carries Content-Type: application/json; charset=utf-8. Neither surface checks the request's own content type: both read the body and parse it as JSON whatever it claims to be. Send application/json anyway, because that is what will be checked the day it changes.
Timestamps are RFC 3339 and nothing else. Every time field on the wire, timestamp, sent_at, scheduled_at, the from and to of a report range, is decoded by Go's standard JSON decoder, which accepts RFC 3339 only. Epoch seconds, epoch milliseconds and a bare 2026-08-06 all fail to decode, and on the ingest host that fails the whole request with malformed JSON. Send UTC.
Text is UTF-8 and Persian is normalised at ingest. Arabic look-alike letters are folded to their Persian forms, ي becomes ی and ك becomes ک, diacritics and tatweel are stripped, and exotic whitespace is collapsed. The half-space (ZWNJ), letter case and Persian digits are kept exactly as sent. This is why a segment on city = تهران matches a user whose keyboard was Arabic.
Ids you send are strings and ids we return are numbers. user_id, anonymous_id and message_id are strings of at most 256 bytes with no format requirement. A segment id, campaign id or export id is an unsigned integer in JSON, and the path form must be a positive integer or the answer is 400.
Nothing in a body can change the account. Both surfaces take the account from the credential and overwrite whatever the body says. A tenant_id in a segment body is not rejected, it is simply never read.
The two error envelopes
The ingest host answers a failure in the same envelope it answers a success:
{"status":"error","message":"request body too large"}
The management host answers an object with a stable code:
{
"error": {
"code": "forbidden",
"message": "this key does not carry data.export, see GET /v1/whoami for what it does carry",
"need": "data.export"
}
}
The code is the contract. The message is not, and an integration that branches on message text will break the first time the wording improves. details appears on some validation failures and carries the offending part of your payload. need appears only on a 403.
On the management host the envelope is not uniform, despite the comment in the source saying it is. Eleven of the twenty-two routes reuse handlers that were written for the panel and answer {"error":"count unavailable"}, where error is a string rather than an object, and the two report routes answer in Persian. Parse defensively: read error, branch on whether it is a string or an object, and never assume error.code exists. The Management API reference names which route answers which.
Pagination
There is none worth the name, and this is the gap most likely to cost you an afternoon.
?limit= is read on exactly one route, GET /v1/exports, where it defaults to twenty-five and is clamped to one hundred. ?cursor= is read there too and then discarded. Every other list route ignores both parameters. No handler ever emits next_cursor, so the key is absent from every response body rather than present and empty, and has_more is always false even when there are more rows.
GET /v1/segments and GET /v1/campaigns are worse than unpaginated: the SQL behind them ends in ORDER BY updated_at DESC LIMIT 200, and nothing in the response says so. There is no count, no has_more and no warning. An account holding 250 segments receives the 200 most recently updated, and no route on any surface reaches the other 50. Treat these two lists as "the newest 200", and keep anything you need addressable by id.
Limits and budgets
The ingest host has no rate limit. Not per second, not per minute, not per key, not per IP. The only volume control on it is the monthly billing quota, and when that is spent the answer is 402 Payment Required with a Persian sentence and the whole request refused. An SDK must not retry a 402: nothing changes until somebody pays.
The management host meters by weight, not by count. Each key gets six hundred units per calendar minute. A call that reads nothing costs one unit, a bounded warehouse query costs five, a scan that grows with your history costs twenty-five. The budget is per key rather than per account, so a runaway agent cannot exhaust the budget your order pipeline depends on. Exhaustion is 429 with Retry-After: 60:
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
There are no X-RateLimit-* headers on the budget and GET /v1/whoami does not report what is left, so a client cannot see how close it is until it is refused. The only rate headers on the whole surface are X-RateLimit-Limit and X-RateLimit-Remaining on POST /v1/messages, and those describe a second, separate per-account limiter that counts requests rather than weight.
The budget fails closed. If the counter store cannot be reached the answer is 503 with code budget_unavailable, including on GET /v1/whoami and GET /v1/capabilities, because those spend budget too. GET /v1/status is the only route that survives that outage. The direction is deliberate: an unmetered agent in a loop is more expensive than a report that waits.
Checking a host is up
Both hosts serve GET /v1/status with no credential, no database read and no rate limit.
curl -i https://in.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":"collector","version":"1.42.0"}
service is collector on the ingest host and api on the management host, so a status page can show one dot per component. version is the build stamp, which makes "is the deploy out" answerable from outside. X-Server-Time exists so that a probe measuring round-trip time can tell "we are slow" from "the path between you and us is slow". The body is never cached, because a status page reading a cached ok from a CDN during an outage is worse than no status page.
Local development
The collector listens on http://localhost:8080 by default (HTTP_ADDR). Everything on the ingest host works there with a write key from your local account.
The management API listens on whatever PUBLIC_API_ADDR names, and that variable is empty by default, which means the management API is not served at all until somebody sets it. The symptom is a refused connection, not a 404, and it is the first thing to check when your server-side integration cannot reach anything. The deployment binds it to :8082, which is also the address the MCP server assumes when SEGMENTIC_API_URL is unset.
https://in.segmentic.ir is not the ingest host and is deliberately not redirected to the current one. An SDK still pointed at the old name is meant to fail loudly rather than keep working.
What is not here
Documented as absent because finding out by trying costs more:
- No way to create, list or revoke a key through the API. Both key types are panel-only.
- No way to stop a campaign.
POST /v1/campaigns/{id}/sendis on the management host; pause, resume and cancel are not. Once your backend schedules a send, only the panel can stop it. - No way to download an export. You can queue one and list it. The file is collected through the panel.
- No way to approve a campaign, or to read whether it was approved. Submission has a route, the decision does not. An integration learns the answer by retrying the send and reading the
409code. - No message status lookup. There is no
GET /v1/messages/{idempotency_key}. - No profile read. There is no
GET /v1/profiles/{user_id}on either host. - No journey, template, consent, governance or audit routes on the public surface.
- No
PATCHanywhere.PUT /v1/segments/{id}replaces the whole object, with noIf-Matchand no version token, so two concurrent writers silently clobber each other. - No idempotency except on
POST /v1/messages. A retried timeout onPOST /v1/segmentscreates a second segment. - No IP geolocation.
country,regionandcityare filled only from what the SDK sends incontext.location. - No CORS preflight on the management host. No
OPTIONSresponder is registered on that mux, so a browser's preflight reaches the catch-all and gets a404and the real call is never made. A browser cannot call the management API at all. That is the intended state: ansk_seg_key does not belong in a page. - A wrong method is not a
405on the management host.PUT /v1/campaigns/5falls through to the catch-all and answers404 unknown_endpoint. On the ingest host it is the other way round: an unregistered path under/v1/answers405, because the CORS preflight pattern claims the whole prefix.