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

Sending events server to server

For what only your backend is certain of: a captured payment, a shipped order, a cancelled subscription.

Two endpoints take events. They are on different hosts, they take different credentials, and only one of them de-duplicates. Choosing the wrong one is not a style question: it decides whether a retried purchase is counted once or twice.

#Two doors, and they are not the same door

CollectorManagement API
URLhttps://in.segmentic.net/v1/batchhttps://api.segmentic.net/v1/events
Credentialwrite key, wk_seg_...API key, sk_seg_...
Permissionnone. The write key is the authorisationprofile.write
Array key in the bodybatchevents
Success200202
De-duplicates on message_idyesno
Returns warningsyesno, they are discarded
Backdating limitthe tenant's own events retentionfixed 30 days
Batch-level context and sent_atyesno such fields
Maximum items500500
Maximum body5 MiB8 MiB
Costs request budgetnoyes, 5 units of 600 per minute

The write key is public by design. It ships inside the customer's own JavaScript and inside their Android app, and the platform is built on the assumption that anyone can read it. A wk_ key in a public bundle is working as designed; an sk_ key in the same place is an incident. Using the write key from your backend is therefore not a downgrade in security: it is the same credential doing the same job, from a machine instead of a phone.

The management key is the opposite. It carries a role, it can create segments and send campaigns, and it must never leave your servers. Presenting one to the collector does nothing useful, and presenting a write key to the management API is refused with its own code so the mistake is obvious:

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

#Which one your backend should use

Use the collector's POST /v1/batch.

The reason is de-duplication. Your order pipeline will retry. A timeout at the load balancer, a redeploy mid-request, a worker that crashes after the POST and before it marks the row as sent: each of these ends with the same batch being posted twice. The collector records message_id in Redis with SET NX and a 48 hour window, so the second delivery is answered 200 and never reaches the warehouse. POST /v1/events does not do this. It hands the envelopes straight to the queue. A retried batch of a hundred orders becomes two hundred orders, and the first sign of it is a revenue figure nobody can reconcile.

The second reason is the backdating window. The collector reads the tenant's own events retention and allows a timestamp that far back. POST /v1/events does not set that option, so the fixed 30 day default applies, and anything older is silently moved to exactly 30 days ago. See Timestamps.

Use POST /v1/events when your integration already holds a management key and adding a second credential to your deployment is the harder problem, or when the caller is an agent that is already talking to api.segmentic.net for segments and reports. Accept that you are then responsible for not sending the same event twice.

Both doors reach the same pipeline and the same warehouse tables. Nothing downstream can tell which one an event came through, except that events sent through POST /v1/events carry app_id of 0 and have no IP or user agent attached.

#The collector: POST /v1/batch

The key goes in Authorization: Bearer. Two other forms are accepted, because a browser beacon cannot set headers: X-Segmentic-Key: wk_seg_... and ?write_key=wk_seg_.... From a server, use the header. Only the exact string Bearer (capital B, one space) is stripped from Authorization; any other scheme falls through to the other two forms and then fails as a missing key.

Every item carries its own type. On the single-event endpoints (/v1/track, /v1/identify and the rest) the path decides the type and a type in the body is ignored, but on /v1/batch there is no path to read it from, so an item with a missing or unrecognised type is rejected. The five accepted values are track, identify, alias, page, screen.

A batch of two events
curl -sS https://in.segmentic.net/v1/batch \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "context": { "locale": "fa-IR" },
    "batch": [
      {
        "type": "track",
        "message_id": "order-8821-completed",
        "event": "order_completed",
        "user_id": "u_44120",
        "timestamp": "2026-08-07T09:12:41Z",
        "properties": {
          "order_id": "8821",
          "revenue": 4800000,
          "currency": "IRR",
          "city": "تهران"
        }
      },
      {
        "type": "identify",
        "message_id": "profile-44120-v7",
        "user_id": "u_44120",
        "traits": { "phone": "09123456789", "city": "تهران" }
      }
    ]
  }'
200 OK
{ "status": "ok", "accepted": 2 }

Batch-level context and sent_at are copied onto any item that did not carry its own, and never over one that did. This exists so a mobile SDK can send the device model once for fifty events, and it is equally useful from a server for a shared context.campaign.

The fields of an item:

FieldTypeRequiredNotes
typestringyes on batchtrack, identify, alias, page, screen
message_idstringno, but send oneat most 256 bytes. Generated with a warning when absent
eventstringrequired when type is trackat most 128 bytes after normalisation
user_idstringone of user_id or anonymous_idat most 256 bytes
anonymous_idstringone of user_id or anonymous_idat most 256 bytes. No format rule, it need not be a UUID
previous_idstringrequired when type is aliasthe id being merged from. Unlike every other id it has no length bound at all, only the body cap
timestampRFC 3339nodefaults to the server's receive time
sent_atRFC 3339noenables clock skew correction. See below before you set it
propertiesobjectnoat most 256 keys, key at most 128 bytes, string value at most 8192 bytes
traitsobjectnoat most 256 keys, same bounds
contextobjectnoshape documented on Events

