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

Every route on the ingest host, with a complete request and response you can copy.

This is every route on the ingest host, the one your users' devices talk to. It takes a public write key, it accepts writes, and the only thing it ever reads back is either public (the on-site campaign list) or protected by a second credential (the in-app inbox). For audiences, campaigns, reports and sending, see the Management API.

#The host

https://in.segmentic.net

Locally the collector listens on http://localhost:8080.

The collector's whole job is to validate, de-duplicate and hand off, fast, and never to tell an SDK to drop data because of a problem at our end. That single sentence explains most of the status codes below: an outage on our side is a 503 the SDK will retry, never a 401 it would treat as permanent.

https://in.segmentic.ir is the old name and is deliberately not redirected here. An SDK still pointed at it fails, on purpose, rather than appearing to work.

#Authenticating with the write key

A write key looks like wk_seg_ followed by forty-three base64url characters. It may be presented in three places, and they are read in this order, first match wins:

  1. Authorization: Bearer wk_seg_...
  2. X-Segmentic-Key: wk_seg_...
  3. ?write_key=wk_seg_... in the query string

The header is the normal form. The query parameter exists because an image beacon and a navigator.sendBeacon call cannot set headers, and the browser SDK uses it for GET /v1/onsite so that the request stays a simple cross-origin GET with no preflight.

Only the exact prefix Bearer (capital B, one space) is stripped from Authorization. Any other scheme falls through to the next place rather than being rejected, so Authorization: Token wk_seg_... is read as "no bearer token here" and then the header is ignored entirely.

The key resolves to an account, an app and the app's environment. The account and the app are stamped on every event from the key, never taken from the body, so a payload naming another account has no effect. The environment is not stamped on anything: it stays on the resolved credential, no event column holds it, and nothing on this host reads it. The lookup is by SHA-256 hash: nothing checks the prefix, which is why a management key sent here is simply an unknown write key.

Resolved keys are cached for one minute (WRITE_KEY_CACHE), and so are failures, because an app shipped with a bad key would otherwise hammer the database forever.

Revocation is not instant, and the panel does not promise otherwise. There is a function that drops a cached entry and nothing in the running product calls it: the panel writes the revocation to Postgres and the collector is a separate process holding its own map. So a key revoked in the panel keeps being accepted until its cache entry expires, up to one minute later. Plan a leaked key around that minute rather than around the button press.

#When authentication fails

SituationStatusBodyHeader
No key in any of the three places401{"status":"error","message":"missing write key"}
Unknown key, revoked key, or a suspended account401{"status":"error","message":"invalid write key"}
The lookup itself failed503{"status":"error","message":"cannot verify the write key right now; retry"}Retry-After: 5

Unknown, revoked and suspended give byte-identical answers so that the endpoint cannot be used to find out which keys exist.

The third row used to answer 401 too, and that was the worst possible answer. An SDK reads 401 as "this key will never work", stops, and throws the buffered events away; it reads 503 as "try again later" and keeps them. It was measured rather than reasoned about: with the database scaled to zero, eight of eight events came back 401. The write-ahead log exists precisely so that an infrastructure failure never costs an event, and that one line defeated it, because the request never reached the log.

#CORS

Every write-key endpoint writes these headers before it does anything else, including before authenticating, so a browser sees the real status code rather than a CORS error on a 401 or a 413:

HTTP
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Segmentic-Key
Access-Control-Max-Age: 86400

OPTIONS on any path under /v1/ answers 204 with those headers and no body.

Access-Control-Allow-Credentials is never set, and that is what makes the wildcard origin safe. The write key is the only credential this host accepts and it is public by design, so there is nothing ambient for a browser to attach and nothing for a wildcard to expose. Do not send cookies here; they will not be honoured.

GET /v1/status and the email endpoints under /e/ do not carry CORS headers. They are not called from a page.

#The request body

JSON, up to five megabytes (5242880 bytes) per request. Over that is 413 with {"status":"error","message":"request body too large"}.

Content-Type is not checked. The handlers read the body and parse it as JSON whatever the request claims, so text/plain with a JSON body works today. Send application/json anyway.

Anything that is not valid JSON is 400 with {"status":"error","message":"malformed JSON"}. Note that this includes a well-formed body carrying a badly formed time: timestamp and sent_at are decoded as RFC 3339 and nothing else, so "timestamp": 1786000000 fails the whole request as malformed JSON rather than as a bad field.

#The event body, field by field

The five single-event endpoints share one body shape. Every field is optional at the parsing stage; what is actually required depends on the endpoint.

