Rate Limits & Errors
This is the single source of truth for TentoCMS API rate limits, payload/size limits, and error codes. Other docs link here rather than restating these values — enforced in CI by scripts/docs/canonical-facts-lint.sh, which fails the build if a registered figure is restated elsewhere.
Rate Limits
TentoCMS applies rate limits in the Worker (a Durable Object, always on), keyed per API key for public traffic. A coarser per-IP ceiling at the Cloudflare WAF sits above the in-worker limits as an anti-DDoS backstop — for a single, well-behaved API key you'll only ever meet the per-key limits below.
| Surface | Path(s) | Limit | Scope | Layer |
|---|---|---|---|---|
| Public reads | GET /api/v1/pages, /collections, /blog, /media, /schemas | 1200 req/min | per API key | Worker¹ |
| Public writes | POST/PUT/DELETE /api/v1/pages, /api/v1/collections | 30 req/min | per API key | Worker |
| Media upload | POST /api/v1/media | 10 req/min | per API key | Worker |
| Admin API | /api/v1/admin/* | 300 req/min | per IP | Worker |
| Login & password endpoints | POST /api/v1/auth/login, POST /api/v1/auth/setup-password, POST /api/v1/auth/forgot-password, POST /api/v1/auth/reset-password | Three independent layers, all apply: (1) a Durable-Object request-rate limiter, 10 req/min per IP, checked on every request regardless of outcome (authRateLimiter(), apps/api/src/middleware/rateLimiter.ts); (2) for /auth/login only, an account-lockout counter of 3 failed attempts/IP + 5 failed attempts/email per 30 min²; and (3) an edge WAF rule, 20 req/min per IP, blocking for 5 minutes³ | per IP (layers 1 & 3); per IP & email (layer 2) | Worker + edge |
¹ Counted per cache miss: reads served from the edge cache never reach the Worker, so they don't consume your budget. A coarse per-IP WAF ceiling (2000 req/min sustained, 400 req/10s burst) sits above the 1200/min-per-key read limit as an anti-DDoS backstop. It's set above the per-key limit, so a single API key hits the 1200/min limit first — and only that limit returns the RATE_LIMITED envelope below; the WAF only matters when many keys share one egress IP (e.g. CI runners or corporate NAT). See Static-site generation & bulk reads below.
² After repeated failures, the account-lockout layer applies an exponential lockout (5 min → 15 min → 1 hour → 24 hours). This is separate from, and stacked behind, the flat 10 req/min-per-IP Durable Object limiter — hitting either one independently returns 429.
³ Added 2026-08-07. Its ceiling is set deliberately above the Durable Object limiter, so in normal operation the Worker answers first and you get the RATE_LIMITED envelope below, with Retry-After. The edge rule is the backstop for traffic that ignores it. The difference matters if you reach it: the WAF blocks at the edge for 5 minutes, returns Cloudflare's own 429 rather than our JSON envelope, and counts per IP rather than per account — so on a shared egress IP (CI runners, corporate NAT) one client's volume can block the others. Back off when you receive a 429; do not retry immediately — retries still reach the edge and count towards this ceiling even while the Worker is rejecting them, which is the fastest way to turn a one-minute throttle into a five-minute block. Treat a 429 without our error.code as an edge block that will not clear early. The rule covers these four credential endpoints only: session endpoints such as /api/v1/auth/me are excluded, so an edge block here does not invalidate an existing session.
Rate-limit responses
When an in-worker limit is exceeded, the API returns HTTP 429 with:
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please try again later.",
"retryAfter": 42
}
}
Rate-limited responses may include these headers:
| Header | Meaning |
|---|---|
Retry-After | Seconds to wait before retrying |
X-RateLimit-Limit | Requests allowed in the window |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | When the window resets |
Handle 429 with exponential backoff, honouring Retry-After when present.
Static-site generation & bulk reads
The 1200 req/min-per-key read limit is set generously so a typical static-site build (Astro, Next.js, Nuxt, etc.) can fetch all of a project's published content in a single pass. Each page or collection item you fetch is one uncached request, so a site with more than ~1200 pages/items, or a build that fetches with high concurrency, can exceed the limit within a build.
If your builds hit 429 RATE_LIMITED, options in order of preference:
- Catch and back off. The official client throws
TentoRateLimitErroron a429, exposingretryAfter(seconds, from theRetry-Afterheader). Catch it, waitretryAfter, and retry — rather than letting the build fail. Building your own client? Do the same, using exponential backoff. - Reduce fetch concurrency in your build so requests spread across the minute instead of bursting (e.g. lower your generator's parallelism / request-batch size).
- Fetch in bulk, not per-item. Use the list endpoints with
limit(max 100 per page) and paginate, rather than one request per item, to cut total request count dramatically. - Lower your generation frequency if you rebuild on a schedule — a very high rebuild cadence multiplies read volume.
If you have a legitimate need that exceeds these limits (a very large catalogue, or many builds sharing one egress IP such as a CI runner), contact your TentoCMS administrator — limits can be reviewed per project.
Payload & Size Limits
| Limit | Value | On exceed |
|---|---|---|
Request content size (fields/content JSON on page & collection writes) | 1 MB | 413 PAYLOAD_TOO_LARGE |
| Media upload — images | 10 MB | 400 INVALID_FILE_CONTENT / validation error |
| Media upload — documents (PDF) | 50 MB | validation error |
| Media upload — video (MP4/WebM) | 50 MB | validation error |
List limit query param | default 20, max 100 | 400 INVALID_PAGINATION on GET /pages (INVALID_QUERY on GET /blog; GET /collections/:type has no equivalent code — see below) |
Allowed media upload types are a fixed set (common image types, application/pdf, video/mp4, video/webm). Other types — including ZIP archives — are rejected.
Idempotency (public write endpoints)
Every write request on /api/v1/pages and /api/v1/collections/:type (POST, PUT, DELETE, plus the /publish//unpublish actions) requires an Idempotency-Key header (apps/api/src/middleware/idempotency.ts, applied in pages-write.ts/collections-write.ts). GET/HEAD/OPTIONS requests are exempt.
POST /api/v1/media (media upload) is the one exception — it deliberately does not require or check an Idempotency-Key, because its multipart/form-data body can't be reliably hashed as text for the replay-detection logic below (see the comment in apps/api/src/routes/public/media-write.ts).
- Missing header →
400 BAD_REQUEST:"Idempotency-Key header is required for write operations" - Header longer than 256 characters →
400 BAD_REQUEST:"Idempotency-Key must be a non-empty string with max 256 characters" - Replaying the same key with the same request body while the original is still processing →
409 CONFLICT:"Request with this idempotency key is currently being processed" - Replaying the same key with the same request body after the original completed → the original response is replayed verbatim, with an added
X-Idempotency-Replay: trueheader (no re-execution) - Replaying the same key with a different request body →
409 CONFLICT:"Idempotency key already used with different request body"
Idempotency keys are scoped per project and expire after IDEMPOTENCY_TTL_HOURS (@tentocms/shared). Pick a key that's unique per logical operation (e.g. a UUID you generate client-side per create/update attempt) and reuse it only when retrying that exact same operation.
Error Codes
Most errors use the envelope:
{ "error": { "code": "ERROR_CODE", "message": "Human-readable description" } }
The raw Zod-validator shape
This envelope is not universal. Several routes validate their request body/query with zValidator(...) from @hono/zod-validator and pass no custom error hook. On those routes a validation failure skips the envelope above entirely and returns the library's raw default shape instead: {success:false, error:<ZodError>}. There is no error.code. Zod v4's ZodError.issues array is a non-enumerable property, so it never survives JSON.stringify() — only error.name/error.message do, and error.message is itself a JSON-encoded string you must JSON.parse() to read the structured per-field issues (path/code/message, plus check-specific fields like expected/origin).
Confirmed affected routes (not exhaustive — check for a third zValidator argument to be sure of any given route):
- The public write API:
POST/PUTon/api/v1/pagesand/api/v1/collections/:type(apps/api/src/routes/public/pages-write.ts,collections-write.ts) — malformed bodies here get the raw shape, notVALIDATION_ERROR. GET /api/v1/collections/:type'spage/limit/sortquery validation (apps/api/src/routes/public/collections.ts) — unlikeGET /pagesandGET /blog, which validate by hand and return properINVALID_PAGINATION/INVALID_QUERYenvelopes (see the table above).
Example: POST /api/v1/pages with slug omitted from the body:
{
"success": false,
"error": {
"name": "ZodError",
"message": "[\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"slug\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n }\n]"
}
}
Parsed, error.message is:
[
{
"expected": "string",
"code": "invalid_type",
"path": ["slug"],
"message": "Invalid input: expected string, received undefined"
}
]
VALIDATION_ERROR is a real code, but it's only emitted by routes that manually call .safeParse() and build the envelope themselves — e.g. media folder-name validation (apps/api/src/routes/media.ts), tenant-user invites, admin search — never by a zValidator(...)-based route with no hook. For page/collection/blog content writes and list-query validation specifically (the routes covered by this document and by docs/admin-api/{pages,blog}.md), VALIDATION_ERROR is a documentation error wherever it appears: the real behavior on those specific routes is either the raw shape above, or a specific named code like INVALID_CONTENT/INVALID_PAGINATION/INVALID_QUERY (all manually-coded envelopes).
Common (all endpoints)
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | Missing/invalid/expired API key or session |
FORBIDDEN | 403 | Authenticated but lacking the required role/permission |
NOT_FOUND | 404 | Resource does not exist or is not published |
VALIDATION_ERROR | 400 | Request body/params failed schema validation, on routes that validate manually (e.g. media folder names, tenant-user invites, admin search) — not on zValidator(...)-based routes with no custom hook, which return the raw shape instead |
BAD_REQUEST | 400 | Missing or malformed Idempotency-Key header on a public write endpoint — see Idempotency above |
INVALID_JSON | 400 | Request body is not valid JSON |
RATE_LIMITED | 429 | Rate limit exceeded (see above) |
PAYLOAD_TOO_LARGE | 413 | Content exceeds the 1 MB size cap |
CONFLICT | 409 | State conflict (e.g. duplicate slug, version mismatch) |
INTERNAL_ERROR | 500 | Unexpected server error |
Query & pagination (public read endpoints)
| Code | HTTP | Meaning |
|---|---|---|
INVALID_PAGINATION | 400 | limit > 100 or page < 1 — GET /pages only (GET /blog uses INVALID_QUERY for the same condition; GET /collections/:type has no code at all and returns the raw Zod-validator shape) |
INVALID_SORT_FIELDS | 400 | Sort field not in the allowed list |
INVALID_FILTER_FIELD | 400 | Core filter field not allowed (use the fields. prefix for content fields) |
INVALID_JSON_FIELD_PATH | 400 | Malformed JSON field path in a filter[...] expression |
Schema validation (page types & components)
| Code | HTTP | Meaning |
|---|---|---|
INVALID_PAGE_TYPE | 400 | Referenced page type is invalid/unknown |
UNSUPPORTED_OPTION | 400 | Field option not supported for that field type |
MISSING_REPEATER_FIELDS | 400 | A repeater field is missing its sub-field definitions |
DISALLOWED_REPEATER_FIELD_TYPE | 400 | A repeater contains a disallowed sub-field type (componentPicker, nestedComponent, or nested repeater) |
See Field Types and Validation for the full field-type catalogue and schema rules.
Content operations
| Code | HTTP | Meaning |
|---|---|---|
DUPLICATE_SLUG | 409 | Slug already exists for that page type — admin API only (apps/api/src/routes/{pages,admin/blog}.ts). The public write API (PageWriteService/CollectionWriteService) returns CONFLICT for the same condition instead — see below. |
HAS_REFERENCES | 409 | Cannot delete — other content references this item |
INVALID_STATUS | 400 | Operation not valid from the resource's current status (e.g. starting a non-pending migration) |
INVALID_VERSION | 400 | The :versionNumber path segment on a version-history/restore/revert endpoint isn't an integer (apps/api/src/routes/{pages,collection-items}.ts) — not an optimistic-lock mismatch |
INVALID_PREVIEW_KEY | 401 | Preview key missing or invalid |
Public write API note: PageWriteService/CollectionWriteService (backing POST/PUT on /api/v1/pages and /api/v1/collections/:type) use the single CONFLICT code (409) for both a slug conflict and an optimistic-lock version mismatch — they never return DUPLICATE_SLUG or INVALID_VERSION. Those two codes belong to the separate admin schema-management/content endpoints described above.