timestamp and sent_at are decoded by Go's JSON library into a time, which accepts RFC 3339 and nothing else. Epoch seconds, epoch milliseconds and a bare 2026-08-07 all fail to decode, and the whole request is answered 400 malformed JSON rather than the one item being rejected.

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

Every status this endpoint can return:

SituationStatusBody
Accepted, in whole or in part200{"status":"ok","accepted":N,...}
No key on the request401{"status":"error","message":"missing write key"}
Unknown, revoked, or a suspended account401{"status":"error","message":"invalid write key"}
Our key lookup failed503 with Retry-After: 5{"status":"error","message":"cannot verify the write key right now; retry"}
Body over 5 MiB413{"status":"error","message":"request body too large"}
Body is not JSON400{"status":"error","message":"malformed JSON"}
batch is empty400{"status":"error","message":"batch_empty"}
More than 500 items400{"status":"error","message":"batch_too_large: 501 items, limit 500"}
Account over its monthly ceiling402{"status":"error","message":"<Persian sentence>"}
The bus and the disk buffer both failed503, no Retry-After{"status":"error","message":"temporarily unavailable, please retry"}

Unknown, revoked and suspended collapse into one 401 on purpose, so the endpoint cannot be used to find out which keys exist.

The 402 message is always Persian. The collector has no locale middleware, so Accept-Language: en has no effect on it. Branch on the status code, not on the text.

#The management API: POST /v1/events

Two events through the management API
curl -sS https://api.segmentic.net/v1/events \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "type": "track",
        "message_id": "order-8821-completed",
        "event": "order_completed",
        "user_id": "u_44120",
        "timestamp": "2026-08-07T09:12:41Z",
        "properties": { "order_id": "8821", "revenue": 4800000, "currency": "IRR" }
      },
      {
        "type": "track",
        "event": "order_completed",
        "properties": { "order_id": "8822" }
      }
    ]
  }'
202 Accepted
{
  "accepted": 1,
  "rejected": [ { "index": 1, "reason": "missing_identity" } ]
}

202 and not 200, because the events are queued rather than stored. They become queryable seconds later. A 200 would invite you to read them back immediately and conclude they were lost.

The item shape is the same envelope the collector takes. What differs is the wrapper: the key is events, and there are no batch-level context or sent_at fields. Anything you would have put there has to be repeated on every item.

This route is registered only when the deployment has an importer configured. When it is not, the path falls to the catch-all and answers 404 unknown_endpoint. GET /v1/capabilities reports it as features.ingest.

The permission is profile.write, which the owner, admin and marketer roles carry and analyst does not. A refusal names the permission it wanted, so you do not have to open a support ticket to find out:

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

Every status this endpoint can return:

SituationStatuserror.code
Accepted, in whole or in part202none
No credential, or one that did not resolve401unauthenticated
A wk_ write key was sent401write_key_rejected
The key has passed its expiry date401key_expired
The key lacks profile.write403forbidden
events is empty400batch_empty
Body is not JSON400malformed_json
More than 500 events413batch_too_large, with details of {"limit":500,"sent":N}
Every event failed validation422all_events_rejected, with the per-item array as details
Account over its monthly ceiling402quota_cancelled, quota_trial_over, quota_event_cap or quota_message_cap
The request budget for this key is spent429 with Retry-After: 60budget_exhausted
The budget backend errored503budget_unavailable
The queue was unavailable503ingest_unavailable