FieldTypeRequiredNotes
eventstringon /v1/track onlyat most 128 bytes after normalisation. Persian is fine, spaces are kept, there is no case folding and no snake_case rule
user_idstringone of user_id or anonymous_idat most 256 bytes
anonymous_idstringone of the twoat most 256 bytes, no format requirement, not required to be a UUID
previous_idstringon /v1/alias onlythe id being merged away. No length bound at all, unlike the two above
message_idstringno, but send oneat most 256 bytes. Without it a retry cannot be recognised as a duplicate
timestampRFC 3339nodefaults to the moment we received it
sent_atRFC 3339noenables clock-skew correction
propertiesobjectnoat most 256 keys, key at most 128 bytes, string value at most 8192 bytes
traitsobjectnoat most 256 keys, same value bound
contextobjectnosee below
typestringignored herethe path decides the type. Required on batch items only

type in the body of a single-event request is overwritten by the path, so POST /v1/track can only ever produce a track event no matter what the body says. This is not a validation failure; the field is simply replaced.

The 256-byte bound is enforced in three different ways and the difference bites. user_id and anonymous_id are rejected when they are too long, with id_too_long. context.session_id is truncated at 256 bytes, silently, so a long session id becomes a different session id. previous_id is neither: it is stored whole, and the only thing bounding it is the five-megabyte body cap.

Property and trait keys are normalised: trimmed, control characters dropped, then every run of whitespace and every . and - becomes a single _, with leading and trailing underscores removed. So " spaced key " becomes spaced_key, dotted.key becomes dotted_key and dashed-key becomes dashed_key. A key that normalises to nothing is skipped.

Property values are stored twice where that is meaningful: as text always, and as a number when the value is a number or a boolean. A numeric-looking string is never parsed into a number, because parsing "01234" would throw away the leading zero of a postcode and a national id past 2^53 loses its last digits to a float. A null property is dropped entirely rather than stored as an empty string, so that an is not set filter stays correct.

#The context object

context describes the device, the app and the page. Some of it is stored on the event, some of it is deliberately not, and the difference matters because you cannot filter on something that was never kept.

FieldStored asBound
context.app.versionapp_version64
context.device.typedevice_type32
context.device.modeldevice_model128
context.device.manufacturerdevice_vendor64
context.device.push_providerpush_provider16
context.os.name, context.os.versionos_name (lower-cased), os_version32 each
context.network.carriercarrier64
context.page.url, .path, .referrerpage_url, page_path, page_referrer2048 each
context.page.titlepage_title512
context.campaign.source, .medium, .name, .term, .contentutm_source, utm_medium, utm_campaign, utm_term, utm_content128 each
context.campaign.campaign_id, .journey_idcampaign_id, journey_idnumeric
context.campaign.variant_id, .message_id, .tokenvariant_id, source_message_id, sg_t64, 256, 128
context.locale, .timezone, .session_idlocale, timezone, session_id32, 64, 256
context.location.country, .region, .citycountry, region, city64 each
context.ipip, only when the connection gave us nothing64
context.user_agentnothing. The User-Agent header wins
context.screen.width, .height, .densitynothing. Accepted and dropped
context.location.latitude, .longitudenothing. Accepted and dropped
context.device.id, .name, .push_token, .has_gms, .ad_tracking_enablednothing. Accepted and dropped

context.device.push_token is dropped on purpose. Only the route is kept, never the token: a push token in the event stream would be copied into the warehouse, every export and every backup, for a value the device registry already owns. Register the token with POST /v1/devices instead.

The client cannot set the IP, the user agent, the browser name, the bot flag or the account. Those come from the connection and the key, because a client must not be able to fake its own geo or device. Facts the SDK does send about the device win over what the User-Agent header parses to; the header only fills gaps. Bot traffic is flagged and stored, never dropped, because dropping it silently makes a traffic dip unexplainable; every report filters it out by default.

There is no IP geolocation in the deployed build. country, region and city are filled only from context.location.

#The response shape

Every JSON response on this host is one of these fields:

FieldTypePresent when
status"ok" or "error"always
acceptednumbernon-zero
duplicatesnumbernon-zero
rejectednumbernon-zero
warningsarray of {code, field, note}there are any
errorsarray of {index, reason}batch items failed
messagestringon an error

accepted is omitted when it is zero, so a fully rejected batch answers without an accepted key at all, and an error answers without one too. Read it as "zero if absent".

duplicates is the part of accepted we already held. It does not come out of accepted, because accepted answers the one question an SDK asks, "may I stop sending these", and a client that resent whatever was not accepted would resend a duplicate forever. So accepted: 500, duplicates: 493 means seven events were stored and 493 were recognised as ones we already had. Absent means zero, like the others.

A warning means the event was accepted with a correction applied. The codes you can see:

