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
| Collector | Management API | |
|---|---|---|
| URL | https://in.segmentic.net/v1/batch | https://api.segmentic.net/v1/events |
| Credential | write key, wk_seg_... | API key, sk_seg_... |
| Permission | none. The write key is the authorisation | profile.write |
| Array key in the body | batch | events |
| Success | 200 | 202 |
De-duplicates on message_id | yes | no |
| Returns warnings | yes | no, they are discarded |
| Backdating limit | the tenant's own events retention | fixed 30 days |
Batch-level context and sent_at | yes | no such fields |
| Maximum items | 500 | 500 |
| Maximum body | 5 MiB | 8 MiB |
| Costs request budget | no | yes, 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:
{
"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.
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": "تهران" }
}
]
}'
{ "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:
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes on batch | track, identify, alias, page, screen |
message_id | string | no, but send one | at most 256 bytes. Generated with a warning when absent |
event | string | required when type is track | at most 128 bytes after normalisation |
user_id | string | one of user_id or anonymous_id | at most 256 bytes |
anonymous_id | string | one of user_id or anonymous_id | at most 256 bytes. No format rule, it need not be a UUID |
previous_id | string | required when type is alias | the id being merged from. Unlike every other id it has no length bound at all, only the body cap |
timestamp | RFC 3339 | no | defaults to the server's receive time |
sent_at | RFC 3339 | no | enables clock skew correction. See below before you set it |
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 bounds |
context | object | no | shape 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:
| Situation | Status | Body |
|---|---|---|
| Accepted, in whole or in part | 200 | {"status":"ok","accepted":N,...} |
| No key on the request | 401 | {"status":"error","message":"missing write key"} |
| Unknown, revoked, or a suspended account | 401 | {"status":"error","message":"invalid write key"} |
| Our key lookup failed | 503 with Retry-After: 5 | {"status":"error","message":"cannot verify the write key right now; retry"} |
| Body over 5 MiB | 413 | {"status":"error","message":"request body too large"} |
| Body is not JSON | 400 | {"status":"error","message":"malformed JSON"} |
batch is empty | 400 | {"status":"error","message":"batch_empty"} |
| More than 500 items | 400 | {"status":"error","message":"batch_too_large: 501 items, limit 500"} |
| Account over its monthly ceiling | 402 | {"status":"error","message":"<Persian sentence>"} |
| The bus and the disk buffer both failed | 503, 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
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" }
}
]
}'
{
"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:
{
"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:
| Situation | Status | error.code |
|---|---|---|
| Accepted, in whole or in part | 202 | none |
| No credential, or one that did not resolve | 401 | unauthenticated |
A wk_ write key was sent | 401 | write_key_rejected |
| The key has passed its expiry date | 401 | key_expired |
The key lacks profile.write | 403 | forbidden |
events is empty | 400 | batch_empty |
| Body is not JSON | 400 | malformed_json |
| More than 500 events | 413 | batch_too_large, with details of {"limit":500,"sent":N} |
| Every event failed validation | 422 | all_events_rejected, with the per-item array as details |
| Account over its monthly ceiling | 402 | quota_cancelled, quota_trial_over, quota_event_cap or quota_message_cap |
| The request budget for this key is spent | 429 with Retry-After: 60 | budget_exhausted |
| The budget backend errored | 503 | budget_unavailable |
| The queue was unavailable | 503 | ingest_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:
{
"status": "ok",
"accepted": 2,
"rejected": 1,
"errors": [ { "index": 1, "reason": "missing_identity" } ]
}
The management API returns the array itself under 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:
| Code | Meaning |
|---|---|
unknown_type | type was absent or is not one of the five |
missing_identity | neither user_id nor anonymous_id was set |
missing_event_name | type is track and event was empty |
event_name_too_long | over 128 bytes after normalisation |
event_name_invalid_chars | the name contains a control character |
id_too_long | user_id, anonymous_id or message_id is over 256 bytes |
missing_previous_id | type 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 code | What happened |
|---|---|
generated_message_id | no message_id was sent; retries of this event cannot be de-duplicated |
timestamp_in_future | more than an hour ahead of the server; clamped to the receive time |
timestamp_too_old | older than the ingest window; clamped to the edge of it |
too_many_properties | over 256 properties; the extras were dropped |
too_many_traits | over 256 traits; the extras were dropped |
unserialisable_property | one property could not be encoded and was dropped. field names it |
invalid_phone | the phone trait is not a valid Iranian mobile number; it is stored exactly as sent and no phone_operator is derived |
invalid_national_id | the 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:
| Event | A good message_id |
|---|---|
| an order was paid | order-8821-completed |
| an order was refunded | order-8821-refunded |
| a shipment status changed | shipment-4471-delivered |
| a nightly profile sync | profile-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.
{
"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
| Status | Retry? | Why |
|---|---|---|
200 / 202 | no | it worked. Read the per-item errors before moving on |
400 | no | the payload is malformed and will be malformed again |
401 | no | the credential is wrong. Retrying makes a log entry, not a fix |
402 | no | the account is over its ceiling. Nothing changes until somebody pays |
403 | no | the key lacks profile.write. Someone must issue a different key |
404 | no | on the management API this means the ingest route is not served here |
413 | no | the batch is too big. Split it; retrying the same body cannot pass |
422 | no | every event failed validation. Read error.details |
429 | yes, after Retry-After: 60 | management API only, the budget for this key is spent |
503 | yes | ours, not yours. See below |
| a transport error with no response | yes | the 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-After | Message | What it means | |
|---|---|---|---|
| Key lookup failed | 5 | cannot verify the write key right now; retry | The request never reached the pipeline. Retrying the identical body is exactly right |
| Publish failed | absent | temporarily unavailable, please retry | Both 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_idand a later one sent withuser_idare on different partitions and have no order relative to each other. This is whataliasandidentifyare 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 answered400 malformed JSON. - No de-duplication on
POST /v1/events, and no idempotency key either.Idempotency-Keyis 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-Afteron the publish-failure503from the collector. Only the key-lookup503carries 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/batchand the tenant's own retention window are the only route for old data. - No IP geolocation in the deployed build.
country,regionandcityare populated only from what you send incontext.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-LimitorX-RateLimit-Remainingon 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/batchdoes 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.
// 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.
#!/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.
<?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.
curl -sS http://localhost:8082/v1/status
{ "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
contextobject. - The event dictionary for the standard names and properties that make the built-in funnels work without configuration.
- Identity for
user_id,anonymous_idandalias. - Errors for every code on both surfaces.
- Limits for every number the platform holds you to.
- The management API for segments, campaigns and reports.