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

Limits and rate limiting

Every number the server holds you to, and which of them you can ask the server for at runtime.

Every number on this page comes from the code that enforces it. Where a limit truncates your data instead of refusing it, that is said plainly, because a silent truncation is the failure you find out about months later from a report that makes no sense.

#Read the limits from the server

GET /v1/capabilities publishes five of these numbers at runtime. It costs one budget unit and needs no permission.

Shell
curl -s https://api.segmentic.net/v1/capabilities \
  -H "Authorization: Bearer sk_seg_..."
JSON
{
  "version": "v1",
  "features": {
    "segments": true,
    "campaigns": true,
    "analytics": true,
    "transactional": true,
    "export": true,
    "import": true,
    "journeys": true,
    "ingest": true,
    "async_exports": true,
    "campaign_approval": true
  },
  "limits": {
    "max_page_size": 100,
    "max_preview_rows": 100,
    "max_batch_size": 500,
    "estimate_sample": 100,
    "query_timeout_sec": 30
  }
}

Read max_page_size and max_batch_size from here rather than hardcoding 100 and 500. Every features flag reports what this deployment actually serves, so a self-hosted install that runs without the analytics engine answers "analytics": false and your client can fail at start-up rather than on the call.

Four warnings about this response, all of them things a reader assumes and should not.

  • max_preview_rows and estimate_sample bound nothing on the management host. They belong to two panel endpoints that are not registered here. Ignore them.
  • query_timeout_sec is 30, and it is the timeout for reads, writes and POST /v1/audiences/count. It is not the timeout the two report routes use, which is 45 seconds. See timeouts.
  • ingest and import are the same boolean under two names, the one that registers POST /v1/events.
  • export controls no route on this host. The two export routes are turned on and off by async_exports instead. An account reading "export": false may still have both export routes, and the reverse. For exports, look at async_exports.

Everything else on this page has to be hardcoded, because there is nowhere to read it from. There is no GET /v1/reports/limits and no endpoint that reports your account's event ceiling or its current usage.

#Ingest limits

These apply on both doors: the ingest host and POST /v1/events on the management host.

LimitValueApplies toOn breach
Event name length128 bytesthe event field on a trackRejects, event_name_too_long
Id length256 bytesuser_id, anonymous_id, message_idRejects, id_too_long
Session id length256 bytescontext.session_idTruncates silently
Previous idno limitprevious_id on an aliasChecked for presence, never for length
Property or trait key128 byteseach key, after normalisationTruncates silently
Property or trait value8192 byteseach string valueTruncates silently
Properties per event256propertiesKeeps the first 256, warns too_many_properties
Traits per identify256traitsKeeps the first 256, warns too_many_traits
Events per batch500POST /v1/batch and POST /v1/eventsRejects, batch_too_large
Request body5 MiBthe whole request on the ingest hostRejects, 413
Page URL2048 bytescontext.page.url, .path and .referrerTruncates silently
Webhook body1 MiBPOST /v1/hooks/{source}/{token}Rejects
Bounce report1 MiBPOST /v1/bounce/{local}Rejects, 413

Only one of these has a configuration key. MAX_BODY_BYTES sets the body cap and defaults to 5242880. The rest are compiled in, so a self-hosted install cannot raise them either.

Ordering matters. Rejection happens before truncation, and truncation happens before storage, so an event with a 300 byte user_id is refused whole rather than stored with a shortened id.

#The silent ceilings

Every other field inside context is cut to length with no warning and no error. They are listed here because the alternative is finding out from a segment that does not match the users you expected.

BytesFields
8the currency property, uppercased
16context.device.push_provider
32context.locale, context.device.type, context.os.name, context.os.version, context.library.version, and the browser version derived from the User-Agent
64context.timezone, context.ip, context.app.version, context.device.manufacturer, context.network.carrier, context.library.name, context.location.country, context.location.region, context.location.city, context.campaign.variant_id, and the browser name and device vendor derived from the User-Agent
128context.device.model, context.campaign.token, and all five utm fields: source, medium, name, term and content
256context.session_id, context.campaign.message_id
512context.page.title, and the User-Agent header itself

