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:
Authorization: Bearer wk_seg_...X-Segmentic-Key: wk_seg_...?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
| Situation | Status | Body | Header |
|---|---|---|---|
| No key in any of the three places | 401 | {"status":"error","message":"missing write key"} | |
| Unknown key, revoked key, or a suspended account | 401 | {"status":"error","message":"invalid write key"} | |
| The lookup itself failed | 503 | {"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:
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.
| Field | Type | Required | Notes |
|---|---|---|---|
event | string | on /v1/track only | at most 128 bytes after normalisation. Persian is fine, spaces are kept, there is no case folding and no snake_case rule |
user_id | string | one of user_id or anonymous_id | at most 256 bytes |
anonymous_id | string | one of the two | at most 256 bytes, no format requirement, not required to be a UUID |
previous_id | string | on /v1/alias only | the id being merged away. No length bound at all, unlike the two above |
message_id | string | no, but send one | at most 256 bytes. Without it a retry cannot be recognised as a duplicate |
timestamp | RFC 3339 | no | defaults to the moment we received it |
sent_at | RFC 3339 | no | enables clock-skew correction |
properties | object | no | at most 256 keys, key at most 128 bytes, string value at most 8192 bytes |
traits | object | no | at most 256 keys, same value bound |
context | object | no | see below |
type | string | ignored here | the 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.
| Field | Stored as | Bound |
|---|---|---|
context.app.version | app_version | 64 |
context.device.type | device_type | 32 |
context.device.model | device_model | 128 |
context.device.manufacturer | device_vendor | 64 |
context.device.push_provider | push_provider | 16 |
context.os.name, context.os.version | os_name (lower-cased), os_version | 32 each |
context.network.carrier | carrier | 64 |
context.page.url, .path, .referrer | page_url, page_path, page_referrer | 2048 each |
context.page.title | page_title | 512 |
context.campaign.source, .medium, .name, .term, .content | utm_source, utm_medium, utm_campaign, utm_term, utm_content | 128 each |
context.campaign.campaign_id, .journey_id | campaign_id, journey_id | numeric |
context.campaign.variant_id, .message_id, .token | variant_id, source_message_id, sg_t | 64, 256, 128 |
context.locale, .timezone, .session_id | locale, timezone, session_id | 32, 64, 256 |
context.location.country, .region, .city | country, region, city | 64 each |
context.ip | ip, only when the connection gave us nothing | 64 |
context.user_agent | nothing. The User-Agent header wins | |
context.screen.width, .height, .density | nothing. Accepted and dropped | |
context.location.latitude, .longitude | nothing. Accepted and dropped | |
context.device.id, .name, .push_token, .has_gms, .ad_tracking_enabled | nothing. 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:
| Field | Type | Present when |
|---|---|---|
status | "ok" or "error" | always |
accepted | number | non-zero |
duplicates | number | non-zero |
rejected | number | non-zero |
warnings | array of {code, field, note} | there are any |
errors | array of {index, reason} | batch items failed |
message | string | on 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:
| Code | Meaning |
|---|---|
generated_message_id | no message_id was sent, so we minted one and retries of this event cannot be de-duplicated |
timestamp_in_future | the device clock was more than an hour ahead; clamped to receive time |
timestamp_too_old | older than the account's ingest window; clamped to the edge of it |
too_many_properties | more than 256 properties; the note says how many were sent and how many were kept |
unserialisable_property | one property could not be encoded; field names it |
too_many_traits | more than 256 traits |
invalid_phone | not a valid Iranian mobile number; the raw value was stored as given |
invalid_national_id | the 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.
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": "تهران" }
}'
{"status":"ok","accepted":1}
Without a message_id the same call answers:
{
"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.
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"
}
}'
{"status":"ok","accepted":1}
What that payload becomes:
| Trait | Stored | Why |
|---|---|---|
email | ali@digikala.com | trimmed 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 |
gender | male | folded and mapped. m, male, man, مرد, اقا, پسر all become male; the female set becomes female; anything else becomes other |
lifetime_value | text 48200000 and number 48200000 | numeric traits are written twice so that both equals and greater than filters work |
is_subscriber | text true and number 1 | |
referral_code | text 0912345 only | a 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.
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"
}
}'
{"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.
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"}'
{"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.
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"}}
]
}'
{"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:
{
"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_idwithout 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
200withaccepted: 1andduplicates: 1, exactly like a first delivery, because an SDK that got an error would keep retrying forever. In a batch, duplicates count towardacceptedfor the same reason and are reported induplicatesbeside 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
503is 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.
- No
timestampmeans the time we received it. No warning. - A
timestampmore than one hour ahead of our clock is clamped to receive time with the warningtimestamp_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. - A
timestampolder than your account's ingest window is clamped to the edge of that window with the warningtimestamp_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. - Otherwise, if
sent_atis present and differs from our clock by more than a minute, the whole difference is added totimestamp. 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
| Situation | Status | Body |
|---|---|---|
| Accepted | 200 | {"status":"ok","accepted":1}, with warnings if any |
| Accepted, and it was a duplicate | 200 | identical |
| No write key | 401 | {"status":"error","message":"missing write key"} |
| Bad, revoked or suspended key | 401 | {"status":"error","message":"invalid write key"} |
| Key lookup failed, our outage | 503 | {"status":"error","message":"cannot verify the write key right now; retry"} |
| Body over five megabytes | 413 | {"status":"error","message":"request body too large"} |
| Body is not JSON | 400 | {"status":"error","message":"malformed JSON"} |
| Account over its quota | 402 | {"status":"error","message":"<a Persian sentence>"} |
| Validation failed, single event | 400 | {"status":"error","message":"<the full reason>"} |
| Batch empty or over 500 | 400 | {"status":"error","message":"batch_empty"} or "batch_too_large: 501 items, limit 500" |
| Some batch items were bad | 200 | {"status":"ok","accepted":N,"rejected":M,"errors":[...]} |
| Bus and disk buffer both failed | 503 | {"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.
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"
}'
{"status":"ok"}
| Field | Type | Required | Notes |
|---|---|---|---|
device_id | string | yes | at most 256 bytes |
user_id | string | one of the two | |
anonymous_id | string | one of the two | |
platform | string | yes | android, ios, web, windows, macos, linux, plus aliases such as iphone, ipad, osx, darwin, win, browser. server is refused |
tokens | object | one usable token | transport name to token |
push_provider and push_token | string | no | the older single-route form. tokens wins if both are sent |
push_enabled | boolean | no | omitted means enabled, so an old SDK does not mute its own users |
has_gms | boolean | no | omitted means "did not say", which is not the same as false |
app_version, manufacturer, model, os_name, os_version, locale, timezone, sdk_name, sdk_version | string | no | each at most 256 bytes |
Which transport may reach which platform:
| Platform | Transports |
|---|---|
android | fcm, bazaar, myket, mqtt |
ios | apns, mqtt |
web, windows, macos, linux | webpush |
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:
{
"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
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}'
{"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:
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..."
}
}'
{"status":"ok"}
The flat form works too:
{"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:
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..."}'
{"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.
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"
}'
{"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:
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"}'
{"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 ) )
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.
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
}'
{
"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:
{"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:
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"]
}'
{"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.
curl "https://in.segmentic.net/v1/onsite?write_key=wk_seg_..."
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
Content-Type: application/json; charset=utf-8
{
"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:
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"
}'
{"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:
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"
}'
{"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.
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"}
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.
| Routes | Served when |
|---|---|
/v1/track, /v1/identify, /v1/page, /v1/screen, /v1/alias, /v1/batch, /v1/status | always |
/v1/devices, /v1/devices/unregister | a device store is configured |
/v1/webpush/subscribe, /v1/webpush/unsubscribe | web push is configured |
/v1/messenger/link, /v1/messenger/unlink | messengers are configured |
/v1/inbox, /v1/inbox/ack | the inbox is configured |
/v1/onsite, /v1/onsite/event, /v1/onsite/response | on-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.
| Path | What it is |
|---|---|
GET /e/o | the 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/u | one-click unsubscribe. The GET deliberately does not unsubscribe; see Consent and unsubscribes |
GET /e/p, POST /e/p | the 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-Afteron the publish-failure503. Only the key-lookup503carries one. Use your own backoff. - No
Content-Typeenforcement. A JSON body labelled anything is accepted. - No locale negotiation.
Accept-Languageis 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,PUTorDELETEvariants of the event endpoints.GET /v1/trackanswers405. - 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_idis 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.