error is not always an object on this API. Ten of its routes reuse a dashboard handler and answer {"error":"some string"} instead: the two GET /v1/schema/* reads, POST /v1/audiences/count, the segment list and the segment read, the campaign list and the campaign read, the two POST /v1/reports/* routes, and POST /v1/messages. POST /v1/events itself always uses the object form, but a client that shares one error parser across the whole API must branch on the type of error before reading error.code.

#Batching and the maximum

500 items per request, on both doors. It is a hard bound: 501 items is refused whole, and nothing in the batch is stored. The refusal on the management API publishes the limit in its details, so a client sizing its loop does not have to find the number by bisection.

The body cap is separate and is hit first by wide events rather than by many of them. 5 MiB on the collector, 8 MiB on the management API. 500 events with 256 properties each will exceed 5 MiB long before it exceeds 500 items.

There is no compression. Neither endpoint reads Content-Encoding, so a gzipped body arrives as bytes that are not JSON and is answered 400. If your events are large, send more requests rather than bigger ones.

There is no Content-Type enforcement either. The body is read and parsed as JSON whatever you declare. Send application/json anyway, so that a proxy in between does not decide otherwise.

Below the limit, batch size is a throughput decision and nothing else. A 500 item batch on the collector costs exactly one Redis round trip and one produce round trip, the same as a 2 item batch, which is why batching is worth doing at all.

#Partial failure, and reading the per-item errors

One bad item does not sink a batch. Both endpoints validate every item, keep the good ones, and name the bad ones by their index in the array you sent.

The collector returns counts plus an errors array:

200 OK, one item rejected
{
  "status": "ok",
  "accepted": 2,
  "rejected": 1,
  "errors": [ { "index": 1, "reason": "missing_identity" } ]
}

The management API returns the array itself under rejected:

202 Accepted, one item rejected
{
  "accepted": 2,
  "rejected": [ { "index": 1, "reason": "missing_identity" } ]
}

The same word means two different things. On the collector rejected is a count and the detail is in errors; on the management API rejected is the detail. A parser written for one will read the other as zero failures.

index is the index into the array you sent, not into the accepted subset. That is deliberate: the events being rejected may have no id yet, which is often half the reason they are being rejected, so the index is the only way to find them again.

reason starts with a stable code. The full list:

CodeMeaning
unknown_typetype was absent or is not one of the five
missing_identityneither user_id nor anonymous_id was set
missing_event_nametype is track and event was empty
event_name_too_longover 128 bytes after normalisation
event_name_invalid_charsthe name contains a control character
id_too_longuser_id, anonymous_id or message_id is over 256 bytes
missing_previous_idtype is alias and previous_id was empty

One of these arrives with the offending value appended, unknown_type, because knowing the code without the value sends you back to your own logs. An item with "type": "trak" produces the reason unknown_type: "trak", not unknown_type. The other six always arrive bare, and none of them names the field or the value that was wrong.

Match reason by prefix, or split on the first ": ". An equality test against "unknown_type" will not fire on unknown_type: "trak", which is precisely the case you wrote the alert for.

When every item fails, the two doors part company again. The collector answers 200 with accepted absent from the body altogether, because the field is omitted when zero. The management API answers 422 all_events_rejected and puts the whole array in error.details, on the reasoning that an integration's error handling branches on status and a wholly rejected batch is a caller bug that needs to surface.

Warnings are different from errors: the item was accepted and something about it was changed. Only the collector returns them, capped at roughly 50 per response. POST /v1/events computes them and throws them away, so a missing message_id there is silently generated and you are never told.

Warning codeWhat happened
generated_message_idno message_id was sent; retries of this event cannot be de-duplicated
timestamp_in_futuremore than an hour ahead of the server; clamped to the receive time
timestamp_too_oldolder than the ingest window; clamped to the edge of it
too_many_propertiesover 256 properties; the extras were dropped
too_many_traitsover 256 traits; the extras were dropped
unserialisable_propertyone property could not be encoded and was dropped. field names it
invalid_phonethe phone trait is not a valid Iranian mobile number; it is stored exactly as sent and no phone_operator is derived
invalid_national_idthe national_id trait failed its check digit and was not stored at all

#message_id, and why a server has to set it

message_id is the only thing that makes a retry safe.

The collector keys de-duplication on the pair of your account and the message_id, with SET NX in Redis and a 48 hour window. The first delivery publishes. Every repeat inside that window is answered 200 {"status":"ok","accepted":1}, identical to the first, and nothing new reaches the warehouse. It is also not billed: a retrying client costs a Redis lookup, not an invoice line. Duplicates inside a single batch are caught too, so a batch that accidentally carries the same order twice stores it once.

The response deliberately does not tell you that an event was a duplicate. A client told "duplicate" would treat it as an error and keep retrying, which is the exact loop the window exists to end.

If you send no message_id, one is generated for you and you get a generated_message_id warning. That warning is not decoration. It means this event has no retry protection at all, and the count it feeds will drift upward every time your network has a bad afternoon.

Derive the id from something your own database already guarantees is unique, and make it deterministic, so that the retry computes the same string as the first attempt:

EventA good message_id
an order was paidorder-8821-completed
an order was refundedorder-8821-refunded
a shipment status changedshipment-4471-delivered
a nightly profile syncprofile-44120-2026-08-07

Never use a random UUID generated at send time. A retry generates a different one and the de-duplication has nothing to work with. Never use a timestamp for the same reason.

The rules on the value: it is trimmed, it must be at most 256 bytes, and there is no other constraint. It is scoped to your account, so it cannot collide with another customer's.

POST /v1/events does not de-duplicate at all. It reads message_id, validates its length, stores it on the event, and never checks whether it has seen it before. Send one anyway, so that a duplicate can be found and removed later, but do not expect the platform to stop it.

#Timestamps, backdating, and the clock skew rule

Omit timestamp and the event is stamped with the server's receive time. For an event your backend is emitting as it happens, that is correct and is one less field to get wrong.

Set timestamp when the event happened at a different moment from the one you are sending it: a batch job that drains an outbox table every ten minutes, a payment confirmed by a gateway callback that took an hour to arrive, a migration of last year's orders.

Three rules apply, in this order.

More than one hour in the future is clamped to the receive time, with a timestamp_in_future warning. A timestamp ahead of now can only come from a wrong clock, and letting it through would put events into reporting periods a customer has already read and closed.

Older than the ingest window is clamped to exactly the edge of the window, with a timestamp_too_old warning. It is never refused. On live ingest a phone with a broken clock should not lose its events. The consequence for a backfill is severe and worth stating plainly: send two years of history through a 30 day window and every event lands on the same instant, accepted, with an HTTP success, and the first sign of it is a funnel that makes no sense months later.

The window is not a global constant. On the collector it is the tenant's own events retention: an account that keeps events for ever gets 3650 days, an account that has set 90 days gets 90, and an account on the 30 day floor still gets the full 30 day default. On POST /v1/events the window is not set at all, so the fixed 30 day default always applies, whatever your retention says.

If you are migrating history, use the collector's POST /v1/batch and check your account's events retention first. POST /v1/events will clamp everything older than 30 days without refusing anything.

Then there is sent_at, and it is the field most likely to ruin a backfill.

sent_at exists for a phone whose clock is wrong. The SDK reports when it thinks it sent the batch; the server compares that with when it actually arrived; the difference is applied to the event time. A device two hours slow that claims an event at 08:00 and a send at 10:00, arriving at 12:00, has its event stored at 10:00. The correction only fires when the difference exceeds one minute, and only if the corrected time still lands inside the window. No warning is emitted.

Your server's clock is right. So either omit sent_at, or set it to the moment you actually send. What you must not do is copy your timestamp into sent_at, which is a natural thing to write and is catastrophic: for a three day old event, the difference between "sent" and "arrived" is computed as three days, that difference is added to the event time, and the event lands at now. Your entire backfill collapses onto today.

Backdating done correctly: no sent_at at all
{
  "batch": [
    {
      "type": "track",
      "message_id": "order-7702-completed",
      "event": "order_completed",
      "user_id": "u_39900",
      "timestamp": "2026-05-14T11:02:00Z",
      "properties": { "order_id": "7702", "revenue": 1250000 }
    }
  ]
}

#Which status codes are safe to retry

StatusRetry?Why
200 / 202noit worked. Read the per-item errors before moving on
400nothe payload is malformed and will be malformed again
401nothe credential is wrong. Retrying makes a log entry, not a fix
402nothe account is over its ceiling. Nothing changes until somebody pays
403nothe key lacks profile.write. Someone must issue a different key
404noon the management API this means the ingest route is not served here
413nothe batch is too big. Split it; retrying the same body cannot pass
422noevery event failed validation. Read error.details
429yes, after Retry-After: 60management API only, the budget for this key is spent
503yesours, not yours. See below
a transport error with no responseyesthe request may never have arrived

402 deserves its own note because it looks retryable and is not. The account is past a ceiling it asked for. Retrying achieves nothing until an invoice is paid or the billing period rolls over. It is also not your integration's bug and should not be logged as one.

On neither door does the 402 carry numbers. The amount used and the ceiling are computed on our side and then thrown away, so "how far over am I" can only be asked of the billing screen, never of this response.

The whole batch is refused on 402, never a prefix. A partial accept would leave you unable to tell which items to resend.

For everything retryable, back off exponentially and cap the wait. There is no rate limiting on the collector at all, which means a retry storm will be accepted rather than throttled, which in turn means the only thing protecting you from your own loop is your own loop.

#The 503, and why dropping it costs you the data

503 is the one status that means the failure is on our side.

This was not always so, and the reason it is now is worth reading, because the same mistake is easy to make in a client. The collector used to answer 401 when its own key lookup failed. An SDK reads 401 as "this key will never work", stops, and throws the events away; it reads 503 as "try again later" and keeps them. So a database outage silently destroyed events at the customer's end, while their own logs told them their API key was invalid, which is the wrong thing to go and debug. Measured rather than reasoned about: with the database scaled to zero, eight of eight events came back 401.

Treat 503 the same way. Keep the batch, wait, send it again. Do not log it as a validation failure and do not discard it.

Log the X-Segmentic-Trace header alongside it: sixteen hex characters, set by both doors on every response and repeated in no error body. It is the only thing that lets us trace one specific request. Set the header yourself and, as long as the value is hex of a sane length, that value comes back instead.

There are two 503s on the collector and you can tell them apart from the response alone:

Retry-AfterMessageWhat it means
Key lookup failed5cannot verify the write key right now; retryThe request never reached the pipeline. Retrying the identical body is exactly right
Publish failedabsenttemporarily unavailable, please retryBoth the event bus and the local disk buffer failed

The second one is rare, because a bus outage on its own does not produce it: the collector writes to a local write-ahead log and replays it when the bus returns. It takes a failure of both to get here.

On a publish-failure 503 the message_id has already been recorded in the de-duplication window. A retry carrying the same message_id inside the next 48 hours is answered 200 {"status":"ok","accepted":1} and is not stored: the collector cannot tell that retry apart from a genuine duplicate. To get that event in, retry it with a different message_id, and accept that this one event has no retry protection. This is the only case where reusing the id is wrong.

On the management API, 503 ingest_unavailable means the queue was unavailable. Retry the batch. Because that endpoint does not de-duplicate, a 503 after a partial publish will double-count whatever did get through, which is one more reason to prefer the collector for anything with money in it.

503 budget_unavailable is a different failure with the same status: the budget backend errored and the API fails closed rather than letting an unmetered client loop. Retry with backoff. It affects every route on api.segmentic.net, including GET /v1/whoami.

#Ordering

One guarantee, and it is worth knowing exactly how far it goes.

Every event is placed on the bus with a partition key of its user_id, or its anonymous_id when there is no user_id. All events for one person therefore land on one partition, and the consumers that hold state per person (profile updates, journey state, session stitching) see them in the order the bus accepted them, without any cross-partition coordination.

What follows from that, and what does not:

  • Within one batch, items are produced in array order, so two events for the same person in one batch keep their order.
  • Across two requests, order is the order the requests were accepted. Two concurrent requests from two workers have no order between them.
  • Changing the identity changes the partition. An event sent with anonymous_id and a later one sent with user_id are on different partitions and have no order relative to each other. This is what alias and identify are for; see Identity.
  • Ordering is not preserved through a bus outage. Events buffered into the local write-ahead log are replayed on a timer, roughly every five seconds, so an event accepted during the outage can reach the bus after events accepted afterwards.

If the order of two events matters to a report, do not rely on the order they were sent. Set timestamp on both.

#The monthly ceiling and the request budget

Two different limits, on two different doors, refusing with two different status codes.

The monthly event ceiling applies to both. It is checked once for the whole batch, before any per-item work, and refuses with 402. It fails open: if the check itself errors the batch is accepted, because losing a customer's data when a database blinks is a far larger incident than a runaway bill.

The request budget applies only to api.segmentic.net. It is weighted, not counted, because one call reads a struct and the next scans a warehouse. POST /v1/events costs 5 units. The allowance is 600 units a minute, scoped per API key rather than per account, so a runaway agent cannot exhaust the budget your order pipeline depends on. That is 120 ingest calls a minute per key, or 60000 events a minute at the maximum batch size.

The window is a fixed calendar minute, not a sliding one, and there are no X-RateLimit-* headers on the budget. You cannot ask how much of it you have left, and GET /v1/whoami does not report it. When it is spent you get 429 with Retry-After: 60, which is conservative: the minute may roll sooner.

The collector has no request budget and no rate limiting of any kind. The only volume control on it is the monthly ceiling.

#Your User-Agent, and the bot flag

The collector takes the caller's IP and User-Agent from the connection, never from the body, so that a client cannot fake its own geo or device. From a browser this is the only way to know what the visitor is on. From your backend it means the event is stamped with your data centre and your HTTP client.

That is mostly harmless. browser_name will read python-requests or Go-http-client, which is untidy and tells you nothing you did not know.

One case is not harmless. The user agent parser flags a request as a bot when the User-Agent string contains a URL, and separately when the parsed name contains the substring bot. Every report, every dashboard tile and the segment compiler filter on is_bot = 0. So a well-behaved integration that politely identifies itself the way an HTTP client is supposed to:

User-Agent: myshop-orders/1.0 (+https://myshop.ir)

produces events that are stored, are billed, are visible in the raw user timeline, and are invisible in every report and in every segment. Nothing errors. The events are not there when the marketing team looks.

Send a plain token with no URL in it, and avoid the substring bot in the name:

User-Agent: myshop-orders/1.0

Omitting the header entirely also works: with no User-Agent the parser is never run and none of the device fields are touched. POST /v1/events never reads the header at all, so this whole section does not apply to it.

#What does not exist

Written down because finding out by experiment costs an afternoon.

  • No compression. Neither endpoint reads Content-Encoding, so a gzipped body is answered 400 malformed JSON.
  • No de-duplication on POST /v1/events, and no idempotency key either. Idempotency-Key is honoured on exactly one endpoint on the whole API, POST /v1/messages, and nowhere else.
  • No warnings from POST /v1/events. They are computed and discarded.
  • No way to read an event back, edit one, or delete one. There is no GET /v1/events. Corrections are made by sending a compensating event.
  • No Retry-After on the publish-failure 503 from the collector. Only the key-lookup 503 carries one.
  • No rate limiting on the collector: no per-second cap, no burst control, no per-account concurrency limit.
  • No backfill endpoint reachable from api.segmentic.net. POST /v1/import/events, which refuses an out-of-window timestamp instead of clamping it, is registered only on the internal dashboard API and is not routed publicly. The collector's /v1/batch and the tenant's own retention window are the only route for old data.
  • No IP geolocation in the deployed build. country, region and city are populated only from what you send in context.location.
  • No CORS preflight on api.segmentic.net. A browser cannot call the management API; that is what the write key and the collector are for.
  • No X-RateLimit-Limit or X-RateLimit-Remaining on the request budget.
  • No English on the collector's 402. It is always Persian.
  • No live event debugger for batched traffic. The dashboard's debugger records single-event posts and webhook events; /v1/batch does not feed it, so a server integration that batches will see nothing there.

#A complete Go program

Posts a batch to the collector, retries the failures that are ours, and reads the per-item errors. Standard library only.

main.go
// Sends completed orders to Segmentic from a Go backend.
//
//	export SEGMENTIC_WRITE_KEY=wk_seg_...
//	go run main.go
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

const (
	ingestURL    = "https://in.segmentic.net/v1/batch"
	maxBatchSize = 500
)

type envelope struct {
	Type       string         `json:"type"`
	MessageID  string         `json:"message_id"`
	Event      string         `json:"event,omitempty"`
	UserID     string         `json:"user_id"`
	Timestamp  *time.Time     `json:"timestamp,omitempty"`
	Properties map[string]any `json:"properties,omitempty"`
	Traits     map[string]any `json:"traits,omitempty"`
}

// No sent_at field. It is for correcting a wrong device clock, and setting it
// from a server whose clock is right can only move timestamps that were
// already correct.
type batch struct {
	Batch   []envelope     `json:"batch"`
	Context map[string]any `json:"context,omitempty"`
}

type itemError struct {
	Index  int    `json:"index"`
	Reason string `json:"reason"`
}

type reply struct {
	Status   string      `json:"status"`
	Accepted int         `json:"accepted"`
	Rejected int         `json:"rejected"`
	Errors   []itemError `json:"errors"`
	Message  string      `json:"message"`
}

// errRetryable marks a failure that a second attempt can fix.
var errRetryable = errors.New("segmentic: temporarily unavailable")

// errBurnt marks the one 503 where the message_id has already been consumed:
// the publish failed after de-duplication recorded the ids, so an identical
// retry is answered 200 and stored nowhere.
var errBurnt = errors.New("segmentic: publish failed; message ids are spent")

func post(client *http.Client, key string, events []envelope) (reply, error) {
	if len(events) > maxBatchSize {
		return reply{}, fmt.Errorf("segmentic: %d events, limit %d", len(events), maxBatchSize)
	}

	body, err := json.Marshal(batch{
		Batch:   events,
		Context: map[string]any{"locale": "fa-IR"},
	})
	if err != nil {
		return reply{}, err
	}

	req, err := http.NewRequest(http.MethodPost, ingestURL, bytes.NewReader(body))
	if err != nil {
		return reply{}, err
	}
	req.Header.Set("Authorization", "Bearer "+key)
	req.Header.Set("Content-Type", "application/json")
	// A plain token. A User-Agent containing a URL makes the parser flag the
	// event as a bot, and every report filters bots out.
	req.Header.Set("User-Agent", "myshop-orders/1.0")

	res, err := client.Do(req)
	if err != nil {
		// The request may never have arrived, so the ids are still free.
		return reply{}, fmt.Errorf("%w: %v", errRetryable, err)
	}
	defer res.Body.Close()

	raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
	if err != nil {
		return reply{}, fmt.Errorf("%w: %v", errRetryable, err)
	}

	var out reply
	if err := json.Unmarshal(raw, &out); err != nil {
		return reply{}, fmt.Errorf("segmentic: unreadable reply, status %d: %s", res.StatusCode, raw)
	}

	switch res.StatusCode {
	case http.StatusOK:
		return out, nil
	case http.StatusServiceUnavailable:
		if res.Header.Get("Retry-After") != "" {
			// The key lookup failed. Nothing reached the pipeline.
			return out, fmt.Errorf("%w: %s", errRetryable, out.Message)
		}
		return out, fmt.Errorf("%w: %s", errBurnt, out.Message)
	default:
		// 400, 401, 402 and 413 all say the same thing on a second attempt.
		return out, fmt.Errorf("segmentic: %d %s", res.StatusCode, out.Message)
	}
}

func main() {
	key := os.Getenv("SEGMENTIC_WRITE_KEY")
	if key == "" {
		log.Fatal("SEGMENTIC_WRITE_KEY is not set")
	}

	paidAt := time.Now().UTC().Add(-45 * time.Minute)
	events := []envelope{
		{
			Type: "track",
			// Derived from the order, so a retry computes the same string.
			MessageID: "order-8821-completed",
			Event:     "order_completed",
			UserID:    "u_44120",
			Timestamp: &paidAt,
			Properties: map[string]any{
				"order_id": "8821",
				"revenue":  4800000,
				"currency": "IRR",
				"city":     "تهران",
			},
		},
		{
			Type:      "identify",
			MessageID: "profile-44120-v7",
			UserID:    "u_44120",
			Traits: map[string]any{
				"phone":      "09123456789",
				"first_name": "سارا",
				"city":       "تهران",
			},
		},
	}

	client := &http.Client{Timeout: 15 * time.Second}

	var out reply
	var err error
	for attempt := 1; attempt <= 5; attempt++ {
		out, err = post(client, key, events)
		if err == nil || !errors.Is(err, errRetryable) {
			break
		}
		wait := time.Duration(1<<attempt) * time.Second
		log.Printf("attempt %d failed (%v); waiting %s", attempt, err, wait)
		time.Sleep(wait)
	}
	if err != nil {
		log.Fatalf("segmentic: giving up: %v", err)
	}

	log.Printf("accepted %d, rejected %d", out.Accepted, out.Rejected)
	for _, e := range out.Errors {
		// Split on ": " because unknown_type carries the offending value.
		code, _, _ := strings.Cut(e.Reason, ": ")
		log.Printf("item %d (%s) rejected: %s", e.Index, events[e.Index].MessageID, code)
	}
}

#A complete Python program

Drains an outbox table and posts it to the collector in chunks of 500, with backdated timestamps and no sent_at. Needs requests.

send_orders.py
#!/usr/bin/env python3
"""Send an outbox of paid orders to Segmentic.

    pip install requests
    export SEGMENTIC_WRITE_KEY=wk_seg_...
    python send_orders.py
"""

import os
import sys
import time
from datetime import datetime, timedelta, timezone

import requests

INGEST_URL = "https://in.segmentic.net/v1/batch"
MAX_BATCH = 500
# Everything else means the payload or the credential is wrong, and a second
# attempt sends the same wrong thing.
RETRYABLE = {408, 500, 502, 503, 504}


def rfc3339(moment: datetime) -> str:
    """The only timestamp format the ingest endpoint decodes."""
    return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def chunks(items, size):
    for start in range(0, len(items), size):
        yield items[start:start + size]


def post_batch(session: requests.Session, key: str, events: list) -> dict:
    """POST one batch. Returns the decoded reply or raises."""
    if len(events) > MAX_BATCH:
        raise ValueError(f"{len(events)} events, limit {MAX_BATCH}")

    response = session.post(
        INGEST_URL,
        # No sent_at anywhere: these events are backdated, and sent_at would
        # be read as clock skew and move every one of them to now.
        json={"batch": events, "context": {"locale": "fa-IR"}},
        headers={
            "Authorization": f"Bearer {key}",
            "Content-Type": "application/json",
            # No URL in the token: a User-Agent containing one makes the
            # event count as bot traffic, which every report filters out.
            "User-Agent": "myshop-outbox/1.0",
        },
        timeout=20,
    )

    try:
        body = response.json()
    except ValueError:
        body = {"message": response.text[:500]}

    if response.status_code == 200:
        return body
    if response.status_code in RETRYABLE:
        raise ConnectionError(f"{response.status_code}: {body.get('message')}")
    raise RuntimeError(f"{response.status_code}: {body.get('message')}")


def send_with_retries(session, key, events, attempts=5):
    for attempt in range(1, attempts + 1):
        try:
            return post_batch(session, key, events)
        except (ConnectionError, requests.RequestException) as exc:
            if attempt == attempts:
                raise
            wait = 2 ** attempt
            print(f"attempt {attempt} failed ({exc}); waiting {wait}s", file=sys.stderr)
            time.sleep(wait)


def main() -> int:
    key = os.environ.get("SEGMENTIC_WRITE_KEY")
    if not key:
        print("SEGMENTIC_WRITE_KEY is not set", file=sys.stderr)
        return 1

    # Stand-in for the rows your own outbox query returns.
    now = datetime.now(timezone.utc)
    orders = [
        {"id": 8821, "user": "u_44120", "rial": 4800000, "paid": now - timedelta(hours=3)},
        {"id": 8822, "user": "u_39900", "rial": 1250000, "paid": now - timedelta(hours=2)},
        {"id": 8823, "user": "", "rial": 990000, "paid": now - timedelta(hours=1)},
    ]

    events = [
        {
            "type": "track",
            # Deterministic, so a retry produces the same id and the second
            # delivery is de-duplicated instead of counted again.
            "message_id": f"order-{order['id']}-completed",
            "event": "order_completed",
            "user_id": order["user"],
            "timestamp": rfc3339(order["paid"]),
            "properties": {
                "order_id": str(order["id"]),
                "revenue": order["rial"],
                "currency": "IRR",
            },
        }
        for order in orders
    ]

    session = requests.Session()
    failures = 0

    for part in chunks(events, MAX_BATCH):
        reply = send_with_retries(session, key, part)
        print(f"accepted {reply.get('accepted', 0)}, rejected {reply.get('rejected', 0)}")

        for problem in reply.get("errors", []):
            # Prefix match: unknown_type arrives as 'unknown_type: "trak"'.
            code = problem["reason"].split(": ", 1)[0]
            bad = part[problem["index"]]
            print(f"  {bad['message_id']}: {code}", file=sys.stderr)
            failures += 1

        for note in reply.get("warnings", []):
            print(f"  warning {note['code']}: {note.get('note', '')}", file=sys.stderr)

    return 1 if failures else 0


if __name__ == "__main__":
    sys.exit(main())

#A complete PHP program

The management API path, for a backend that already holds an sk_seg_ key. Needs only ext-curl and ext-json.

send_events.php
<?php
/**
 * Send events to Segmentic's management API from PHP.
 *
 *   SEGMENTIC_API_KEY=sk_seg_... php send_events.php
 *
 * This endpoint does not de-duplicate. If this script can run twice over the
 * same rows, mark them as sent in your own database inside a transaction.
 */

