TentoCMS
Api

Rate Limits & Errors

The single source of truth for rate limits, payload limits, idempotency and error codes.

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.

SurfacePath(s)LimitScopeLayer
Public readsGET /api/v1/pages, /collections, /blog, /media, /schemas1200 req/minper API keyWorker¹
Public writesPOST/PUT/DELETE /api/v1/pages, /api/v1/collections30 req/minper API keyWorker
Media uploadPOST /api/v1/media10 req/minper API keyWorker
Admin API/api/v1/admin/*300 req/minper IPWorker
Login & password endpointsPOST /api/v1/auth/login, POST /api/v1/auth/setup-password, POST /api/v1/auth/forgot-password, POST /api/v1/auth/reset-passwordThree 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:

HeaderMeaning
Retry-AfterSeconds to wait before retrying
X-RateLimit-LimitRequests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetWhen 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:

  1. Catch and back off. The official client throws TentoRateLimitError on a 429, exposing retryAfter (seconds, from the Retry-After header). Catch it, wait retryAfter, and retry — rather than letting the build fail. Building your own client? Do the same, using exponential backoff.
  2. 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).
  3. 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.
  4. 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

LimitValueOn exceed
Request content size (fields/content JSON on page & collection writes)1 MB413 PAYLOAD_TOO_LARGE
Media upload — images10 MB400 INVALID_FILE_CONTENT / validation error
Media upload — documents (PDF)50 MBvalidation error
Media upload — video (MP4/WebM)50 MBvalidation error
List limit query paramdefault 20, max 100400 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: true header (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/PUT on /api/v1/pages and /api/v1/collections/:type (apps/api/src/routes/public/pages-write.ts, collections-write.ts) — malformed bodies here get the raw shape, not VALIDATION_ERROR.
  • GET /api/v1/collections/:type's page/limit/sort query validation (apps/api/src/routes/public/collections.ts) — unlike GET /pages and GET /blog, which validate by hand and return proper INVALID_PAGINATION/INVALID_QUERY envelopes (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)

CodeHTTPMeaning
UNAUTHORIZED401Missing/invalid/expired API key or session
FORBIDDEN403Authenticated but lacking the required role/permission
NOT_FOUND404Resource does not exist or is not published
VALIDATION_ERROR400Request 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_REQUEST400Missing or malformed Idempotency-Key header on a public write endpoint — see Idempotency above
INVALID_JSON400Request body is not valid JSON
RATE_LIMITED429Rate limit exceeded (see above)
PAYLOAD_TOO_LARGE413Content exceeds the 1 MB size cap
CONFLICT409State conflict (e.g. duplicate slug, version mismatch)
INTERNAL_ERROR500Unexpected server error

Query & pagination (public read endpoints)

CodeHTTPMeaning
INVALID_PAGINATION400limit > 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_FIELDS400Sort field not in the allowed list
INVALID_FILTER_FIELD400Core filter field not allowed (use the fields. prefix for content fields)
INVALID_JSON_FIELD_PATH400Malformed JSON field path in a filter[...] expression

Schema validation (page types & components)

CodeHTTPMeaning
INVALID_PAGE_TYPE400Referenced page type is invalid/unknown
UNSUPPORTED_OPTION400Field option not supported for that field type
MISSING_REPEATER_FIELDS400A repeater field is missing its sub-field definitions
DISALLOWED_REPEATER_FIELD_TYPE400A 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

CodeHTTPMeaning
DUPLICATE_SLUG409Slug 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_REFERENCES409Cannot delete — other content references this item
INVALID_STATUS400Operation not valid from the resource's current status (e.g. starting a non-pending migration)
INVALID_VERSION400The :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_KEY401Preview 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.

Copyright © 2026