CodeMeaning
generated_message_idno message_id was sent, so we minted one and retries of this event cannot be de-duplicated
timestamp_in_futurethe device clock was more than an hour ahead; clamped to receive time
timestamp_too_oldolder than the account's ingest window; clamped to the edge of it
too_many_propertiesmore than 256 properties; the note says how many were sent and how many were kept
unserialisable_propertyone property could not be encoded; field names it
too_many_traitsmore than 256 traits
invalid_phonenot a valid Iranian mobile number; the raw value was stored as given
invalid_national_idthe national id failed its check digit; the trait was dropped

Note the asymmetry in the last two, which is deliberate: a bad phone number is kept because it is often a real number in an unexpected format, and a bad national id is dropped because a national id that fails its check digit is not a national id.

#POST /v1/track

Records something a person did. event is required; without it the answer is 400 missing_event_name.

Shell
curl -X POST https://in.segmentic.net/v1/track \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "m-1",
    "event": "order_completed",
    "user_id": "u_123",
    "properties": { "revenue": 2500000, "currency": "IRR", "order_id": "8821", "city": "تهران" }
  }'
JSON
{"status":"ok","accepted":1}

Without a message_id the same call answers:

JSON
{
  "status": "ok",
  "accepted": 1,
  "warnings": [
    {
      "code": "generated_message_id",
      "field": "message_id",
      "note": "no message_id sent; retries of this event cannot be de-duplicated"
    }
  ]
}

Revenue is extracted from the properties, in this order: the first non-zero of revenue, total, value; failing that price multiplied by quantity, where quantity defaults to one. currency defaults to IRR and is upper-cased, so "irt" is stored as IRT. No conversion is ever guessed.

#POST /v1/identify

Sets traits on a profile. event is ignored; the stored event is always named identify.

Shell
curl -X POST https://in.segmentic.net/v1/identify \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "id-8821",
    "user_id": "u_123",
    "traits": {
      "email": "  Ali@Digikala.COM ",
      "phone": "0912 345 6789",
      "first_name": "علی",
      "city": "کرج",
      "gender": "مرد",
      "lifetime_value": 48200000,
      "is_subscriber": true,
      "referral_code": "0912345"
    }
  }'
JSON
{"status":"ok","accepted":1}

What that payload becomes:

TraitStoredWhy
emailali@digikala.comtrimmed and lower-cased. There is no format validation at all
phone+989123456789 plus phone_operator: "mci"anything but E.164 creates a second profile for the same human. The operator is derived from the four-digit prefix, and 0912 is mci (Hamrah-e Aval)
cityکرجPersian normalised, so an Arabic-keyboard كرج matches too
gendermalefolded and mapped. m, male, man, مرد, اقا, پسر all become male; the female set becomes female; anything else becomes other
lifetime_valuetext 48200000 and number 48200000numeric traits are written twice so that both equals and greater than filters work
is_subscribertext true and number 1
referral_codetext 0912345 onlya numeric-looking string is never parsed, so the leading zero survives

The double write is not cosmetic. It was added after a live account with about 115,000 profiles had an empty numeric map for every trait, so an audience of «موجودی کلید ۱۰۰ یا بیشتر» returned nobody and «کمتر از ۱۰» returned all 114,943 including a user holding 428. No error, no warning, an audience that reads like an answer.

national_id is validated with the Iranian check digit and dropped if it fails, with the warning invalid_national_id. A valid one is stored with its Persian and Arabic-Indic digits converted to ASCII and its surrounding spaces trimmed, and otherwise exactly as you sent it. The check strips dashes and spaces before it counts, accepts 8 to 10 digits, and pads a short one to ten only for its own arithmetic, so 12345679 is stored at eight characters, 001-234-5679 keeps its dashes and 001 234 5679 keeps its inner spaces. Send the ten-digit form if your segments and joins expect ten.

#POST /v1/page and POST /v1/screen

The same body. event is optional here: without it the stored event is named page_viewed on /v1/page and screen_viewed on /v1/screen.

Shell
curl -X POST https://in.segmentic.net/v1/page \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "p-4471",
    "anonymous_id": "a_9f21c0",
    "event": "product",
    "properties": { "sku": "DKP-118820" },
    "context": {
      "page": {
        "url": "https://shop.example.ir/p/118820?utm_source=sms",
        "path": "/p/118820",
        "title": "گوشی موبایل",
        "referrer": "https://www.google.com/"
      },
      "session_id": "s_20260807_01",
      "locale": "fa-IR"
    }
  }'
JSON
{"status":"ok","accepted":1}

#POST /v1/alias

Attaches an anonymous history to a signed-in person. previous_id is required; without it the answer is 400 missing_previous_id. The stored event is always named alias.

Shell
curl -X POST https://in.segmentic.net/v1/alias \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"message_id":"al-1","user_id":"u_123","previous_id":"a_9f21c0"}'
JSON
{"status":"ok","accepted":1}