Country, region, city, page title and every event name also pass through Persian normalisation before they are truncated, so Arabic ye and kaf become Persian ye and kaf. That is what lets a segment on city = تهران match customers whose apps disagree about spelling.

If a value matters to a segment or a report, keep it inside these bounds yourself.

#The timestamp window

An event carries its own timestamp. How far back that may reach is your own account's event retention, not a fixed constant.

SettingValueBehaviour
Default past window30 daysThe floor. An account on 30 day retention still gets the full 30 days
Future window1 hourAnything further ahead is clamped to receive time and warns timestamp_in_future
Retention set to "keep for ever"3650 daysTen years, finite despite the name, so a 1970 timestamp from a broken clock is still refused. It is the window an account that never opened the retention screen gets

On live ingest, a timestamp older than the window is clamped to the edge of the window and the event is accepted with the warning timestamp_too_old. It is not rejected.

This is the one behaviour on the platform most likely to cost you a week. It used to be that 30 days was the whole window, whatever your retention said, and the symptom was invisible: an account migrating two years of history had every event older than thirty days silently moved to exactly thirty-days-ago, accepted with a warning and a 200. Nothing failed. The data was simply wrong, all of it stacked on one timestamp, and the first sign was a funnel that made no sense months later. The window now comes from your retention policy, which is what the clamp always claimed to enforce.

The retention lookup is cached for one minute and fails soft to 30 days when it cannot be read.

On a backfill the same condition rejects with timestamp_too_old instead of clamping, because on a backfill moving a timestamp is worse than refusing it. A backfill row with no timestamp at all is also rejected.

The window that comes from your retention applies on the ingest host only. POST /v1/events on the management host does not read your account's policy and always gets the 30 day default, because it builds its options with no MaxPast. Any event older than thirty days sent through that door is silently moved to thirty-days-ago and answered 200, and the response carries no warning either, because that route discards them. Never migrate history through it. The panel's event import refuses instead of clamping, and refusing is what a migration needs.

#Retention is the ingest window, not the lifetime of the data

These are two different numbers and confusing them is expensive. A retention policy validates up to 3650 days, with a floor of 30 days on any non-zero setting. But the ClickHouse events table carries a fixed 400 day TTL on event_time that ignores the account's policy and drops every row.

So a ten year retention setting can be stored, and no event survives more than 400 days past its own timestamp. The policy only decides which timestamps we accept on the way in, and which rows are deleted sooner than 400 days. Raising it does not make the data live longer.

If you need a history longer than 400 days, export it yourself before the edge arrives. There is nowhere else to get it back from.

#Management host limits

LimitValueApplies to
Request body8 MiBmost routes
Request body256 KiBPOST /v1/messages
Request body1 MiBPOST /v1/audiences/validate, POST /v1/audiences/count, the two report routes
Max page size100every list route
Default page size25every list route
Export kindsevents, messages, profiles, segmentPOST /v1/exports
Export formatndjson by default, csv the only alternativePOST /v1/exports
Export file lifetime7 dayspublished in the 202 body as expires_after_hours: 168

The three different body caps are not a mistake anybody has fixed. POST /v1/messages gets 256 KiB because a transactional payload is a template id and a few variables, and the four routes that borrow a panel handler get that handler's 1 MiB.

#Pagination

limit is clamped, never rejected. limit=500 gives you 100. limit=0 and limit=banana both give you 25. You will not get an error telling you the cap; read it from GET /v1/capabilities.

A cursor is an opaque base64url string. A cursor that does not decode is silently treated as the start of the list, not as an error, so a client that corrupts its cursor loops over page one for ever rather than failing. Compare the ids you receive against the ones you already have.

GET /v1/exports accepts limit but never returns a cursor. It answers {"data":[...],"has_more":false} and has_more is false even when there are more jobs. There is no way to page past the first response. Ask for the maximum, 100, and take that as the whole list.

GET /v1/segments and GET /v1/campaigns answer with their panel handler's own shape, {"segments":[...]} and {"campaigns":[...]}, not with the paged envelope. The data and next_cursor shape appears on GET /v1/exports alone.

