Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersLimits, caps and delivery guarantees

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

WhatLimit
Public API, per API keyPer 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 keysPer (practice, client) at the plan limit, plus one practice-wide window of 5 times it. partner-api.md.
Every route, per client IP120 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

WhatCapError
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)50409 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

WhatLimitOn breach
JSON request body5 MB413 REQUEST_BODY_TOO_LARGE
Every body fieldShape-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 documentA 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 expressionAt 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.

This page is the product manual, published from the repository at docs/developers/limits.md. Customers read the same text inside CMDash under Help. Last updated October 11, 2026.

Limits, caps and delivery guarantees — CMDash Docs