TentoCMS
Api

Responses & Caching

The success and error envelopes, component data shape, and the cache headers every response carries.

Response Format

Success responses follow a consistent data/pagination structure across endpoints. Error responses mostly do too, but not uniformly — see Error Responses below for the one documented exception (GET /collections/:type pagination errors).

Success Responses

List Endpoint:

{
  "data": [...],
  "pagination": {
    "total": 45,
    "page": 1,
    "limit": 20,
    "totalPages": 3
  }
}

Single Resource:

{
  "data": {...},
  "redirect": null
}

Structure:

FieldTypeDescription
dataobject/arrayResponse payload (array for lists, object for single)
paginationobjectPagination metadata (list endpoints only)
redirectobject/nullRedirect information (single page endpoint only)

Component data shape (_type flattening)

Components embedded in page or collection content (via componentPicker or nestedComponent fields) are flattened in API responses. Each resolved component has its content fields at the object root — there is no .fields sub-object — plus a _type field holding the component's type slug exactly as stored. Slugs only ever contain lowercase letters, numbers, and hyphens, so _type values are always kebab-case (for example "cta-banner"); no casing transformation is applied by the API.

{
  "_type": "cta-banner",
  "headline": "Get started today",
  "button_label": "Sign up",
  "button_url": "/signup"
}

Content field keys keep their original casing (for example button_label); _type is the component's slug verbatim. Use _type to decide which frontend component to render for each entry.

A component's _type is always present, including under ?refTypes=false. That parameter applies only to the _type on resolved collection-item references, which is a different discriminator that happens to share the key name.

Error Responses

Most errors return a consistent structure — but this isn't applied uniformly across every list endpoint. GET /pages and GET /blog validate their page/limit params by hand (apiPaginationSchema.safeParse() in apps/api/src/routes/public/pages.ts:119 and blogPostQuerySchema.safeParse() in apps/api/src/routes/public/blog.ts:234) and both return the envelope below on failure — INVALID_PAGINATION for pages, INVALID_QUERY for blog. GET /collections/:type, however, validates the same page/limit params with zValidator('query', listQuerySchema) and no custom error hook (apps/api/src/routes/public/collections.ts:159) — a bad value there (e.g. ?limit=500) skips this envelope entirely and returns @hono/zod-validator's raw default shape instead. See Limits & Errors for what that raw shape actually looks like.

The consistent envelope, where it does apply:

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message"
  }
}

Error Structure:

FieldTypeDescription
error.codestringMachine-readable error code
error.messagestringHuman-readable error description

Example Errors:

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "API key required. Provide via X-API-Key header or api_key query param."
  }
}
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Page with slug 'nonexistent' not found"
  }
}
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Please try again later.",
    "retryAfter": 42
  }
}

Cache Headers

Only the pages endpoints (GET /pages, GET /pages/:slug) set Cache-Control and ETag headers. GET /collections/:type, GET /collections/:type/:slug, and the /blog endpoints do not set either header on their published (non-preview) responses — everything in this section describes pages-endpoint behavior only. All endpoints do set an X-Cache: HIT|MISS header reflecting the internal KV cache, and a Vary header (see below).

X-Cache Header

Every pages, collections, and blog response includes:

X-Cache: HIT

or

X-Cache: MISS

This reflects whether the response came from TentoCMS's own internal server-side KV cache (PublicContentCache) — HIT means it was served from KV without touching D1; MISS means the content was freshly queried from D1 and (outside preview mode) written to KV for next time. It is unrelated to CDN or browser caching and is not present on the media metadata/serving endpoints, which don't use this internal cache.

X-Cache is diagnostic only — it's useful when debugging staleness or investigating latency, but clients don't need to read it or branch on it for correct behaviour. Don't build application logic around its value; use Cache-Control/ETag (pages only, see below) or your own client-side caching for actual caching decisions.

Cache-Control Header

Public read endpoints (pages list + single resource, and blog/collections/media) share one value:

Cache-Control: public, max-age=900, stale-while-revalidate=1800
  • public: Response can be cached by CDN and browser
  • max-age=900: Fresh for 15 minutes
  • stale-while-revalidate=1800: Serve stale for up to 30 minutes while revalidating in the background

Content published/unpublished through the admin API is also purged from the edge cache, so those edits are reflected sooner than max-age alone would imply. (Scheduled publishes and browser caches are not purged, so they refresh on max-age expiry.)

Cache Behavior:

Time since fetchBehaviour
0–15 minServed from cache (fresh)
15–45 minServed from cache (stale) while revalidating in the background
45 min+Cache expired, fetched fresh from API

ETag Header

The pages endpoints only (GET /pages, GET /pages/:slug) include an ETag header:

ETag: "a1b2c3d4e5f6g7h8"

Collections and blog responses never set ETag. 304 Not Modified short-circuiting is enabled on the single-resource page endpoint (GET /api/v1/pages/:slug). The pages list endpoint returns an ETag but does not honour If-None-Match.

Conditional Requests:

Send the If-None-Match header with the ETag value on a single-page request:

curl -H "X-API-Key: tento_pk_..." \
     -H "If-None-Match: \"a1b2c3d4e5f6g7h8\"" \
     https://tento-api.intelligentlending.co.uk/api/v1/pages/about-us

Response if unchanged:

HTTP/1.1 304 Not Modified

Response if changed:

HTTP/1.1 200 OK
ETag: "x1y2z3a4b5c6d7e8"
Content-Type: application/json

{...}

Benefits:

  • Reduced bandwidth usage
  • Faster responses (no body transfer)
  • Lower server load

Vary Header

Page responses include:

Vary: Accept-Encoding, X-Preview-Key, X-API-Key

Media responses include:

Vary: Accept-Encoding, X-API-Key

Accept-Encoding ensures proper caching when content is compressed (gzip, brotli). X-API-Key partitions the shared cache by tenant (tenant identity comes from the API key, not the URL), and X-Preview-Key (on page responses) keeps preview and public responses on separate cache entries.

Cache Strategy Recommendations

For Static Site Generators (Next.js, Gatsby, Hugo):

  1. Fetch at build time
  2. Cache in CDN with long TTL (1 hour+)
  3. Revalidate periodically (ISR in Next.js)
  4. Use stale-while-revalidate for instant responses

For Client-Side Apps (React, Vue, Angular):

  1. Fetch on demand
  2. Use ETag for conditional requests
  3. Implement client-side cache (SWR, React Query)
  4. Respect Cache-Control headers

For Server-Rendered Apps (Nuxt, Next.js SSR):

  1. Fetch on each request
  2. Pass through cache headers to client
  3. Implement server-side cache (Redis, Memcached)
  4. Use conditional requests with If-None-Match

Copyright © 2026