The SDKs send this automatically on the first identify after anonymous browsing, before the identify itself. If you are writing your own client, copy that: without the alias the user's entire pre-login history is orphaned and every funnel that crosses the login boundary reports the wrong number.

#POST /v1/batch

Up to 500 events in one request. Each item carries its own type, and here the field is load-bearing rather than ignored: it must be one of track, identify, alias, page, screen.

Shell
curl -X POST https://in.segmentic.net/v1/batch \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "sent_at": "2026-08-07T09:12:41Z",
    "context": { "locale": "fa-IR", "app": { "version": "5.2.1" } },
    "batch": [
      {"type":"track","message_id":"b1","event":"product_viewed","user_id":"u1"},
      {"type":"track","message_id":"b2","event":"checkout_started","user_id":"u2"},
      {"type":"identify","message_id":"b3","user_id":"u3","traits":{"phone":"09123456789"}}
    ]
  }'
JSON
{"status":"ok","accepted":3}

The top-level context and sent_at are defaults: they are copied into any item that does not carry its own, and an item's own value is never overwritten.

A bad item does not lose the good ones. The response names the index so your retry logic can find the item without matching on content:

JSON
{
  "status": "ok",
  "accepted": 2,
  "rejected": 1,
  "errors": [ { "index": 1, "reason": "missing_identity" } ]
}

Two failures do refuse the whole request, both with 400: an empty batch array (batch_empty) and more than 500 items (batch_too_large: 501 items, limit 500). A quota refusal also takes the whole batch, never a prefix, because a partial accept would leave the SDK unable to tell which items to resend.

A fifty-event batch costs one de-duplication round trip and one publish round trip, not fifty of each. The warnings array in the response is bounded at roughly fifty entries so that a batch of five hundred slightly-wrong events cannot answer with a megabyte of advice; every warning is still counted in the account's own metrics.

The live event debugger in the panel does not see events sent through /v1/batch. Recording happens on the single-event path and the webhook path only. Every mobile SDK batches and so does the web SDK, so if you are watching the debugger and seeing nothing while accepted counts up, this is why.

#message_id and de-duplication

message_id is what makes a retry safe. SDKs on flaky mobile networks resend aggressively, so without it a purchase count silently doubles.

  • The scope is your account. Two accounts may use the same message_id without colliding.
  • The window is 48 hours. It has to comfortably exceed the longest SDK retry: an Android client that buffered events offline for a day and then flushed must still be recognised.
  • A duplicate answers 200 with accepted: 1 and duplicates: 1, exactly like a first delivery, because an SDK that got an error would keep retrying forever. In a batch, duplicates count toward accepted for the same reason and are reported in duplicates beside it.
  • A message id is claimed before the event is published and the claim is given back if the publish fails, so an event that answered 503 is not mistaken for a duplicate when the SDK resends it.
  • A duplicate is not billed. A retrying SDK costs us a cache lookup, not an invoice line you will dispute.
  • If the de-duplication store is unreachable, the event is accepted and published anyway. Accepting a possible duplicate is strictly better than losing the event: a duplicate is repairable downstream and missing data is not.

The mechanism is a set-if-absent with a time to live, not a Bloom filter, so there are no false positives.

#Timestamps and clock skew

timestamp is when the thing happened, on the device. sent_at is when the device sent the request. The second one is what lets us correct the first.

  1. No timestamp means the time we received it. No warning.
  2. A timestamp more than one hour ahead of our clock is clamped to receive time with the warning timestamp_in_future. A time ahead of now can only come from a wrong device clock, and letting it through would put events in periods that reports have already finalised.
  3. A timestamp older than your account's ingest window is clamped to the edge of that window with the warning timestamp_too_old. The default window is 30 days; an account that keeps events for longer gets a longer one. It is per account, not a global constant.
  4. Otherwise, if sent_at is present and differs from our clock by more than a minute, the whole difference is added to timestamp. The corrected value is used only if it still lands inside the window. No warning is raised for a correction.

Worked example: a device clock is two hours slow. It claims the event happened at 08:00 and that it sent at 10:00. We receive at 12:00. The skew is two hours, so the stored time is 10:00, not 08:00.

Live ingest always clamps and never rejects an out-of-window timestamp. That means a historical migration through this host silently stacks everything older than the window on one instant, answers 200, and looks fine until a funnel makes no sense months later. This has happened to a real account migrating two years of history. Do not backfill through /v1/track or /v1/batch.

#Status codes on the event endpoints

