AuralisDocs

Rate limits

What is actually capped, what is not capped at all, and the two behaviours that catch integrators.

Rate limiting is on. It is also narrower than you probably expect, and two of its behaviours will surprise you if you meet them in production rather than here.

The short version

The ordinary CRUD surface is not rate limited. /v1/tickets, /v1/customers, /v1/companies, /v1/teams, /v1/macros and their siblings have no rate limit of any kind, with an API key or with a session.

What is limited falls into three groups: unauthenticated ingress (the routes a stranger can reach), credential verification failures (an anti-brute-force budget), and a few authenticated routes that spend something irreversible — an email, a realm account, a model call.

Unlimited is not the same as unbounded

An endpoint with no rate limit is still bounded by the 4 MiB body cap, the 500-row page cap, and your database. "Not rate limited" means there is no request counter, not that you should page a million tickets in a tight loop.

What a refusal looks like

Every limit refuses the same way: 429, with headers and a body that both say which budget you hit.

HTTP/2 429
retry-after: 56
ratelimit-limit: 10
ratelimit-remaining: 0
ratelimit-reset: 56
x-request-id: a4b32aa5-14e2-4952-b859-a059127aa677
{
  "scope": "csat",
  "limit": 10,
  "window_seconds": 60,
  "error": "rate_limited",
  "message": "too many csat requests; retry in 56s"
}

Treat a 429 as "come back later", not "rejected". Nothing was written and nothing was lost. The same request replayed after Retry-After is accepted. A client that gives up on 429 turns a deferral into a lost ticket.

You cannot see your budget before you spend it

RateLimit-* headers are emitted only on the 429 itself. A successful response carries none of them, so there is no way to watch your remaining allowance and slow down in advance. Back off reactively, on the 429.

What is limited

Every window is a fixed 60-second window, not a sliding one. A fixed window admits up to twice the limit across a boundary; that is a known and accepted trade, not a bug.

Unauthenticated ingress

ScopePer 60sCounted perRoutes
ingest600workspacePOST /v1/ingest/email, POST /v1/ingest/{channel}
webhook600integrationPOST /v1/webhooks/{provider}/{integration_id}
live_chat_session180live-chat sessionthe customer live-chat routes
handoff120workspacePOST /v1/handoff
widget30workspace + visitor addressPOST /v1/contact-forms/{slug}
csat10ticketthe CSAT survey routes
preview_token60client addressGET /v1/help-centre/preview/{token}

Credential verification failures

These count failures only. A successful verification is refunded, so normal traffic never accumulates against them.

ScopeFailures per 60sCounted per
token_verify60client address
api_key_verify60client address
login_address50client address
invite_accept, invite_claim20client address
login_identity10email address (hashed)

Authenticated routes that spend something

ScopePer 60sCounted perWhy it is capped
invite_issue20workspaceEach one sends mail; sending reputation is finite.
auralis_chat10workspaceEach one is a model call.
domain_claim10workspaceEach one touches DNS verification.

A concurrency cap, not a rate limit

stream allows 8 concurrent live-chat event streams per session. The ninth gets 429 with Retry-After: 5. It is a cap on simultaneous connections, not on requests over time.

The two behaviours that catch integrators

These are the ones worth reading twice.

Every caller behind the same proxy shares one address budget

The address budgets are keyed on the peer address the runtime actually sees. The runtime deliberately does not trust X-Forwarded-For here, because on this path that header is supplied by the caller and trusting it would let anyone reset their own budget.

The consequence is real: if your integration reaches the runtime through a shared proxy or NAT, every caller behind it shares one 60-failure verification budget. One misconfigured client looping on a bad credential can exhaust it for everyone on that address.

Exhausting the failure budget locks out valid credentials too

The budget is checked before the credential is verified. Once it is spent, a correct key gets 429 as well:

# after 60 failed attempts from this address, with a valid key:
curl -sk -D- -o /dev/null \
  -H "Authorization: Bearer $AURALIS_API_KEY" \
  https://api.localhost/v1/tickets?limit=1
# HTTP/2 429   ratelimit-limit: 60   retry-after: 6

It self-heals at the window boundary, and only failures accumulate. But it means a retry loop on a bad credential is actively harmful — it does not just fail, it takes your working clients down with it. Never retry a 401 without changing the credential.

When the limiter cannot reach its counter

If the counter store is unreachable, the limiter fails open — the request is allowed and a warning is logged. Availability is preferred to enforcement.

Two scopes fail closed instead, answering 429: invite_issue and invite_claim. Both spend something that cannot be taken back — sending reputation, and an account on a shared identity realm — so the safe default inverts.

Limits that are not rate limits

These bite far more often than the rate limiter does.

LimitValueOn breach
Request body4 MiB413 body_too_large
JSON nesting depth64 levels422 body_too_deep
Page size (limit)500 max on /v1/tickets422 validation_error
Offset2,147,483,647 max422 validation_error
NUL byte in body or URLnot allowed400 invalid_text

The body and depth guards apply to JSON content types only. A multipart upload bypasses them deliberately, because multipart legitimately carries NUL bytes and has no nesting to measure.

Two list endpoints have no default page size

GET /v1/customers and GET /v1/companies take limit as optional with no default. Omit it and you get the entire workspace in one response — on a workspace with 3,200 customers, that is 3,200 rows. Always send an explicit limit on those two.

Handling limits well

  • Read Retry-After and honour it. Do not use a fixed sleep.
  • Back off on 429, and do not back off on 401 — fix the credential.
  • Prefer one request that pages than many that poll.
  • Log x-request-id on every failure.

On this page