declare(strict_types=1);

const EVENTS_URL = 'https://api.segmentic.net/v1/events';
const MAX_BATCH  = 500;

/**
 * POST one batch. Returns the decoded 202 body.
 *
 * @throws RuntimeException with the HTTP status as its code.
 */
function segmenticSend(string $key, array $events): array
{
    if (count($events) > MAX_BATCH) {
        throw new RuntimeException(count($events) . ' events, limit ' . MAX_BATCH, 413);
    }

    $payload = json_encode(
        ['events' => $events],
        JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
    );

    $curl = curl_init(EVENTS_URL);
    curl_setopt_array($curl, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $key,
            'Content-Type: application/json',
        ],
    ]);

    $raw    = curl_exec($curl);
    $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $error  = curl_error($curl);
    curl_close($curl);

    if ($raw === false) {
        // No response at all. Treated as retryable: the request may never
        // have arrived.
        throw new RuntimeException('transport: ' . $error, 503);
    }

    $body = json_decode($raw, true);
    if (!is_array($body)) {
        throw new RuntimeException('unreadable reply: ' . substr($raw, 0, 300), $status);
    }

    if ($status === 202) {
        return $body;
    }

    // error.code is the contract. error.message is prose and will be reworded.
    // On ten other routes of this API, error is a plain string instead, so
    // read its type before reaching into it.
    $code = is_array($body['error'] ?? null)
        ? ($body['error']['code'] ?? 'unknown')
        : (string) ($body['error'] ?? 'unknown');

    throw new RuntimeException($code, $status);
}

