Limits, caps and delivery guarantees
A1009 P4. Every number below is read from the code that enforces it; the file that owns it is named so you can check. When a limit is hit, the response carries a registered error code from the error catalogue or, for a list, a disclosure header. Nothing here is a silent truncation.
Rate limits
| What | Limit |
|---|---|
| Public API, per API key | Per hour by plan: Starter 500, Essentials 2,000, Command 10,000, Enterprise 20,000 (fallback 1,000). Table and override: public-api-versioning.md. |
| Public API, per workspace (all keys together) | 5 times the plan limit, per hour. The per-key window alone multiplied the quota by the number of keys; this window closes that. |
| Public API, PARTNER keys | Per (practice, client) at the plan limit, plus one practice-wide window of 5 times it. partner-api.md. |
| Every route, per client IP | 120 requests a minute by default, narrowed by @Throttle(...) on sensitive routes. |
Headers
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset mean the same
thing on every limiter: X-RateLimit-Reset is the Unix time, in seconds, at which
the window resets. (The per-IP limiter used to send the seconds remaining, so a
client that read it the way the Public API documents it saw a reset in 1970.)
Retry-After, on a 429, is always the seconds to wait, as RFC 9110 defines it, and
the error envelope repeats it as details.retryAfterSeconds. A 429 from the
workspace-wide window carries details.scope: "tenant"; from a key's own window,
"key".
When Redis is unavailable
The Public API limiter fails open: if its Redis cannot be reached, calls are
allowed and not counted rather than the paid API going down with a cache. That used
to be permanent after a failed connect at boot (the limiter never tried again until the
process restarted). It now recovers on its own: the next request after a 5-second
cooldown starts one background probe, and metering resumes as soon as Redis answers.
While it is down the limiter logs a warning (at most once a minute) and reports
metering: "unmetered" through ApiRateLimitService.getHealth(), with the time it
lost Redis and the number of calls allowed uncounted. The same report is included, for a signed-in
caller only, in GET /health/ready under signals.apiRateLimiter; it never changes the readiness
status or the 200/503 code, and an anonymous caller still receives only { "status": ... }.
Caps per workspace
| What | Cap | Error |
|---|---|---|
| Active API keys (unrevoked, unexpired) | 25, exact: the count and the insert share one transaction under a per-workspace lock. Rotating a key does not count. | 409 API_KEY_LIMIT_REACHED |
| Webhook registrations (active or not) | 50 | 409 WEBHOOK_LIMIT_REACHED |
List endpoints
A list is bounded at 500 rows (LIST_ROW_CAP). A list that was cut says so: the
X-List-Truncated response header names the surface, and meta.hasMore /
meta.totalCount in the body say it again (?totalCount=true asks for the exact
count). The full convention, and the routes that are paged instead:
list-conventions.md. A limit you pass is clamped to the
cap, never trusted.
Request bodies and uploads
| What | Limit | On breach |
|---|---|---|
| JSON request body | 5 MB | 413 REQUEST_BODY_TOO_LARGE |
| Every body field | Shape-checked at the controller (zod). A field of the wrong type is 400 with details.fieldErrors; it never reaches the database as an operator. | 400 |
.docx policy upload (gap analysis) | The document text may expand to at most 20 MB, and the archive may hold at most 2,000 entries. Nothing is stored or counted against your storage quota. | 413 DOCUMENT_UNCOMPRESSED_TOO_LARGE |
| Purchase-order document | A real PDF (starts with %PDF-), at most 10 MB, scanned for malware when a scanner is configured. | 400 PO_DOCUMENT_NOT_PDF, 400 PO_DOCUMENT_INFECTED, 503 PO_DOCUMENT_SCAN_UNAVAILABLE |
| Custom-field regular expression | At most 200 characters. At save it is also tried against adversarial inputs (generic ones plus runs of the characters the pattern itself spells out) and refused if any takes more than 15 ms; that check runs once per pattern. Every value is matched under a clock: 15 ms for one value, and 250 ms in total for a request that checks many (the lazy re-validation). Values the clock does not reach are reported as "not checked" (notChecked in the response), never as passing. | 400 |
Webhook delivery guarantees
What is guaranteed. Once an event has a delivery row, delivery is at-least-once: the row is written before the first HTTP attempt, a failed attempt is retried on a schedule by a cron pump (it survives a deploy), a registration whose endpoint keeps failing has its circuit opened and its backlog is kept, in order, for you to redeliver, and deliveries within one registration carry gapless sequence numbers. Receivers must treat a repeat as normal (dedupe on the event id) and verify the signature.
What is not guaranteed. The delivery row is written after the business change
commits, not inside its transaction. The services that emit events (a risk being
created, a sync finishing) call dispatch once their own write has committed, and
dispatch fans out without being awaited. If the API process dies in the window between
the commit and the delivery row being written, the change is saved and the event is
never sent. There is no outbox table that would let a restart notice it. Today this
is a documented limitation, not a bug being worked around: closing it means writing the
delivery rows inside each emitter's transaction (an outbox), which touches every emitter.
What to do about it. Treat webhooks as a prompt, not as the ledger. An integration
that must not miss a change should also reconcile on a schedule against the list
endpoints (for example GET /v2/risks), which read the current state whether or not an
event was sent.