Responses & Caching
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:
| Field | Type | Description |
|---|---|---|
data | object/array | Response payload (array for lists, object for single) |
pagination | object | Pagination metadata (list endpoints only) |
redirect | object/null | Redirect 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:
| Field | Type | Description |
|---|---|---|
error.code | string | Machine-readable error code |
error.message | string | Human-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 browsermax-age=900: Fresh for 15 minutesstale-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 fetch | Behaviour |
|---|---|
| 0–15 min | Served from cache (fresh) |
| 15–45 min | Served 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):
- Fetch at build time
- Cache in CDN with long TTL (1 hour+)
- Revalidate periodically (ISR in Next.js)
- Use
stale-while-revalidatefor instant responses
For Client-Side Apps (React, Vue, Angular):
- Fetch on demand
- Use
ETagfor conditional requests - Implement client-side cache (SWR, React Query)
- Respect
Cache-Controlheaders
For Server-Rendered Apps (Nuxt, Next.js SSR):
- Fetch on each request
- Pass through cache headers to client
- Implement server-side cache (Redis, Memcached)
- Use conditional requests with
If-None-Match