SituationStatusBody
Accepted200{"status":"ok","accepted":1}, with warnings if any
Accepted, and it was a duplicate200identical
No write key401{"status":"error","message":"missing write key"}
Bad, revoked or suspended key401{"status":"error","message":"invalid write key"}
Key lookup failed, our outage503{"status":"error","message":"cannot verify the write key right now; retry"}
Body over five megabytes413{"status":"error","message":"request body too large"}
Body is not JSON400{"status":"error","message":"malformed JSON"}
Account over its quota402{"status":"error","message":"<a Persian sentence>"}
Validation failed, single event400{"status":"error","message":"<the full reason>"}
Batch empty or over 500400{"status":"error","message":"batch_empty"} or "batch_too_large: 501 items, limit 500"
Some batch items were bad200{"status":"ok","accepted":N,"rejected":M,"errors":[...]}
Bus and disk buffer both failed503{"status":"error","message":"temporarily unavailable, please retry"}

The rejection reasons are stable codes, because the panel maps them to Persian and customers alert on them: unknown_type, missing_identity, missing_event_name, event_name_too_long, event_name_invalid_chars, id_too_long, missing_previous_id, batch_too_large, batch_empty. Two of them carry your own value in the message, as in unknown_type: "trak", so match on the prefix rather than on equality.

The last row is the only case where an SDK should retry for a data reason. A bus outage alone does not produce it: the collector falls back to a local write-ahead log, so the queue being down is not visible to you at all. Both had to fail. Note that this 503 carries no Retry-After; only the key-lookup 503 does.

The 402 is Persian regardless of your Accept-Language. The collector has no locale negotiation: nothing tags the request with a language, so the sentence falls back to Persian every time. Branch on the status code, not on the text.

#POST /v1/devices

Registers a device so a campaign can push to it. Served only when push is configured.

Shell
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "d_5f2a91c4",
    "user_id": "u_123",
    "platform": "android",
    "tokens": { "fcm": "cZ1x...:APA91b..." },
    "push_enabled": true,
    "has_gms": true,
    "app_version": "5.2.1",
    "manufacturer": "Samsung",
    "model": "SM-A546E",
    "os_name": "android",
    "os_version": "14",
    "locale": "fa-IR",
    "timezone": "Asia/Tehran",
    "sdk_name": "segmentic-android",
    "sdk_version": "1.4.0"
  }'
JSON
{"status":"ok"}
FieldTypeRequiredNotes
device_idstringyesat most 256 bytes
user_idstringone of the two
anonymous_idstringone of the two
platformstringyesandroid, ios, web, windows, macos, linux, plus aliases such as iphone, ipad, osx, darwin, win, browser. server is refused
tokensobjectone usable tokentransport name to token
push_provider and push_tokenstringnothe older single-route form. tokens wins if both are sent
push_enabledbooleannoomitted means enabled, so an old SDK does not mute its own users
has_gmsbooleannoomitted means "did not say", which is not the same as false
app_version, manufacturer, model, os_name, os_version, locale, timezone, sdk_name, sdk_versionstringnoeach at most 256 bytes

Which transport may reach which platform:

PlatformTransports
androidfcm, bazaar, myket, mqtt
iosapns, mqtt
web, windows, macos, linuxwebpush

A token on the wrong transport is kept out and warned about rather than stored, because the failure mode without that check is not an error: it is a campaign that reports 100% sent and delivers nothing.

That table says what registration accepts, not what can be delivered to, and two rows of it currently deliver nothing at all. No mqtt provider is implemented: the transport name is a constant and it sits in the router's preference order, and no code behind it sends anything, so an Android or iOS device holding only an mqtt token registers cleanly and is never reachable. windows, macos and linux have no route in the push router either: the router's per-platform preference table has entries for android, ios and web only, so a desktop registration is stored, counted, and never sent to. Register fcm, apns or webpush on web, and read a desktop or mqtt registration as bookkeeping rather than as reachability.

APNs tokens are repaired on the way in. Older iOS APIs stringify a token as <a1b2 c3d4>, and sending that verbatim is rejected by Apple for every message forever, so the angle brackets and spaces are stripped and the value is lower-cased.

Failures answer 400 with the reason and, unusually, with the warnings attached:

JSON
{
  "status": "error",
  "message": "device: registration carries no usable token",
  "warnings": [
    { "code": "transport_not_supported", "message": "...", "field": "apns" }
  ]
}

Without those an SDK author sending an APNs token from an Android build sees only "no usable token" and has nothing to go on. The warning codes here are empty_token, token_too_long (over 4096), transport_not_supported and fcm_without_gms. The rejection reasons, verbatim, are device: device_id is required, device: platform must be one of android, ios, web, windows, macos, linux, device: user_id or anonymous_id is required and device: registration carries no usable token. A registration with push_enabled: false and no token is accepted, because that is a real state change.

A store failure is 503 with {"status":"error","message":"temporarily unavailable, please retry"}. Unlike an event, a failed registration has no buffer behind it, so the SDK must retry.

#POST /v1/devices/unregister

