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

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.

CALLERS
BrowserPublic write key
Mobile appPublic write key
Trusted backendSecret API key
SEGMENTICSegmentic APIsEach key stays inside its own boundary
SURFACES
Ingest APIin.segmentic.net
Management APIapi.segmentic.net
Panelapp.segmentic.net
The callers, credentials and trust boundaries of the Segmentic ingest and management APIs

#The two surfaces

IngestManagement
Hosthttps://in.segmentic.nethttps://api.segmentic.net
Keywk_seg_...sk_seg_...
Where the key livesinside your app: a JavaScript bundle, an APK, an IPAon your own server, in an environment variable
Who calls ityour users' devicesyour backend, a script, an agent
What it doeswrites events, devices and addressesreads and changes what is in the account
ReferenceIngest endpointsManagement 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_
Identifiesone app inside one accountone key inside one account, carrying a role
Permissionsnone, and none are possiblewhatever its role grants
Can read a person's datano, except that person's own in-app inbox, which needs a second credentialyes, if the role allows it
Publicyes, deliberatelyno. It is a secret
Expirynone365 days by default, chosen at creation
Revocationper app, in the panelper 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:

JSON
{
  "error": {
    "code": "write_key_rejected",
    "message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
  }
}

The refusal happens before any database lookup, on the prefix alone.

An sk_ key sent to the ingest host is 401 with the ordinary refusal:

JSON
{"status":"error","message":"invalid write key"}

The collector never looks at the prefix. It hashes whatever it was given and looks the hash up in the write-key table, where a management key is simply not present, so it is indistinguishable from a key that was revoked or never existed. That collapsing is deliberate: the endpoint must not become a way to test which keys exist. The cost is that this direction gives you no hint, and if you are staring at invalid write key with a key you are certain is valid, check its prefix first.

#Which job belongs to which host

JobHostRoute
Record what a person didingestPOST /v1/track
Set traits on a profileingestPOST /v1/identify
Record a page or screen viewingestPOST /v1/page, POST /v1/screen
Attach an anonymous history to a signed-in personingestPOST /v1/alias
Send many events at once from a deviceingestPOST /v1/batch
Send events from your own servermanagementPOST /v1/events
Register a device for pushingestPOST /v1/devices
Subscribe a browser to web pushingestPOST /v1/webpush/subscribe
Link a Bale, Eitaa or Rubika chatingestPOST /v1/messenger/link
Read a signed-in user's in-app inboxingestPOST /v1/inbox
Fetch the on-site campaigns for a pageingestGET /v1/onsite
Validate or count an audiencemanagementPOST /v1/audiences/validate, POST /v1/audiences/count
Create, change or delete a segmentmanagementPOST /v1/segments, PUT /v1/segments/{id}, DELETE /v1/segments/{id}
Create a campaign, then send itmanagementPOST /v1/campaigns, POST /v1/campaigns/{id}/send
Run a funnel or retention reportmanagementPOST /v1/reports/funnel, POST /v1/reports/retention
Send one transactional messagemanagementPOST /v1/messages
Queue an exportmanagementPOST /v1/exports
Ask what a key may domanagementGET /v1/whoami
Ask what this deployment servesmanagementGET /v1/capabilities
Check a host is upbothGET /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:

JSON
{"status":"error","message":"request body too large"}

The management host answers an object with a stable code:

JSON
{
  "error": {
    "code": "forbidden",
    "message": "this key does not carry data.export, see GET /v1/whoami for what it does carry",
    "need": "data.export"
  }
}

The code is the contract. The message is not, and an integration that branches on message text will break the first time the wording improves. details appears on some validation failures and carries the offending part of your payload. need appears only on a 403.

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:

JSON
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}

There are no X-RateLimit-* headers on the budget and GET /v1/whoami does not report what is left, so a client cannot see how close it is until it is refused. The only rate headers on the whole surface are X-RateLimit-Limit and X-RateLimit-Remaining on POST /v1/messages, and those describe a second, separate per-account limiter that counts requests rather than weight.

The budget fails closed. If the counter store cannot be reached the answer is 503 with code budget_unavailable, including on GET /v1/whoami and GET /v1/capabilities, because those spend budget too. GET /v1/status is the only route that survives that outage. The direction is deliberate: an unmetered agent in a loop is more expensive than a report that waits.

#Checking a host is up

Both hosts serve GET /v1/status with no credential, no database read and no rate limit.

Shell
curl -i https://in.segmentic.net/v1/status
HTTP
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache, must-revalidate
X-Server-Time: 2026-08-07T09:12:41Z

{"status":"ok","service":"collector","version":"1.42.0"}

service is collector on the ingest host and api on the management host, so a status page can show one dot per component. version is the build stamp, which makes "is the deploy out" answerable from outside. X-Server-Time exists so that a probe measuring round-trip time can tell "we are slow" from "the path between you and us is slow". The body is never cached, because a status page reading a cached ok from a CDN during an outage is worse than no status page.

#Local development

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}/send is on the management host; pause, resume and cancel are not. Once your backend schedules a send, only the panel can stop it.
  • No way to download an export. You can queue one and list it. The file is collected through the panel.
  • No way to approve a campaign, or to read whether it was approved. Submission has a route, the decision does not. An integration learns the answer by retrying the send and reading the 409 code.
  • No message status lookup. There is no GET /v1/messages/{idempotency_key}.
  • No profile read. There is no GET /v1/profiles/{user_id} on either host.
  • No journey, template, consent, governance or audit routes on the public surface.
  • No PATCH anywhere. PUT /v1/segments/{id} replaces the whole object, with no If-Match and no version token, so two concurrent writers silently clobber each other.
  • No idempotency except on POST /v1/messages. A retried timeout on POST /v1/segments creates a second segment.
  • No IP geolocation. country, region and city are filled only from what the SDK sends in context.location.
  • No CORS preflight on the management host. No OPTIONS responder is registered on that mux, so a browser's preflight reaches the catch-all and gets a 404 and the real call is never made. A browser cannot call the management API at all. That is the intended state: an sk_seg_ key does not belong in a page.
  • A wrong method is not a 405 on the management host. PUT /v1/campaigns/5 falls through to the catch-all and answers 404 unknown_endpoint. On the ingest host it is the other way round: an unregistered path under /v1/ answers 405, because the CORS preflight pattern claims the whole prefix.
PreviousReports and exportsNextIngest endpoints

On this page

  • The two surfaces
  • The two keys
  • Getting each key
  • The management key never reaches a client
  • Which job belongs to which host
  • Shared conventions
  • The two error envelopes
  • Pagination
  • Limits and budgets
  • Checking a host is up
  • Local development
  • What is not here

Segmentic

This page is written from the code