$key = getenv('SEGMENTIC_API_KEY');
if ($key === false || $key === '') {
    fwrite(STDERR, "SEGMENTIC_API_KEY is not set\n");
    exit(1);
}

// No batch-level context or sent_at on this endpoint: those fields exist only
// on the collector, so anything shared has to be repeated per item.
$events = [
    [
        'type'       => 'track',
        'message_id' => 'order-8821-completed',
        'event'      => 'order_completed',
        'user_id'    => 'u_44120',
        'timestamp'  => gmdate('Y-m-d\TH:i:s\Z', time() - 1800),
        'properties' => [
            'order_id' => '8821',
            'revenue'  => 4800000,
            'currency' => 'IRR',
            'city'     => 'تهران',
        ],
        'context'    => ['locale' => 'fa-IR'],
    ],
    [
        'type'       => 'identify',
        'message_id' => 'profile-44120-v7',
        'user_id'    => 'u_44120',
        'traits'     => ['phone' => '09123456789', 'city' => 'تهران'],
        'context'    => ['locale' => 'fa-IR'],
    ],
];

$attempt = 0;
while (true) {
    $attempt++;
    try {
        $reply = segmenticSend($key, $events);
        break;
    } catch (RuntimeException $e) {
        // 429 carries Retry-After: 60. 503 is ours. Everything else is fixed
        // by changing the request, not by repeating it.
        $retryable = in_array($e->getCode(), [429, 503], true);
        if (!$retryable || $attempt >= 5) {
            fwrite(STDERR, 'segmentic refused: ' . $e->getCode() . ' ' . $e->getMessage() . "\n");
            exit(1);
        }
        $wait = $e->getCode() === 429 ? 60 : 2 ** $attempt;
        fwrite(STDERR, "attempt {$attempt}: {$e->getMessage()}; waiting {$wait}s\n");
        sleep($wait);
    }
}