Shell
curl -X POST https://in.segmentic.net/v1/devices/unregister \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"device_id":"d_5f2a91c4","user_id":"u_123","revoked":false}'
JSON
{"status":"ok"}

device_id is required. Without it the answer is 400 {"status":"error","message":"device_id is required"}, and a body that is not valid JSON gets exactly the same answer rather than malformed JSON.

revoked: true means the app was uninstalled and the install is gone. revoked: false, the default, means a sign-out: the user is detached and the token is kept. Call it on sign-out. On a shared phone, leaving the previous account attached means the next person receives someone else's order updates.

#Web push

Served only when web push is configured. Two routes.

POST /v1/webpush/subscribe accepts what the browser handed you, either nested or flattened:

Shell
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "subscription": {
      "endpoint": "https://fcm.googleapis.com/fcm/send/dK9...",
      "p256dh": "BJ7s...",
      "auth": "k1Qb..."
    }
  }'
JSON
{"status":"ok"}

The flat form works too:

JSON
{"user_id":"u_123","endpoint":"https://...","p256dh":"BJ7s...","auth":"k1Qb..."}

The nested form exists so a page can post what the browser gave it without picking it apart, and more importantly without re-encoding the keys. Base64 that has been decoded and re-encoded by a well-meaning helper is the classic way a subscription silently stops decrypting.

All four of user_id, endpoint, p256dh and auth are required. Missing any of them is 400 {"status":"error","message":"user_id and a complete subscription are required"}, because an endpoint with no keys is unusable: the payload cannot be encrypted.

POST /v1/webpush/unsubscribe needs only endpoint, and no user id is checked:

Shell
curl -X POST https://in.segmentic.net/v1/webpush/unsubscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"endpoint":"https://fcm.googleapis.com/fcm/send/dK9..."}'
JSON
{"status":"ok"}

The endpoint is the subscription's own secret. Holding it is already enough to send to that browser, so demanding more before letting someone stop receiving would be protecting the wrong direction. An empty endpoint is 400 {"status":"error","message":"endpoint is required"}.

A store failure on either route is 503, not a swallowed 200, because the user has already granted a permission the page cannot ask for twice.

A web push subscription on its own does not make anybody reachable. Before it reaches the web push sender, every send on the webpush channel loads that user's device rows and suppresses the message as not_reachable when there are none. The check runs whether or not a device registry is configured, so on an install with no device store every web push is suppressed, and the campaign reports the suppression rather than an error. If you are integrating browser push only, register the same user with POST /v1/devices (platform: "web") as well as subscribing, and confirm on a test send before you build a campaign on it.

#Messengers

Served only when messengers are configured. Links a Bale, Eitaa or Rubika chat to a profile.

Shell
curl -X POST https://in.segmentic.net/v1/messenger/link \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "platform": "bale",
    "chat_id": "44120099",
    "username": "ali_gh",
    "source": "bot_start"
  }'
JSON
{"status":"ok"}

platform must be exactly bale, eitaa or rubika. Anything else, including telegram, is 400 {"status":"error","message":"user_id, chat_id and a known platform are required"}. The column behind it has a database check constraint, so an unrecognised value would otherwise fail deeper down with an error nobody can act on.

user_id, chat_id and a valid platform are required. username and source are optional and are passed through as sent. The value that carries real consent is bot_start, meaning the person started the bot themselves; anything else is worth being able to find later.

POST /v1/messenger/unlink needs user_id and platform only:

Shell
curl -X POST https://in.segmentic.net/v1/messenger/unlink \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"user_id":"u_123","platform":"bale"}'
JSON
{"status":"ok"}

#The in-app inbox

Served only when the inbox is configured. This is the one route on this host that reads a person's own data, and a public write key cannot be what protects it.

Every request carries a second credential, user_hash, which your own backend computes at sign-in:

user_hash = lowercase_hex( HMAC-SHA256( identity_secret, user_id ) )
Shell
printf '%s' "u_123" \
  | openssl dgst -sha256 -hmac "$SEGMENTIC_IDENTITY_SECRET" -r \
  | cut -d' ' -f1

The identity secret never reaches a browser or an app. There is no screen in the panel that issues it and no API route that returns it: it is created by a Segmentic operator, so getting one means asking us. Rotating it invalidates every hash you have already handed out, which signs your whole app out of its inbox until you redeploy.

Shell
curl -X POST https://in.segmentic.net/v1/inbox \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "user_hash": "9f1c0b7d3e5a...",
    "limit": 20
  }'
