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.
curl -s https://api.segmentic.net/v1/capabilities \
-H "Authorization: Bearer sk_seg_..."
{
"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_rowsandestimate_samplebound nothing on the management host. They belong to two panel endpoints that are not registered here. Ignore them.query_timeout_secis 30, and it is the timeout for reads, writes andPOST /v1/audiences/count. It is not the timeout the two report routes use, which is 45 seconds. See timeouts.ingestandimportare the same boolean under two names, the one that registersPOST /v1/events.exportcontrols no route on this host. The two export routes are turned on and off byasync_exportsinstead. An account reading"export": falsemay still have both export routes, and the reverse. For exports, look atasync_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.
| Limit | Value | Applies to | On breach |
|---|---|---|---|
| Event name length | 128 bytes | the event field on a track | Rejects, event_name_too_long |
| Id length | 256 bytes | user_id, anonymous_id, message_id | Rejects, id_too_long |
| Session id length | 256 bytes | context.session_id | Truncates silently |
| Previous id | no limit | previous_id on an alias | Checked for presence, never for length |
| Property or trait key | 128 bytes | each key, after normalisation | Truncates silently |
| Property or trait value | 8192 bytes | each string value | Truncates silently |
| Properties per event | 256 | properties | Keeps the first 256, warns too_many_properties |
| Traits per identify | 256 | traits | Keeps the first 256, warns too_many_traits |
| Events per batch | 500 | POST /v1/batch and POST /v1/events | Rejects, batch_too_large |
| Request body | 5 MiB | the whole request on the ingest host | Rejects, 413 |
| Page URL | 2048 bytes | context.page.url, .path and .referrer | Truncates silently |
| Webhook body | 1 MiB | POST /v1/hooks/{source}/{token} | Rejects |
| Bounce report | 1 MiB | POST /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.
| Bytes | Fields |
|---|---|
8 | the currency property, uppercased |
16 | context.device.push_provider |
32 | context.locale, context.device.type, context.os.name, context.os.version, context.library.version, and the browser version derived from the User-Agent |
64 | context.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 |
128 | context.device.model, context.campaign.token, and all five utm fields: source, medium, name, term and content |
256 | context.session_id, context.campaign.message_id |
512 | context.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.
| Setting | Value | Behaviour |
|---|---|---|
| Default past window | 30 days | The floor. An account on 30 day retention still gets the full 30 days |
| Future window | 1 hour | Anything further ahead is clamped to receive time and warns timestamp_in_future |
| Retention set to "keep for ever" | 3650 days | Ten 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
| Limit | Value | Applies to |
|---|---|---|
| Request body | 8 MiB | most routes |
| Request body | 256 KiB | POST /v1/messages |
| Request body | 1 MiB | POST /v1/audiences/validate, POST /v1/audiences/count, the two report routes |
| Max page size | 100 | every list route |
| Default page size | 25 | every list route |
| Export kinds | events, messages, profiles, segment | POST /v1/exports |
| Export format | ndjson by default, csv the only alternative | POST /v1/exports |
| Export file lifetime | 7 days | published 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.
| Limit | Value | What it bounds |
|---|---|---|
| Time range | 730 days | The widest window either report will scan. Two years for a large account is already hundreds of billions of rows |
| Funnel steps | 12 | One condition is built per step and a human has to read the result |
| Retention periods | 60 | The number of columns in a table somebody has to read. Defaults to 30 when you send zero or less |
| Filters per report | 10 | |
| Property key length | 128 | |
| Event name length | 256 | |
| Property value length | 512 | |
| Path depth | 8 | The paths report, which is not on this host |
| Path rows | 100 | The 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}.
| Limit | Value | Error text |
|---|---|---|
| Nesting depth | 8 | segment: nesting too deep |
| Conditions | 200 | segment: too many conditions |
| Values in one list | 1000 | segment: list has too many values |
| Property key length | 128 |
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
| Limit | Value | Config key |
|---|---|---|
| Variables per message | 40 | none |
| Idempotency key format | ^[A-Za-z0-9._:-]{8,200}$ | none |
| Idempotency key lifetime | 7 days | API_IDEMPOTENCY_RETENTION, default 168h |
| Stale reservation timeout | 1 minute | API_STALE_RESERVATION |
| Request body | 256 KiB | none |
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.
| Class | Weight | Meaning |
|---|---|---|
| Trivial | 1 | Reads nothing, or reads one row by primary key |
| Query | 5 | One bounded warehouse query |
| Heavy | 25 | A scan whose cost scales with your history |
The cost of every route:
| Route | Cost | Permission |
|---|---|---|
GET /v1/whoami | 1 | none |
GET /v1/capabilities | 1 | none |
GET /v1/schema/events | 5 | event.read |
GET /v1/schema/traits | 5 | event.read |
POST /v1/audiences/validate | 1 | segment.read |
POST /v1/audiences/count | 25 | segment.read |
GET /v1/segments | 1 | segment.read |
GET /v1/segments/{id} | 1 | segment.read |
POST /v1/segments | 1 | segment.write |
PUT /v1/segments/{id} | 1 | segment.write |
DELETE /v1/segments/{id} | 1 | segment.delete |
GET /v1/campaigns | 1 | campaign.read |
GET /v1/campaigns/{id} | 5 | campaign.read |
POST /v1/campaigns | 1 | campaign.write |
PUT /v1/campaigns/{id}/recurrence | 1 | campaign.send |
DELETE /v1/campaigns/{id}/recurrence | 1 | campaign.send |
POST /v1/campaigns/{id}/send | 1 | campaign.send |
POST /v1/campaigns/{id}/submit | 1 | campaign.write |
POST /v1/events | 5 | profile.write |
GET /v1/exports | 1 | data.export |
POST /v1/exports | 25 | data.export |
POST /v1/reports/funnel | 25 | analytics.read |
POST /v1/reports/retention | 25 | analytics.read |
POST /v1/messages | 1 | campaign.send |
The mechanics:
| Property | Value |
|---|---|
| Default allowance | 600 units per minute, from PUBLIC_API_BUDGET_PER_MINUTE |
| Algorithm | Fixed window, one Redis round trip |
| Window | One calendar minute on the wall clock, not a rolling minute |
| Scope | Per API key, not per account |
| Failure direction | Fails closed. Redis unreachable means 503 budget_unavailable |
| Charged | Before 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.
| Property | Value |
|---|---|
| Applies to | POST /v1/messages and nothing else |
| Algorithm | Fixed window, Redis INCR |
| Window | One calendar minute |
| Scope | Per account. Rotating a key buys no fresh allowance |
| Limit source | Your account's own api_rate_per_minute, read on every request with no cache |
| Default | API_RATE_PER_MINUTE, which ships as 0 |
0 means | Metering is off entirely. This is the shipped default |
| Failure direction | Fails 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/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.
| Meter | Counts |
|---|---|
events | Everything the collector accepted, including de-duplicated replays |
profiles | The high-water mark of identified people in the period, not the sum |
messages.email, messages.sms, messages.push, messages.web, messages.inapp | Sends, one meter per channel |
messages.messenger | Bale, 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.
| Trigger | Threshold |
|---|---|
| Overdue invoice | An issued invoice at least 75 days past due. A declared payment disarms it |
| Usage | Profiles 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
| Call | Server-side ceiling |
|---|---|
GET /v1/whoami, GET /v1/capabilities, GET /v1/status | No database work at all |
Every read, every write, POST /v1/audiences/count, POST /v1/messages | 30 s, the query timeout |
POST /v1/reports/funnel, POST /v1/reports/retention | The handler allows 45 s, but the listener cuts the response at 30 s |
| Any call on the management host | WriteTimeout 30 s, ReadTimeout 15 s, ReadHeaderTimeout 5 s |
| Any call on the ingest host | WriteTimeout 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_propertiesandmax_traitsbound 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/exportstakeskind,formatandspec, and there is nomax_rows, no server-side cap and noexpected_counton 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/messagescosts 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.