Error codes and what to do about them
Every code that can come back, what it means, and whether to retry, fix something, or wait.
A failure does not have one shape on Segmentic. The ingest host has one shape, the management host has another, and a third leaks through on eleven management routes. A client written against a single shape reads undefined on the other two, and the branch it takes next is the branch nobody tested.
This page lists every code a customer-facing surface can return, grouped by what you are supposed to do about it.
Three envelopes, not one
Parse defensively. If error is an object, read error.code. If error is a string, there is no code and the HTTP status is all you have. If there is no error key at all, you are on the ingest host and the field you want is status.
The ingest host
https://in.segmentic.net answers a flat object. There is no code field anywhere on this host. The machine-readable value is status, and it is only ever the literal "ok" or "error".
{"status":"error","message":"malformed JSON"}
A success carries counts. accepted and rejected are omitted when they are zero rather than sent as 0, so a batch refused whole has no accepted key at all.
{
"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"
}
]
}
A batch reports per-item failures by index into the array you sent, so your retry logic can find the item without matching on its content. The events you sent may have no ids yet, which is half of why they were rejected.
{
"status": "ok",
"accepted": 2,
"rejected": 1,
"errors": [{"index": 1, "reason": "unknown_type: \"trak\""}]
}
The reason string is the full wrapped error text and it includes your own offending value. The stable part is the leading sentinel word, unknown_type here. Match on the prefix, not on the whole string.
Content type is application/json; charset=utf-8 on every response from this host.
Three ingest routes do not use this envelope at all. POST /v1/bounce/{local} answers plain text, not JSON. POST /v1/inbox and POST /v1/devices answer their own shapes, documented on devices and push and on-site and inbox. GET /s/{code} answers a plain-text 404 for an unknown code and a 302 otherwise.
The management host
https://api.segmentic.net nests everything under error.
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
details carries the structured part of a validation failure, so you can point at the offending part of your own payload.
{
"error": {
"code": "batch_too_large",
"message": "a batch may carry at most 500 events",
"details": {"limit": 500, "sent": 501}
}
}
need appears on a 403 and names the exact permission the key is missing. It is published deliberately: the alternative is opening a support ticket to find out which permission to grant.
{
"error": {
"code": "forbidden",
"message": "this key does not carry campaign.send, see GET /v1/whoami for what it does carry",
"need": "campaign.send"
}
}
On POST /v1/events, details is the per-item array instead of an object.
{
"error": {
"code": "all_events_rejected",
"message": "no event in this batch could be accepted",
"details": [
{"index": 0, "reason": "missing_identity"},
{"index": 1, "reason": "missing_identity"}
]
}
}
The flat shape, on eleven routes
Eleven management routes share their handler with the panel, and the panel's error writer emits a flat string with no code at all.
{"error":"segment not found"}
A client that only reads body.error.code gets undefined on every one of these. The routes, and the failures that take this shape:
| Route | Failures in the flat shape |
|---|---|
GET /v1/schema/events | 503 schema unavailable |
GET /v1/schema/traits | 503 schema unavailable |
POST /v1/audiences/validate | 400 malformed JSON |
POST /v1/audiences/count | 400 malformed JSON, 400 the compiler's own text, 503 count unavailable |
GET /v1/segments | 503 segments unavailable |
GET /v1/segments/{id} | 400 invalid segment id, 404 segment not found |
GET /v1/campaigns | 503 campaigns unavailable |
GET /v1/campaigns/{id} | 400 invalid campaign id, 404 campaign not found |
POST /v1/reports/funnel | 400 invalid_report, 503 Persian prose |
POST /v1/reports/retention | 400 invalid_report, 503 Persian prose |
POST /v1/messages | 400, 409, 429 and 503, all of them |
invalid_report is the one exception inside the exception: it is flat but it does carry a code.
{"error":"قیف به دستکم دو مرحله نیاز دارد","code":"invalid_report"}
Four refusals from the shared credential guard also reach the management host in the flat shape, with a code and Persian text: wrong_surface (401), impersonation_read_only (403), impersonation_forbidden (403) and ip_not_allowed (403). The last is reachable by an ordinary API key, because the account's network allow-list applies to every credential and not only to browser sessions.
Neither the ingest host nor the management host honours Accept-Language. The language middleware is applied to the panel's own mux only. Every message that comes from the translation catalogue reaches you in Persian regardless of what you ask for. That covers all four quota_* messages and account_locked.
Branch on the code, never on the message
The code is the contract. The message is not, and it changes whenever the wording improves.
Two messages make this concrete. The write_key_rejected message carries the single ellipsis character U+2026 twice, inside (wk_…) and (sk_seg_…), not three full stops. The transactional key-format message carries an en dash U+2013 between the 8 and the 200, not a hyphen. An integration matching on either string breaks on a character its author never typed.
Fix the request
Retrying any of these sends the identical payload to the identical refusal. The nested envelope, on the management host.
| Code | HTTP | Endpoints | What it means |
|---|---|---|---|
malformed_json | 400 | any route with a body | The body did not parse, or it was over 8 MiB |
batch_empty | 400 | POST /v1/events | events was absent or empty |
bad_id | 400 | PUT /v1/segments/{id}, DELETE /v1/segments/{id}, POST /v1/campaigns/{id}/send, POST /v1/campaigns/{id}/submit | The path did not carry a positive integer id |
name_required | 400 | POST /v1/segments | name was empty or whitespace |
invalid_channel | 400 | POST /v1/campaigns | The channel is not one this account can author on. details is {"field":"channel"} |
unknown_endpoint | 404 | the catch-all | The path is not registered on this host. The message names the method and path and points at GET /v1/capabilities |
not_found | 404 | PUT /v1/segments/{id}, POST /v1/campaigns/{id}/send, POST /v1/campaigns/{id}/submit | The id is unknown, or it belongs to another account. The two cases are deliberately not distinguished |
batch_too_large | 413 | POST /v1/events | More than 500 events. details is {"limit":500,"sent":N}. Split the batch |
filter_invalid | 422 | POST /v1/audiences/validate, POST /v1/segments, PUT /v1/segments/{id} | The audience definition did not compile. The message is the compiler's own sentence |
campaign_invalid | 422 | POST /v1/campaigns, POST /v1/campaigns/{id}/submit | The campaign did not validate |
export_kind_invalid | 422 | POST /v1/exports | kind was not one of events, messages, profiles, segment |
all_events_rejected | 422 | POST /v1/events | Every event in the batch failed normalisation. details is the per-item array |
Three more are 409, and the fix is a human action rather than a payload edit.
| Code | HTTP | Endpoint | What it means |
|---|---|---|---|
approval_required | 409 | POST /v1/campaigns/{id}/send | This account requires a campaign to be approved before it sends. Submit it, then get approval |
approval_stale | 409 | POST /v1/campaigns/{id}/send | The campaign changed after it was approved. Submit it again |
not_submittable | 409 | POST /v1/campaigns/{id}/submit | Only a draft or a paused campaign can be submitted |
POST /v1/audiences/validate answers 422 for an invalid filter, where the panel's own endpoint answers 200 with valid: false. The panel is right for a form somebody is typing into and wrong for an integration whose error handling branches on status.
Fix the credential
Four of these are 401 and they are not interchangeable. Read the code before you go looking for a typo.
| Code | HTTP | What it means |
|---|---|---|
unauthenticated | 401 | No credential on the request, or one that did not resolve. Message: a valid API key is required |
api_key_required | 401 | A panel session was offered. This host takes sk_seg_ keys only |
write_key_rejected | 401 | The token starts wk_. That is the public key that ships in your app, and it can read nothing. Mint a management key |
key_expired | 401 | The key resolved and it has passed its expiry. Rotate it |
wrong_surface | 401 | A staff credential on a customer host, or the reverse. Flat shape, Persian text |
forbidden | 403 | The key's role intersected with its scopes does not include the permission. need names it. GET /v1/whoami returns the effective set |
ip_not_allowed | 403 | The account has a network allow-list and this address is not on it. Flat shape, Persian text |
impersonation_read_only | 403 | A support session, read-only, attempted a mutation. Flat shape |
impersonation_forbidden | 403 | A support session attempted an action support may never take in your account. Flat shape |
GET /v1/whoami costs one budget unit, the same as every other trivial route, and answers with the effective permission set, so a well-written client can fail at start-up rather than on the one call a month that needs the permission it lacks. GET /v1/status is the only route on the management host that is not metered.
curl -s https://api.segmentic.net/v1/whoami \
-H "Authorization: Bearer sk_seg_..."
{
"tenant_id": 42,
"api_key_id": 7,
"role": "analyst",
"permissions": [
"analytics.read", "audit.read", "campaign.read", "data.export",
"event.read", "journey.read", "member.read", "profile.read",
"segment.read", "settings.read", "template.read"
],
"scoped": false
}
permissions is the effective set and it is sorted alphabetically. The role is one of seven: owner, admin, marketer, analyst, viewer, approver, finance. A key with the owner role cannot be created at all, so no key ever carries tenant.transfer or tenant.delete.
scoped is false on every key the product can mint. The scopes column exists and is read, but no route writes it, so a key always carries the whole of its role. A key narrower than its role cannot be created yet.
On the ingest host, missing write key and invalid write key are both 401 and both permanent. The second answers identically for an unknown key, a revoked key and a suspended key, so the endpoint cannot be used to find out which keys exist.
Wait
| Code | HTTP | Where | What to do |
|---|---|---|---|
budget_exhausted | 429 | every management route except GET /v1/status | Retry-After: 60. The minute bucket turns on the wall clock. See limits |
no code, flat error | 429 | POST /v1/messages | Retry-After: 60. The message reads rate limit exceeded: N requests per minute |
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json; charset=utf-8
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
The transactional 429 is the other shape:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 0
Content-Type: application/json; charset=utf-8
{"error":"rate limit exceeded: 2 requests per minute"}
The ingest host never answers 429. It has no rate limiter at all.
Retry
Every one of these is a 503 and every one of them means the fault is ours. Back off and send the same payload again.
| Code | HTTP | Endpoint | Cause |
|---|---|---|---|
budget_unavailable | 503 | every management route except GET /v1/status | Redis was unreachable and the budget check fails closed |
ingest_unavailable | 503 | POST /v1/events | The queue was unavailable. 503 rather than 500 deliberately: a caller told 500 assumes their payload was the problem and stops |
segment_unavailable | 503 | POST /v1/segments, PUT /v1/segments/{id}, DELETE /v1/segments/{id} | The segment store failed |
campaign_unavailable | 503 | POST /v1/campaigns, POST /v1/campaigns/{id}/send | The campaign store failed |
approval_unavailable | 503 | send and submit | The approval store failed. It fails closed, so the send did not happen |
export_unavailable | 503 | GET /v1/exports, POST /v1/exports | The export store failed |
no code, flat error | 503 | POST /v1/messages | message not sent. Only when no message id came back. See transactional |
no code, flat error | 503 | schema, count, segments and campaigns reads, and the two report routes | The warehouse or the store was unavailable. On the reports the message is a Persian sentence |
Only one 503 in the whole product carries a Retry-After, and it is on the ingest host: the write-key lookup failure sets Retry-After: 5. Every other 503 sets none. Choose your own backoff.
Pay, or open a ticket
Retrying these never clears them. Somebody has to pay an invoice, upgrade a plan or grant a permission.
| Code | HTTP | Endpoint | What it means |
|---|---|---|---|
quota_cancelled | 402 | POST /v1/events and every ingest route | The subscription is cancelled |
quota_trial_over | 402 | POST /v1/events and every ingest route | The trial period has ended |
quota_event_cap | 402 | POST /v1/events and every ingest route | The account reached the hard event ceiling on its plan for this Jalali month |
quota_message_cap | 402 | nothing reaches it | The code path exists. No caller passes a message meter to the quota check, so this never fires |
account_locked | 403 | GET /v1/exports, POST /v1/exports, POST /v1/reports/funnel, POST /v1/reports/retention | Usage reached three times the allowance, or an invoice is 75 days past due. details is {"reason":"usage_300"} or {"reason":"overdue_75"} |
The quota_* messages are always Persian, on both hosts, whatever Accept-Language says. The four sentences:
| Code | Message |
|---|---|
quota_cancelled | اشتراک این حساب لغو شده است |
quota_trial_over | دورهٔ آزمایشی به پایان رسیده است |
quota_event_cap | سقف رویدادهای این ماه پر شده است |
quota_message_cap | سقف پیامهای این ماه پر شده است |
account_locked closes exactly four management routes. Ingest, transactional send, campaign creation, campaign send and segment authoring all stay open while an account is locked, because a hole in your data cannot be filled in afterwards and a debt can be collected afterwards.
The quota verdict knows how far over the account is. That number does not reach you: the refusal carries the sentence and nothing else. Read usage from the panel.
A 402 causes every Segmentic SDK to discard the batch it was holding, not to buffer it. All three SDKs treat any 4xx except 429 as permanent. When you hit a hard event ceiling, the events already queued on your users' devices are lost, and they cannot be recovered afterwards. Watch the usage warnings, not the refusal.
The ingest host has statuses, not codes
There is no code field on https://in.segmentic.net. Branch on the HTTP status.
| HTTP | Message | Cause | Do |
|---|---|---|---|
| 400 | malformed JSON | The body did not parse | Fix |
| 400 | a sentinel from the table below | One event failed validation | Fix |
| 400 | batch_empty | Zero items in batch | Fix |
| 400 | batch_too_large: N items, limit 500 | Over 500 items | Fix, split it |
| 401 | missing write key | No Authorization: Bearer, no X-Segmentic-Key, no ?write_key= | Fix. Never retry |
| 401 | invalid write key | Unknown, revoked or suspended | Fix. Never retry |
| 402 | Persian quota text | A hard ceiling, a cancelled subscription or an ended trial | Do not retry |
| 413 | request body too large | Over 5 MiB | Fix, send less |
| 503 | cannot verify the write key right now; retry | Our key lookup failed. Sets Retry-After: 5 | Retry, keep the events |
| 503 | temporarily unavailable, please retry | The bus and the disk buffer both failed | Retry |
Note that the same condition gets different statuses on the two hosts: a batch over 500 items is 400 on POST /v1/batch and 413 on POST /v1/events.
The channel and on-site routes use the same envelope with their own messages.
| Endpoint | HTTP | Message |
|---|---|---|
POST /v1/devices | 400 | malformed JSON, or the normaliser's own text with warnings alongside |
POST /v1/devices | 503 | temporarily unavailable, please retry |
POST /v1/devices/unregister | 400 | device_id is required |
POST /v1/webpush/subscribe | 400 | user_id and a complete subscription are required |
POST /v1/webpush/unsubscribe | 400 | endpoint is required |
POST /v1/messenger/link | 400 | user_id, chat_id and a known platform are required |
POST /v1/messenger/unlink | 400 | user_id and a known platform are required |
POST /v1/inbox | 400 | user_id is required |
POST /v1/inbox | 403 | user identity is not verified |
POST /v1/onsite/event | 400 | campaign_id and a visitor id are required, or unknown action |
POST /v1/onsite/response | 400 | unknown campaign, or the validator's own text |
POST /v1/hooks/{source}/{token} | 401 | signature mismatch, or unauthorized |
POST /v1/hooks/{source}/{token} | 404 | unknown webhook |
POST /v1/bounce/{local} | 400 or 413 | plain text, not JSON |
Two routes never report a failure at all, deliberately. GET /v1/onsite answers 200 {"campaigns":[]} when its store is down, because it runs inside your page load and a slow or failing campaign fetch must not be visible to your visitor. POST /v1/onsite/event is always 200, even when the impression write failed. POST /v1/hooks/... answers 200 {"status":"ok","accepted":0} for a payload it cannot transform, because Shopify and WooCommerce retry a non-2xx for days.
Why one event was refused
These ten strings are the stable part of errors[].reason on the ingest host and of details[].reason on POST /v1/events. The set is closed. Anything outside it is counted as invalid.
| Sentinel | Meaning |
|---|---|
unknown_type | type was not one of track, identify, page, screen, alias. Wrapped with your value |
missing_identity | Neither user_id nor anonymous_id was present |
missing_event_name | type: "track" with no event |
event_name_too_long | The event name is over 128 bytes |
event_name_invalid_chars | The event name contains a Unicode control character |
id_too_long | user_id, anonymous_id or message_id is over 256 bytes |
missing_previous_id | type: "alias" with no previous_id |
batch_too_large | More than 500 items. Wrapped: batch_too_large: N items, limit 500 |
batch_empty | Zero items |
timestamp_too_old | Backfill only. Live ingest clamps the timestamp and warns instead |
Warnings, which arrive on a 200
A warning means the event was accepted and something was corrected. It is not an error and it does not change the status. Every warning is {"code","field","note"}, and field and note are omitted when empty.
| Code | Field | What happened |
|---|---|---|
generated_message_id | message_id | You sent none, so one was generated. Retries of this event cannot be de-duplicated |
timestamp_in_future | timestamp | The device clock is ahead of the server. Clamped to receive time |
timestamp_too_old | timestamp | Older than the ingest window. Clamped to its edge |
too_many_properties | properties | Over 256 properties. The first 256 were kept |
too_many_traits | traits | Over 256 traits. The first 256 were kept |
unserialisable_property | the offending key | The value could not be stored and was dropped |
invalid_phone | phone | Not a valid Iranian mobile number. Stored as given |
invalid_national_id | national_id | Failed the check digit. Not stored at all |
A batch response stops collecting warnings once it holds fifty, though all of them are counted against your account's data-quality metrics. Watch generated_message_id: it is the one warning that costs you something, because it means a retried event will be stored twice.
This table is the ingest host. POST /v1/events on the management host computes the same warnings and then discards them, so its response never carries a warnings array and you cannot tell from it that a timestamp was clamped or a message id generated. If you use that door to migrate data, expect no signal about the corrections.
Transactional send errors
POST /v1/messages is the oldest handler on the management host and it answers in the flat shape throughout. There is no code on any of its failures.
Every one of these is a 400 with the sentinel text as the whole error string:
error | Meaning |
|---|---|
transactional: user_id is required | No recipient |
transactional: template_id is required | No template |
transactional: idempotency_key is required | No key, in the body or the Idempotency-Key header |
transactional: idempotency_key must be 8-200 characters of letters, digits, dot, dash, underscore or colon | The key failed ^[A-Za-z0-9._:-]{8,200}$. The wire text has U+2013 between the 8 and the 200 |
transactional: unknown channel | Not a channel this account can send on |
transactional: this endpoint does not send marketing; use a campaign | category was marketing. This endpoint bypasses frequency caps and quiet hours, so accepting marketing here would be a documented way round your own sending rules |
transactional: too many variables | Over 40 entries in vars |
malformed JSON: <detail> | Unparseable, or an unknown field. Unknown fields are refused rather than ignored, so a mistyped idempotencyKey is a 400 and not a fresh key on every retry |
One 409 and one 503:
| HTTP | error | Meaning |
|---|---|---|
| 409 | transactional: a message with this idempotency key is already in flight | Your own earlier attempt is still running. Retry-After: 1 |
| 503 | message not sent | The send failed and no message id came back |
A 200 on this endpoint does not mean delivered. Read reason and reason_fa in the body: they are set when the message was deliberately not sent, for an opt-out, a suppression, or no address on file. And if the send succeeded but recording it failed, the endpoint answers 200 with the result rather than 503, because the message really did go and a caller told otherwise would retry and send a second one.
Retrying
The rule the SDKs implement, and the one to implement yourself.
| Status | Retry | Why |
|---|---|---|
| 400, 402, 403, 404, 409, 413, 422 | No | The same payload gets the same answer for ever. The exception is 409 on POST /v1/messages, which is your own attempt still running |
| 401 | No | The credential will never work. Fix the key |
| 429 | Yes, after Retry-After | The window turns |
| 5xx | Yes, with exponential backoff and jitter | The fault is ours |
All three Segmentic SDKs express this as one line: anything in 400 to 499 except 429 is permanently rejected and dropped; everything else is retried with exponential backoff and full jitter, base 1 second, capped at 5 minutes.
The 401 against 503 distinction is the whole reason the ingest host separates them. 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. This host used to answer 401 when the key lookup itself failed, which is our outage and not your key: with the database scaled to zero, eight events out of eight came back 401 and were destroyed at the customer's end, while their logs told them their write key was invalid. That is now 503 with Retry-After: 5, and the disk buffer behind the collector exists precisely so an infrastructure failure never costs an event.
The consequence for you: treat any 401 from us as a configuration bug, never as a transient. If you see 401 in bulk on a key that worked yesterday, it is a real credential problem, because the outage case no longer looks like this.
Where a retry is protected
| Path | Mechanism | Window |
|---|---|---|
| Every route on the ingest host | message_id de-duplication in Redis | 48 hours, DEDUPE_TTL |
POST /v1/messages | Your own idempotency_key, reserved in the ledger in one round trip | 7 days, API_IDEMPOTENCY_RETENTION |
| Inbound webhooks | A deterministic message id, so a platform's retry after a timeout is the ordinary case | 48 hours |
Send a message_id on every event. Without one, we generate it, you get the generated_message_id warning, and a retry after a network timeout stores the event twice. With one, the retry is free.
That protection does not extend to POST /v1/events. That route publishes straight to the bus without going near Redis, so a message_id there is only the event's identifier and nothing catches a repeat. A retry through that door stores the event twice even when you sent the same message_id. Read before you resend.
A de-duplicated event is reported back as accepted, not as a duplicate. There is no duplicate field on the wire and the counts do not distinguish them. This is deliberate: your retry logic should not have to care, and a duplicate is not metered against your quota.
When Redis is unreachable the de-duplication check is skipped and the possible duplicate is accepted, rather than the event being lost.
A replayed transactional send is marked. Read replayed in the result to tell "we already did this" apart from "we just did this", which matters when your first attempt timed out and you do not know which happened.
No management write other than POST /v1/messages accepts an idempotency key. POST /v1/segments, POST /v1/campaigns, POST /v1/campaigns/{id}/send, POST /v1/exports and POST /v1/events have no such field, so a retried POST /v1/campaigns creates a second campaign. Retry those only after a read that confirms the first attempt did not land.
The trace id to quote in a ticket
Every response from every host carries X-Segmentic-Trace, sixteen hexadecimal characters.
HTTP/1.1 503 Service Unavailable
X-Segmentic-Trace: 4f2a9c81b0d3e756
Content-Type: application/json; charset=utf-8
{"error":{"code":"ingest_unavailable","message":"could not queue these events; retry"}}
It is not in the error body. Log it from the header, and quote it in a support ticket: it is what lets us find the exact request in our logs rather than a class of similar ones. If you already have a trace id of your own, send it on the request in the same header: when it is between 8 and 64 hexadecimal characters we honour it, lower-cased, and echo it back. Anything else is discarded and replaced with ours, because this value ends up in our log lines.
The header name is ours rather than W3C traceparent, because there is no sampling decision and no span hierarchy behind it, and a header that looks like traceparent but is not would mislead the first person who points a tracing tool at it.
What the error body does not carry
Written down because each of these is something a reader looks for and does not find.
- No
request_idfield. The trace id is in the header only. - No
message_fa. The management envelope hascode,message,detailsandneed, and nothing else. Where a message is Persian it is Persian inmessage. - No distinct analytics error codes. Thirteen separate validation failures in the reports engine all collapse into the single code
invalid_report. Nine of them put the catalogue's Persian sentence inerror; the other four, event name too long, too many filters, property key too long and property value too long, fall through the default branch and carry the English text of the error itself, such asanalytics: too many filters. So the language oferroris not fixed on these two routes. Nor can you tell "too many funnel steps" from "time range too wide" without reading the string. - No
usedorlimiton a 402. The quota verdict computes both and discards them before the response is written. - No remaining budget in
GET /v1/whoami. It returns five fields and none of them is a budget. - **No
X-RateLimit-*headers on the management budget.** They exist onPOST /v1/messagesonly, and only when a rate limit is configured, which it is not by default. See limits.
Related: limits and rate limiting, the ingest API, the management API.