JSON
{
  "status": "ok",
  "messages": [
    {
      "message_id": "c104.u_123",
      "title": "سفارش شما ارسال شد",
      "body": "بسته شما تحویل پست شد.",
      "image": "https://cdn.example.ir/parcel.png",
      "deeplink": "myapp://orders/8821",
      "surface": "inbox",
      "token": "1.7.k2.ce.mfz1t8.9c4a...",
      "created_at": "2026-08-07T09:00:00Z",
      "expires_at": "2026-08-21T09:00:00Z",
      "seen": false
    }
  ]
}

messages is always an array, never null, so an SDK that iterates without a nil check gets an empty loop rather than a crash.

token is the attribution signature for that message. Send it back in context.campaign.token on the message_opened event you post to /v1/track, so that the open can be proven to belong to a message we really sent.

A POST rather than a GET for two reasons: the proof belongs in a body rather than in a query string that every proxy, browser history and access log along the way keeps a copy of, and fetching has a side effect, since the rows come back marked delivered.

limit is passed to the store unchanged. Zero means the store's own bound applies, and that bound is not published here.

Every identity failure is the same 403:

JSON
{"status":"error","message":"user identity is not verified"}

Wrong hash, missing hash, and an account with no identity secret configured are indistinguishable, because distinguishing them would turn this into an oracle for which user ids exist. There is no unverified mode.

POST /v1/inbox/ack takes the same credential plus two arrays:

Shell
curl -X POST https://in.segmentic.net/v1/inbox/ack \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "user_hash": "9f1c0b7d3e5a...",
    "seen": ["c104.u_123"],
    "dismissed": ["c99.u_123"]
  }'
JSON
{"status":"ok"}

The ack does not record an engagement. An in-app open is reported through the ordinary event path with the token the inbox handed you, so that it is verified by exactly the same signature check as every other channel's. A second, trusted path for the same signal would be a second thing to get wrong, and this one would be trusted on the word of a caller holding nothing but a public write key.

#On-site messages

Served only when on-site is configured. Three routes: what to show, and what happened.

GET /v1/onsite is the one request in the product that runs on the critical rendering path of somebody else's website, and every decision about it follows from that. It carries no user identity, so one response serves every visitor and a CDN can cache it. It returns targeting rules rather than decisions, so the browser matches locally without a round trip.

Shell
curl "https://in.segmentic.net/v1/onsite?write_key=wk_seg_..."
HTTP
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
Content-Type: application/json; charset=utf-8
JSON
{
  "campaigns": [
    {
      "id": 12,
      "name": "بنر تخفیف نوروز",
      "kind": "banner",
      "status": "live",
      "content": { },
      "targeting": { },
      "max_impressions": 3,
      "cooldown_hours": 24,
      "dismissible": true,
      "starts_at": "2026-03-15T00:00:00Z",
      "ends_at": "2026-03-25T00:00:00Z",
      "impressions": 41822,
      "clicks": 1104,
      "dismissals": 380
    }
  ],
  "cache_seconds": 60
}

kind is banner, modal, slidein or survey. content and targeting are collapsed in the sample above; their shapes are on On-site messages. Sixty seconds is long enough that a busy shop's page views mostly do not reach us, and short enough that pausing a campaign takes effect while the person who pressed pause is still watching. Because of that window the browser checks the start and end dates again locally, so a campaign whose end passes inside the cache stops showing without waiting for it.

The targeting rules in this response are public. Anybody can read them in the network tab, which is why the rule vocabulary contains nothing you would mind a competitor seeing. Do not put anything secret in a targeting rule.

A store failure here answers 200 with an empty list, never a 5xx. This runs inside your page load: a failure of ours must degrade to "no banner today", never to a console error on your site.

POST /v1/onsite/event records what happened:

Shell
curl -X POST https://in.segmentic.net/v1/onsite/event \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": 12,
    "anonymous_id": "a_9f21c0",
    "action": "click",
    "page_url": "https://shop.example.ir/p/118820"
  }'
JSON
{"status":"ok"}

campaign_id and one of user_id or anonymous_id are required; without them the answer is 400 {"status":"error","message":"campaign_id and a visitor id are required"}. action is impression (the empty string means the same), click, dismiss or convert, matched case-insensitively; anything else is 400 {"status":"error","message":"unknown action"}.

A storage failure still answers 200. Losing an impression count costs a number on a dashboard; returning an error to a script running inside your page costs you a console error on every page view.

POST /v1/onsite/response records a survey answer, and adds score and answers:

Shell
curl -X POST https://in.segmentic.net/v1/onsite/response \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": 31,
    "user_id": "u_123",
    "score": 9,
    "answers": { "why": "ارسال سریع بود" },
    "page_url": "https://shop.example.ir/thanks"
  }'
JSON
{"status":"ok"}

