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

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".

POST /v1/track, 400
{"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.

POST /v1/track, 200
{
  "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.

POST /v1/batch, 200
{
  "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.

POST /v1/reports/funnel, 429
{"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.

POST /v1/events, 413
{
  "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.

POST /v1/campaigns/41/send, 403
{
  "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.

POST /v1/events, 422
{
  "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.

GET /v1/segments/7, 404
{"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:

RouteFailures in the flat shape
GET /v1/schema/events503 schema unavailable
GET /v1/schema/traits503 schema unavailable
POST /v1/audiences/validate400 malformed JSON
POST /v1/audiences/count400 malformed JSON, 400 the compiler's own text, 503 count unavailable
GET /v1/segments503 segments unavailable
GET /v1/segments/{id}400 invalid segment id, 404 segment not found
GET /v1/campaigns503 campaigns unavailable
GET /v1/campaigns/{id}400 invalid campaign id, 404 campaign not found
POST /v1/reports/funnel400 invalid_report, 503 Persian prose
POST /v1/reports/retention400 invalid_report, 503 Persian prose
POST /v1/messages400, 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.

POST /v1/reports/funnel, 400
{"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.

CodeHTTPEndpointsWhat it means
malformed_json400any route with a bodyThe body did not parse, or it was over 8 MiB
batch_empty400POST /v1/eventsevents was absent or empty
bad_id400PUT /v1/segments/{id}, DELETE /v1/segments/{id}, POST /v1/campaigns/{id}/send, POST /v1/campaigns/{id}/submitThe path did not carry a positive integer id
name_required400POST /v1/segmentsname was empty or whitespace
invalid_channel400POST /v1/campaignsThe channel is not one this account can author on. details is {"field":"channel"}
unknown_endpoint404the catch-allThe path is not registered on this host. The message names the method and path and points at GET /v1/capabilities
not_found404PUT /v1/segments/{id}, POST /v1/campaigns/{id}/send, POST /v1/campaigns/{id}/submitThe id is unknown, or it belongs to another account. The two cases are deliberately not distinguished
batch_too_large413POST /v1/eventsMore than 500 events. details is {"limit":500,"sent":N}. Split the batch
filter_invalid422POST /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_invalid422POST /v1/campaigns, POST /v1/campaigns/{id}/submitThe campaign did not validate
export_kind_invalid422POST /v1/exportskind was not one of events, messages, profiles, segment
all_events_rejected422POST /v1/eventsEvery 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.

CodeHTTPEndpointWhat it means
approval_required409POST /v1/campaigns/{id}/sendThis account requires a campaign to be approved before it sends. Submit it, then get approval
approval_stale409POST /v1/campaigns/{id}/sendThe campaign changed after it was approved. Submit it again
not_submittable409POST /v1/campaigns/{id}/submitOnly 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.

CodeHTTPWhat it means
unauthenticated401No credential on the request, or one that did not resolve. Message: a valid API key is required
api_key_required401A panel session was offered. This host takes sk_seg_ keys only
write_key_rejected401The token starts wk_. That is the public key that ships in your app, and it can read nothing. Mint a management key
key_expired401The key resolved and it has passed its expiry. Rotate it
wrong_surface401A staff credential on a customer host, or the reverse. Flat shape, Persian text
forbidden403The key's role intersected with its scopes does not include the permission. need names it. GET /v1/whoami returns the effective set
ip_not_allowed403The account has a network allow-list and this address is not on it. Flat shape, Persian text
impersonation_read_only403A support session, read-only, attempted a mutation. Flat shape
impersonation_forbidden403A 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.

Shell
curl -s https://api.segmentic.net/v1/whoami \
  -H "Authorization: Bearer sk_seg_..."
JSON
{
  "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

CodeHTTPWhereWhat to do
budget_exhausted429every management route except GET /v1/statusRetry-After: 60. The minute bucket turns on the wall clock. See limits
no code, flat error429POST /v1/messagesRetry-After: 60. The message reads rate limit exceeded: N requests per minute
HTTP
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
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.

CodeHTTPEndpointCause
budget_unavailable503every management route except GET /v1/statusRedis was unreachable and the budget check fails closed
ingest_unavailable503POST /v1/eventsThe queue was unavailable. 503 rather than 500 deliberately: a caller told 500 assumes their payload was the problem and stops
segment_unavailable503POST /v1/segments, PUT /v1/segments/{id}, DELETE /v1/segments/{id}The segment store failed
campaign_unavailable503POST /v1/campaigns, POST /v1/campaigns/{id}/sendThe campaign store failed
approval_unavailable503send and submitThe approval store failed. It fails closed, so the send did not happen
export_unavailable503GET /v1/exports, POST /v1/exportsThe export store failed
no code, flat error503POST /v1/messagesmessage not sent. Only when no message id came back. See transactional
no code, flat error503schema, count, segments and campaigns reads, and the two report routesThe 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.

CodeHTTPEndpointWhat it means
quota_cancelled402POST /v1/events and every ingest routeThe subscription is cancelled
quota_trial_over402POST /v1/events and every ingest routeThe trial period has ended
quota_event_cap402POST /v1/events and every ingest routeThe account reached the hard event ceiling on its plan for this Jalali month
quota_message_cap402nothing reaches itThe code path exists. No caller passes a message meter to the quota check, so this never fires
account_locked403GET /v1/exports, POST /v1/exports, POST /v1/reports/funnel, POST /v1/reports/retentionUsage 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:

CodeMessage
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.

HTTPMessageCauseDo
400malformed JSONThe body did not parseFix
400a sentinel from the table belowOne event failed validationFix
400batch_emptyZero items in batchFix
400batch_too_large: N items, limit 500Over 500 itemsFix, split it
401missing write keyNo Authorization: Bearer, no X-Segmentic-Key, no ?write_key=Fix. Never retry
401invalid write keyUnknown, revoked or suspendedFix. Never retry
402Persian quota textA hard ceiling, a cancelled subscription or an ended trialDo not retry
413request body too largeOver 5 MiBFix, send less
503cannot verify the write key right now; retryOur key lookup failed. Sets Retry-After: 5Retry, keep the events
503temporarily unavailable, please retryThe bus and the disk buffer both failedRetry

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.

EndpointHTTPMessage
POST /v1/devices400malformed JSON, or the normaliser's own text with warnings alongside
POST /v1/devices503temporarily unavailable, please retry
POST /v1/devices/unregister400device_id is required
POST /v1/webpush/subscribe400user_id and a complete subscription are required
POST /v1/webpush/unsubscribe400endpoint is required
POST /v1/messenger/link400user_id, chat_id and a known platform are required
POST /v1/messenger/unlink400user_id and a known platform are required
POST /v1/inbox400user_id is required
POST /v1/inbox403user identity is not verified
POST /v1/onsite/event400campaign_id and a visitor id are required, or unknown action
POST /v1/onsite/response400unknown campaign, or the validator's own text
POST /v1/hooks/{source}/{token}401signature mismatch, or unauthorized
POST /v1/hooks/{source}/{token}404unknown webhook
POST /v1/bounce/{local}400 or 413plain 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.

SentinelMeaning
unknown_typetype was not one of track, identify, page, screen, alias. Wrapped with your value
missing_identityNeither user_id nor anonymous_id was present
missing_event_nametype: "track" with no event
event_name_too_longThe event name is over 128 bytes
event_name_invalid_charsThe event name contains a Unicode control character
id_too_longuser_id, anonymous_id or message_id is over 256 bytes
missing_previous_idtype: "alias" with no previous_id
batch_too_largeMore than 500 items. Wrapped: batch_too_large: N items, limit 500
batch_emptyZero items
timestamp_too_oldBackfill 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.

CodeFieldWhat happened
generated_message_idmessage_idYou sent none, so one was generated. Retries of this event cannot be de-duplicated
timestamp_in_futuretimestampThe device clock is ahead of the server. Clamped to receive time
timestamp_too_oldtimestampOlder than the ingest window. Clamped to its edge
too_many_propertiespropertiesOver 256 properties. The first 256 were kept
too_many_traitstraitsOver 256 traits. The first 256 were kept
unserialisable_propertythe offending keyThe value could not be stored and was dropped
invalid_phonephoneNot a valid Iranian mobile number. Stored as given
invalid_national_idnational_idFailed 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:

errorMeaning
transactional: user_id is requiredNo recipient
transactional: template_id is requiredNo template
transactional: idempotency_key is requiredNo key, in the body or the Idempotency-Key header
transactional: idempotency_key must be 8-200 characters of letters, digits, dot, dash, underscore or colonThe key failed ^[A-Za-z0-9._:-]{8,200}$. The wire text has U+2013 between the 8 and the 200
transactional: unknown channelNot a channel this account can send on
transactional: this endpoint does not send marketing; use a campaigncategory 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 variablesOver 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:

HTTPerrorMeaning
409transactional: a message with this idempotency key is already in flightYour own earlier attempt is still running. Retry-After: 1
503message not sentThe 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.

StatusRetryWhy
400, 402, 403, 404, 409, 413, 422NoThe same payload gets the same answer for ever. The exception is 409 on POST /v1/messages, which is your own attempt still running
401NoThe credential will never work. Fix the key
429Yes, after Retry-AfterThe window turns
5xxYes, with exponential backoff and jitterThe 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

PathMechanismWindow
Every route on the ingest hostmessage_id de-duplication in Redis48 hours, DEDUPE_TTL
POST /v1/messagesYour own idempotency_key, reserved in the ledger in one round trip7 days, API_IDEMPOTENCY_RETENTION
Inbound webhooksA deterministic message id, so a platform's retry after a timeout is the ordinary case48 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
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_id field. The trace id is in the header only.
  • No message_fa. The management envelope has code, message, details and need, and nothing else. Where a message is Persian it is Persian in message.
  • 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 in error; 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 as analytics: too many filters. So the language of error is not fixed on these two routes. Nor can you tell "too many funnel steps" from "time range too wide" without reading the string.
  • No used or limit on 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 on POST /v1/messages only, 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.

PreviousManagement APINextLimits

On this page

  • Three envelopes, not one
  • Branch on the code, never on the message
  • Fix the request
  • Fix the credential
  • Wait
  • Retry
  • Pay, or open a ticket
  • The ingest host has statuses, not codes
  • Why one event was refused
  • Warnings, which arrive on a 200
  • Transactional send errors
  • Retrying
  • Where a retry is protected
  • The trace id to quote in a ticket
  • What the error body does not carry

Segmentic

This page is written from the code