Both of those routes are cut to 200 rows in the SQL itself: the 200 most recently updated segments, and the 200 most recently updated campaigns. The cap is silent. There is no has_more, no next_cursor, no count and no warning, and ?limit= and ?cursor= are not read here either. An account holding 250 segments sees two hundred of them and has no route on any surface that reaches the other 50, the panel included. If your library goes past that number, keep your own index elsewhere and fetch by id through GET /v1/segments/{id}.

#Report limits

POST /v1/reports/funnel and POST /v1/reports/retention.

LimitValueWhat it bounds
Time range730 daysThe widest window either report will scan. Two years for a large account is already hundreds of billions of rows
Funnel steps12One condition is built per step and a human has to read the result
Retention periods60The number of columns in a table somebody has to read. Defaults to 30 when you send zero or less
Filters per report10
Property key length128
Event name length256
Property value length512
Path depth8The paths report, which is not on this host
Path rows100The paths report, which is not on this host

Every one of these failures comes back as the same code, invalid_report. There are thirteen distinct validation errors behind that one code and no way to tell them apart programmatically. Nine of them put the catalogue's Persian sentence in error; the bottom four rows of this table, event name length, filter count, property key length and property value length, fall through the default branch and carry the English text of the error itself, such as analytics: too many filters. If you need to, match on the string, and accept that neither the string nor its language is a contract.

The paths report exists but is registered on the panel's mux only. It is not reachable with an API key.

#Audience and segment limits

These bound the definition you send to POST /v1/audiences/validate, POST /v1/audiences/count, POST /v1/segments and PUT /v1/segments/{id}.

LimitValueError text
Nesting depth8segment: nesting too deep
Conditions200segment: too many conditions
Values in one list1000segment: list has too many values
Property key length128

The condition count includes event-property predicates, not only the nodes of the boolean tree, so a definition that looks like twenty conditions on screen can be well over a hundred here. All four surface as filter_invalid with a 422 and the compiler's own sentence as the message.

Validate before you save. POST /v1/audiences/validate compiles the definition without touching a database, costs one budget unit, and answers 422 rather than the panel's 200 with valid: false.

#Transactional send limits

LimitValueConfig key
Variables per message40none
Idempotency key format^[A-Za-z0-9._:-]{8,200}$none
Idempotency key lifetime7 daysAPI_IDEMPOTENCY_RETENTION, default 168h
Stale reservation timeout1 minuteAPI_STALE_RESERVATION
Request body256 KiBnone

The key pattern is strict deliberately. The key becomes part of the message id, which is written into the ledger, the frequency counter and the provider's own reference, so a key containing a newline or a quote would travel a long way before anything rejected it. The message id is derived rather than generated: t<tenant_id>.<your key>, so the same key produces the same id all the way down.

category defaults to transactional when you omit it. marketing is refused outright with a 400. This endpoint bypasses frequency caps and quiet hours, so accepting a marketing message here would hand you a documented way round your own sending rules, and the first time it mattered would be a 3am promotional SMS to a whole list.

#The request budget

Every route on the management host except GET /v1/status is metered in weighted cost units. Requests per minute is the wrong unit for a surface where one call reads a struct and the next scans a warehouse.

ClassWeightMeaning
Trivial1Reads nothing, or reads one row by primary key
Query5One bounded warehouse query
Heavy25A scan whose cost scales with your history

The cost of every route:

RouteCostPermission
GET /v1/whoami1none
GET /v1/capabilities1none
GET /v1/schema/events5event.read
GET /v1/schema/traits5event.read
POST /v1/audiences/validate1segment.read
POST /v1/audiences/count25segment.read
GET /v1/segments1segment.read
GET /v1/segments/{id}1segment.read
POST /v1/segments1segment.write
PUT /v1/segments/{id}1segment.write
DELETE /v1/segments/{id}1segment.delete
GET /v1/campaigns1campaign.read
GET /v1/campaigns/{id}5campaign.read
POST /v1/campaigns1campaign.write
PUT /v1/campaigns/{id}/recurrence1campaign.send
DELETE /v1/campaigns/{id}/recurrence1campaign.send
POST /v1/campaigns/{id}/send1campaign.send
POST /v1/campaigns/{id}/submit1campaign.write
POST /v1/events5profile.write
GET /v1/exports1data.export
POST /v1/exports25data.export
POST /v1/reports/funnel25analytics.read
POST /v1/reports/retention25analytics.read
POST /v1/messages1campaign.send