The campaign is loaded and checked rather than trusted: whether this is an NPS survey decides whether the score means anything, and the browser is not the authority on that. An id that does not resolve is 400 {"status":"error","message":"unknown campaign"}. A campaign that is not a survey is 400 {"status":"error","message":"onsite: this campaign is not a survey"}. When the campaign is configured as NPS the score must be present and between 0 and 10, otherwise onsite: an NPS score must be between 0 and 10; when it is not NPS the score is forced to -1, meaning no score. Omitting the field is refused, not read as zero. It used to be read as zero, which is a valid detractor, so a body with no score at all was stored as the angriest answer on the scale and replaced whatever score that person had already given. A free-text answer longer than 2000 characters is truncated rather than rejected, because somebody who wrote three paragraphs about their delivery has said something worth keeping. Saving a response also records a convert, so somebody who told you what they think is not asked the same question next week.

A save failure here is 503, unlike the other two on-site routes: an answer is not a counter.

#GET /v1/status

Unauthenticated, no database read, 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"}

version is the build stamp of the running collector, so "is the deploy out" is answerable from outside. /readyz and the metrics endpoint are on a separate administrative listener and are not part of this surface.

#Routes that exist only when configured

A feature with nowhere to write is not served at all, rather than accepting data it would silently discard. A 404 tells an SDK author immediately that a feature is not configured; a 200 that quietly dropped the subscription would be found weeks later, by a campaign that reached nobody.

RoutesServed when
/v1/track, /v1/identify, /v1/page, /v1/screen, /v1/alias, /v1/batch, /v1/statusalways
/v1/devices, /v1/devices/unregistera device store is configured
/v1/webpush/subscribe, /v1/webpush/unsubscribeweb push is configured
/v1/messenger/link, /v1/messenger/unlinkmessengers are configured
/v1/inbox, /v1/inbox/ackthe inbox is configured
/v1/onsite, /v1/onsite/event, /v1/onsite/responseon-site is configured

An unregistered path under /v1/ answers 405 Method Not Allowed, not 404. The CORS preflight pattern claims every path under that prefix for OPTIONS, so the router knows the path and not the method. Treat a 405 here as "this feature is not turned on for this deployment", and check with the same eye you would give a 404. Outside /v1/ an unregistered path is a plain 404.

#Other paths on this host

These are on the same host and are not part of the SDK surface. They are documented where they belong.

PathWhat it is
GET /e/othe open pixel in an email. Always answers a transparent GIF, even for a forged token, because a broken image in a marketing email is the most visible defect a recipient can see
GET /e/u, POST /e/uone-click unsubscribe. The GET deliberately does not unsubscribe; see Consent and unsubscribes
GET /e/p, POST /e/pthe recipient's preference centre
POST /v1/hooks/{source}/{token}platform webhooks from Digikala, Basalam, Torob, ZarinPal, WooCommerce, Shopify and Segment; see Webhooks
POST /v1/bounce/{local}the email bounce intake, addressed by its return path
GET /sdk/*the browser SDK bundle, served from this origin so that one entry in your content security policy covers both the script and the requests it makes
GET /s/*a hard 404 here. Short links live on their own short domain, because a shorter domain is fewer characters of every SMS

None of these take a write key. The email endpoints take a signed token instead, which is stronger: a write key is public by design and a signature is not.

#What this host does not do

  • No rate limiting of any kind. Not per second, not per key, not per IP, at the edge or in the application. The only volume control is the monthly quota, which answers 402.
  • No Retry-After on the publish-failure 503. Only the key-lookup 503 carries one. Use your own backoff.
  • No Content-Type enforcement. A JSON body labelled anything is accepted.
  • No locale negotiation. Accept-Language is ignored; the one human-readable message on this host, the quota refusal, is always Persian.
  • No IP geolocation. Location comes only from context.location.
  • No GET, PUT or DELETE variants of the event endpoints. GET /v1/track answers 405.
  • No debug recording on the batch path, so the panel's live event debugger is blind to batched traffic.
  • No server-side cookie and no server-assigned anonymous id. Persisting anonymous_id is entirely the SDK's job, and if you write your own client it is your job.
  • No way to read an event back. Nothing on this host returns what you sent. Query it in the panel or through the Management API.
PreviousAPI referenceNextManagement API

On this page

  • The host
  • Authenticating with the write key
  • When authentication fails
  • CORS
  • The request body
  • The event body, field by field
  • The context object
  • The response shape
  • POST /v1/track
  • POST /v1/identify
  • POST /v1/page and POST /v1/screen
  • POST /v1/alias
  • POST /v1/batch
  • message_id and de-duplication
  • Timestamps and clock skew
  • Status codes on the event endpoints
  • POST /v1/devices
  • POST /v1/devices/unregister
  • Web push
  • Messengers
  • The in-app inbox
  • On-site messages
  • GET /v1/status
  • Routes that exist only when configured
  • Other paths on this host
  • What this host does not do

Segmentic

This page is written from the code