printf("accepted %d\n", $reply['accepted'] ?? 0);

foreach ($reply['rejected'] ?? [] as $item) {
    // Prefix match: unknown_type arrives as 'unknown_type: "trak"'.
    $code = explode(': ', $item['reason'], 2)[0];
    $bad  = $events[$item['index']]['message_id'] ?? '(no message_id)';
    fwrite(STDERR, "rejected {$bad}: {$code}\n");
}

#Local development

The collector listens on http://localhost:8080 and serves /v1/batch there with no change to the payload.

The management API is a different matter. It is served on the address in PUBLIC_API_ADDR, which is empty by default, so on a fresh install the public API is not served at all. Nothing listens, and the first symptom is a connection refused that looks like a networking problem. Set it, restart, and check with GET /v1/status before assuming anything else is wrong.

Is the public API up?
curl -sS http://localhost:8082/v1/status
JSON
{ "status": "ok", "service": "api", "version": "dev" }

GET /v1/status is the only route on the management API that needs no credential and that survives a Redis outage. Everything else, including GET /v1/whoami, goes through the request budget, and the budget fails closed.

#Where to go next

  • Events for the envelope in full, including the whole context object.
  • The event dictionary for the standard names and properties that make the built-in funnels work without configuration.
  • Identity for user_id, anonymous_id and alias.
  • Errors for every code on both surfaces.
  • Limits for every number the platform holds you to.
  • The management API for segments, campaigns and reports.
PreviousDevices and pushNextProduct catalogue

On this page

  • Two doors, and they are not the same door
  • Which one your backend should use
  • The collector: POST /v1/batch
  • The management API: POST /v1/events
  • Batching and the maximum
  • Partial failure, and reading the per-item errors
  • message_id, and why a server has to set it
  • Timestamps, backdating, and the clock skew rule
  • Which status codes are safe to retry
  • The 503, and why dropping it costs you the data
  • Ordering
  • The monthly ceiling and the request budget
  • Your User-Agent, and the bot flag
  • What does not exist
  • A complete Go program
  • A complete Python program
  • A complete PHP program
  • Local development
  • Where to go next

Segmentic

This page is written from the code