The mechanics:

PropertyValue
Default allowance600 units per minute, from PUBLIC_API_BUDGET_PER_MINUTE
AlgorithmFixed window, one Redis round trip
WindowOne calendar minute on the wall clock, not a rolling minute
ScopePer API key, not per account
Failure directionFails closed. Redis unreachable means 503 budget_unavailable
ChargedBefore the handler runs, and charged even when the handler then fails

600 units is roughly two dozen heavy reports a minute, or six hundred cheap ones. Exactly: 24 heavy calls go through and the 25th is refused.

The budget is keyed on the API key rather than the account, unlike the rate limit below, and that is deliberate. You issue one narrow key to an agent and keep your own integration key separate, and a runaway agent must not be able to exhaust the budget your order pipeline depends on. See MCP.

POST /v1/messages costs 1, which looks wrong for a call that can send to a person's phone. It is not: the cost of that call is trivial in query terms and enormous in consequence, and the thing meant to bound it is a recipient budget, which does not exist. See what has no limit.

There are no X-RateLimit-* headers on the budget. Not on refusals and not on successes. GET /v1/whoami does not report remaining budget either. You cannot see how close you are: count your own spend from the table above, or handle the 429.

The only recovery is time. The Redis key expires 70 seconds after the first debit in the window and the minute bucket turns on the wall clock. There is no reset endpoint, no per-key override, and no way to raise the allowance short of changing the environment variable and restarting the process.

A locked account's refused request still costs its budget units, because the lock check runs after the budget debit.

#The transactional rate limit

A second, entirely separate mechanism. It counts requests rather than cost units, it is scoped to the account rather than the key, and it fails in the opposite direction.

PropertyValue
Applies toPOST /v1/messages and nothing else
AlgorithmFixed window, Redis INCR
WindowOne calendar minute
ScopePer account. Rotating a key buys no fresh allowance
Limit sourceYour account's own api_rate_per_minute, read on every request with no cache
DefaultAPI_RATE_PER_MINUTE, which ships as 0
0 meansMetering is off entirely. This is the shipped default
Failure directionFails open. Redis unreachable means the call proceeds

It fails open deliberately, and the reason is the traffic it carries. This endpoint sends order receipts and login codes. Refusing them all because a cache is down turns our outage into your checkout failing. A rate limit exists to stop a runaway integration, which is a problem worth being late to.

Because it is a fixed window, a caller can send two windows' worth of messages across a boundary. That is the right trade for a limit whose job is stopping a runaway loop rather than metering to the request.

When a limit is configured, two headers go out on every response, including successes, so you can slow down before you are refused.

HTTP
HTTP/1.1 200 OK
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
Content-Type: application/json; charset=utf-8

X-RateLimit-Remaining is never negative. There is no X-RateLimit-Reset and no X-RateLimit-Resource. With the shipped default of zero, no headers are sent at all.

The ingest host has no rate limiter of any kind. Its only volume controls are the body cap, the batch cap and the quota gate.

#Quotas

A quota is a plan ceiling, counted over a Jalali month.

MeterCounts
eventsEverything the collector accepted, including de-duplicated replays
profilesThe high-water mark of identified people in the period, not the sum
messages.email, messages.sms, messages.push, messages.web, messages.inappSends, one meter per channel
messages.messengerBale, Eitaa and Rubika pooled into one meter

Events are metered after acceptance only, so a rejected payload is not billed and a de-duplicated retry is not either. A retrying SDK costs us a Redis lookup, not an invoice line you would dispute. Messages are metered on a successful send only.

The plan carries two hard ceilings, max_events and max_messages, which are distinct from the included allowance. Zero means no ceiling: the overage is billed and everything keeps working. Going past the allowance costs money and is a bill. Going past a ceiling stops you and is an outage.

Only the event ceiling is enforced. No code path checks a message meter against max_messages, so quota_message_cap never fires. The field is declared, validated and rendered on invoices, and enforced nowhere.

What happens at the ceiling:

  • The request is refused whole with a 402, never partially accepted. A partial accept would leave you unable to tell which events to resend, and you are over the ceiling either way.
  • The check runs once per batch, before any per-item work.
  • Every SDK discards the batch it was holding, because all three treat any 4xx except 429 as permanent. Those events are gone.
  • Retrying changes nothing until somebody pays.

The quota verdict is cached for 15 seconds per account, so you can overshoot a ceiling by whatever you send inside that window. The gate also fails open: if the subscription or usage lookup errors, the traffic is accepted.

The period is a Jalali month, not a Gregorian one, and the days inside it are counted in Tehran local time. The period ends exactly where the next one begins, so no instant falls between two periods.

Announcements, which refuse nothing, fire at 300, 150, 125, 100 and 80 per cent of the included allowance, once per meter per Jalali month.

#The soft lock

A money control, not a rate limit.

TriggerThreshold
Overdue invoiceAn issued invoice at least 75 days past due. A declared payment disarms it
UsageProfiles or events at or above 300% of the included allowance

Overdue is checked first. The verdict is cached for 60 seconds per account, and it fails open on every path it cannot read. The profile figure lags by up to an hour, because it is written by a background poll.

It closes exactly four routes on the management host: GET /v1/exports, POST /v1/exports, POST /v1/reports/funnel and POST /v1/reports/retention.

Everything else stays open, deliberately: ingest, transactional send, campaign creation, campaign send, and segment authoring. A hole in your data cannot be filled in afterwards and a debt can be collected afterwards.

The same lock closes the same two families on the panel. A lock that one credential type honours and another does not is not a lock, it is a detour, and the detour is a script away.

#Timeouts

CallServer-side ceiling
GET /v1/whoami, GET /v1/capabilities, GET /v1/statusNo database work at all
Every read, every write, POST /v1/audiences/count, POST /v1/messages30 s, the query timeout
POST /v1/reports/funnel, POST /v1/reports/retentionThe handler allows 45 s, but the listener cuts the response at 30 s
Any call on the management hostWriteTimeout 30 s, ReadTimeout 15 s, ReadHeaderTimeout 5 s
Any call on the ingest hostWriteTimeout 30 s, ReadTimeout 15 s, IdleTimeout 120 s

The report row is a real conflict, not a rounding difference. A funnel that takes between 30 and 45 seconds is cut off by the HTTP server, not by the handler, so you see a truncated response rather than a clean error. Narrow the time range or the number of steps.

A client timeout of 35 seconds covers every ceiling here. Going higher gains nothing, because the listener closes the connection at 30 seconds regardless.

#What has no limit

Each of these is a limit a reader expects to find. None of them exists.

  • No ceiling on distinct event names, property keys or trait keys. max_properties and max_traits bound one payload, not your schema. You can create ten thousand distinct event names and nothing will stop you. Nothing will make them useful either.
  • No row cap on an export. POST /v1/exports takes kind, format and spec, and there is no max_rows, no server-side cap and no expected_count on the job.
  • No per-IP rate limiting anywhere in the product. Not on the ingest host, not on the management host.
  • No recipient budget. Nothing counts how many people a key may send to in a day, which is why POST /v1/messages costs one budget unit and can still reach a hundred thousand phones through a hundred thousand calls. Bound it yourself.
  • No message ceiling enforcement. Covered above.
  • No import endpoint on the management host. CSV import is a panel feature, capped at 500,000 rows and 64 MiB, and there is no API for it.
  • No Accept-Language. Neither host reads it. Quota and lock messages are always Persian.

Related: error codes, the ingest API, the management API.

PreviousErrorsNextOpenAPI

On this page

  • Read the limits from the server
  • Ingest limits
  • The timestamp window
  • Management host limits
  • Report limits
  • Audience and segment limits
  • Transactional send limits
  • The request budget
  • The transactional rate limit
  • Quotas
  • The soft lock
  • Timeouts
  • What has no limit

Segmentic

This page is written from the code