Contents · Start

Docs / Start

Public API

This guide describes the routes that serve published content and public assets: URLs, query parameters, response and error shapes, and cache headers. It is for frontend developers reading EdgeCMS content without admin credentials.

Public routes return published content only, scoped to one tenant. They are read-only and need no authentication.

Routes

Use the tenant-scoped routes:

GET /api/tenants/:tenantSlug/public/:collectionSlug
GET /api/tenants/:tenantSlug/public/:collectionSlug/:entryIdOrSlug
GET /api/tenants/:tenantSlug/public/singleton/:collectionSlug
GET /api/tenants/:tenantSlug/public/assets/:assetId
GET /api/tenants/:tenantSlug/public/assets/:assetId/:variant
GET /api/tenants/:tenantSlug/public/assets/:assetId/:variant/:format

The Worker rewrites /api/tenants/:tenantSlug/public/* to /api/tenants/:tenantSlug/api/public/* before routing, so both forms work. The examples and smoke tests use the /api/public form.

Global routes without a tenant also exist for single-tenant deployments:

GET /api/public/:collectionSlug
GET /api/public/:collectionSlug/:entryIdOrSlug
GET /api/public/singleton/:collectionSlug
GET /api/public/assets/:assetId
GET /api/public/assets/:assetId/:variant
GET /api/public/assets/:assetId/:variant/:format

If a list route finds no collection with that slug, it tries a singleton with the same slug before returning 404.

Query parameters

ParameterRoutesBehavior
localeAll content routesLocale to read. Defaults to en.
pageList1-based page number. Defaults to 1.
perPageListPage size. Defaults to 20, maximum 100.
sortListComma-separated fields; prefix with - for descending, for example sort=-createdAt,slug.
filter[field] or filter[field][operator]ListFilters. See Filters.
populateEntryRelation fields to populate.
depthEntryRelation population depth, 1 to 3. Defaults to 1.

Filters

Top-level fields you can filter on: slug, createdAt, updatedAt, status. Custom fields use the path data.<field>, for example filter[data.category]=news. Any other field returns a validation error, so you cannot probe arbitrary columns.

Operators: eq (default), neq, gt, gte, lt, lte, contains, in, not_in, exists.

/api/tenants/smoke/api/public/posts?filter[data.category]=news&filter[createdAt][gte]=2026-01-01&sort=-createdAt

Limits:

  • At most 10 filter conditions per request.
  • in and not_in take a comma-separated list of at most 50 values.
  • contains values are at most 100 characters. A collection can restrict which data.* fields accept contains.
  • status only accepts published. filter[status]=draft (or review, scheduled, archived) returns Public status filters only support published content.

Sortable fields: createdAt, updatedAt, slug, and data.<field>. Prefer the top-level fields; sorting on data.* cannot use an index.

Responses

A successful content read returns the payload directly, with no envelope. Errors are simple objects:

json
{ "error": "Entry not found" }

Validation errors add details:

json
{
  "error": "Invalid filter parameters",
  "details": ["Field \"id\" is not filterable."]
}

The response shape is not versioned yet. Parse pagination metadata defensively and treat the smoke response for your collection as the reference for the current list shape.

Errors

CaseResponseCacheWhat to do
Not found{ "error": "Entry not found" }no-storeRender a 404 or fallback content.
Invalid filters400, { "error": "Invalid filter parameters", "details": [...] }no-storeRemove the unsupported filter. Do not retry unchanged.
Non-published status filterValidation errorno-storeUse a server-side preview flow instead.
Wrong tenantNot found or an authorization error, depending on the routeno-storeCheck the tenant slug.
Tenant pending or disabled403 TENANT_UNAVAILABLESee Hosting seams.
Host public ABAC deny (opt-in)403, { "error": "Attribute policy denied", "code": "ABAC_DENIED", "receipt": {…} } plus x-edgecms-policy-decision / x-edgecms-policy-receiptno-storeSee Public-plane denial receipt. Default hosts leave this off.

Cache headers

Successful reads set:

Cache-Control: public, max-age=<browser ttl>, stale-while-revalidate=300, stale-if-error=86400
CDN-Cache-Control: public, max-age=<cdn ttl>, stale-while-revalidate=3600, stale-if-error=86400
Cache-Tag: collection:<slug>,locale:<locale>[,entry:<entry id or slug>]
Vary: Accept-Encoding

Misses and validation failures set:

Cache-Control: no-store
CDN-Cache-Control: no-store

Public read cache entries are kept per tenant, collection, requested locale and filter hash, and hold published content only.

Revalidating your frontend

Use signed webhooks to refresh frontend caches after a publish. Each delivery carries x-edgecms-signature and x-webhook-signature, both sha256=<hex digest>: an HMAC-SHA256 of the raw JSON body.

Destinations must use HTTPS on port 443, contain no credentials, and not point at a local or private host. If WEBHOOK_ALLOWED_HOSTS is set, the host must match it.

Publish events carry frontend paths and cache tags in their metadata:

json
{
  "event": "entry.published",
  "data": {
    "after": { "id": "entry-1", "slug": "hello-world" },
    "metadata": {
      "syncChannel": "global",
      "revalidate": {
        "paths": ["/blog/hello-world"],
        "tags": ["collection:posts", "entry:entry-1", "locale:en"]
      }
    }
  }
}

A webhook dry run checks tenant scope, the destination policy and signing, then records a delivery without sending it. See Webhooks and Frontend integration.

Locales

Request a specific locale and handle missing localized content yourself. The public API does not fall back to another locale.

The shared schema package defines a fallback policy shape (requestedLocale, fallbackLocale, fallbackEnabled), a resolver for it, and a locale completeness matrix: required fields plus missing fields per locale, where any missing required field marks the locale incomplete. No public route or admin screen uses these yet. If fallback behavior is added, the public route tests and this guide change together.

Relations and repeatable blocks

Relation population is opt-in: pass populate with relation field names and keep depth low. Related entries can be missing because they are unpublished, belong to another tenant, or were left out by the public route. Treat a missing relation as absent content, not as a reason to retry. Population is not recursive for every relation shape.

Repeatable, block-like content uses array fields with itemFields, optional minItems and optional maxItems. This is a structured field, not a full component or dynamic-zone system.

Field types

Field typeStored asNotes for frontends
textTextFilter with filter[data.fieldName].
richtextRich text payloadSanitize before rendering.
numberNumberAvoid string comparisons in filters.
booleanBooleanTreat a missing value differently from false.
dateSerialized date stringHandle time zones explicitly.
mediaAsset reference and metadataLoad files from public asset routes, not admin media routes.
relationRelation reference, populated on requestUse populate and depth.
jsonArbitrary JSONValidate it at runtime in your app.
arrayRepeatable values or blocksItem shape comes from itemFields.

There are no workflow fields and no field types loaded from untrusted code. Plugins can add field types only through the trusted Plugin SDK. See Content model for how fields are defined.

Field options

Each field has type-specific options. Developer Export copies them, and the admin and API interpret them. Relation field:

json
{
  "name": "author",
  "type": "relation",
  "required": false,
  "localizable": false,
  "options": {
    "targetCollectionId": "authors",
    "relationType": "one-to-many"
  }
}

Repeatable array field:

json
{
  "name": "blocks",
  "type": "array",
  "required": false,
  "localizable": false,
  "options": {
    "itemFields": [
      { "name": "label", "type": "text", "required": true, "localizable": false }
    ],
    "minItems": 0,
    "maxItems": 12
  }
}

options is metadata, not an extension mechanism. Validate new option shapes in code before relying on them.

Public assets

Asset routes serve files from R2 for published assets. They accept optional image transform parameters:

ParameterValues
w or width1 to 4096
h or height1 to 4096
fitscale-down, contain, cover, crop, pad
format or fauto, webp, avif, jpeg, png, json

With transform parameters, the Worker asks Cloudflare image resizing for the result. If that is unavailable (for example in local development), it serves the stored file instead. The x-image-transform response header is cf-edge, fallback or none. Transform cost, cache keys and tenant isolation for resized images have no dedicated tests yet. See Media.

The shared schema package also defines media contracts that no public route uses yet:

  • Alt text, optional caption and optional credit per tenant asset.
  • Focal point as normalized x and y values from 0 to 1.
  • Tenant upload policy with maximum bytes and allowed MIME types.
  • Public asset cache headers with browser and CDN max-age and immutable caching.
  • A signed private asset access policy with tenant, asset, expiry and a signing secret reference. There is no public route for private assets.

Preview and drafts

Draft preview routes are not public. Resolve drafts in server code through an authenticated admin or API flow, and never put preview secrets in a browser bundle.

POST .../public/preview-token signs short-lived preview token metadata (tenant, entry, collection, preview URL; TTL 60 seconds to 7 days, default 1 hour) with PREVIEW_SECRET, and POST .../public/preview-token/verify checks one. No route returns draft content for these tokens yet. A suspended tenant cannot issue them.

API tokens for browsers

There is no browser-safe content API token. Use public routes for anonymous published reads and server-side admin or auth flows for anything protected.

  • Token metadata can be used by backend and admin flows, but its secrets must stay out of browser code.
  • Never use admin cookies, preview secrets or server-only tokens as public content keys in browser apps.

See Auth and access.

Roles

Admin access is role-based. Tenant context is required for admin mutations, and a role on one tenant cannot change another tenant. Public content and asset routes stay read-only for every role.

RolePublic content and assetsCollectionsEntriesAssetsTrusted plugin panelsSettings
viewerRead publishedReadReadReadRead installed stateNo
editorRead publishedReadChangeChangeRead installed stateNo
adminRead publishedChangeChangeChangeManage trusted plugin stateLimited tenant admin
ownerRead publishedChangeChangeChangeManage trusted plugin stateTenant owner

This matrix is enforced by shared helpers and tests. There is no generated role editor in the admin.

Editorial workflow

Only published entries are publicly readable. draft, review, scheduled and archived entries are not.

The shared schema package defines tenant-scoped request shapes for:

  • Duplicating an entry.
  • Saving a draft with an optimistic version number.
  • Server-only preview token metadata.
  • Review assignments.
  • Scheduled publish and unpublish windows. An unpublish time must be after the publish time.
  • Restoring a version, and diffing two versions. Both name explicit versions.

Workflow rules in the helpers: publishing is valid from review or scheduled, and a published entry can only move to archived.

Draft saves use optimistic versioning: the client sends its base version, and the server rejects the save when the server draft is newer. The autosave helper records tenant slug, entry id, client version, server version and an accepted or conflict result. The admin entry form saves dirty drafts in the background every 30 seconds and warns when the server draft is newer.

There is no release calendar and no bulk publish or unpublish of a release. A release can be planned as a set of reviewed entries with schedule windows and explicit restore or diff steps.

Content model handoff

Developer Export in the admin produces deterministic TypeScript from a collection. Use it for code review, starter or plugin examples, and schema discussions. It is not an import API, a migration runner or a deploy step.

  • Review exported configs before changing the backend schema.
  • Check field types, relation options, localization and singleton settings against what the API supports.
  • Add tests for the public response shape before your frontend depends on new fields.
  • An export does not mean production content was migrated.
  • Import preview is read-only. It reports additions, changes and removals but never applies migrations or deletes content.

REST, GraphQL and OpenAPI

REST is the primary public interface, and these docs and examples use it.

A read-only GraphQL endpoint is also available:

GET  /api/graphql
POST /api/graphql
GET  /api/tenants/:tenantSlug/graphql
POST /api/tenants/:tenantSlug/graphql

The schema is generated per tenant from its collections: each collection gets a list field (locale, page, perPage up to 100, sort) and a single-entry field (id or slug, locale). Resolvers return published entries only. Queries are limited to depth 3 and complexity 50. A GET with no query (or with Accept: text/html) returns the GraphiQL playground; open it to see the exact field names. Its feature set does not match other CMS GraphQL APIs.

Locale delivery matches the REST public path: successful content queries expose _localeProvenance / _localeChain on entries and emit X-Content-Locale-Resolved / X-Content-Locale-Provenance (same shapes as Atomic Edge Promote).

Published OpenAPI 3 for the public and admin route inventory lives at openapi.json. Key request and response payloads are also exported as JSON Schema under schemas/. Regenerate both with bun run generate:api-contracts; bun run check:openapi fails CI when inventory routes and the committed OpenAPI paths drift.

The Worker also serves Elysia's interactive docs at /api/docs (JSON at /api/docs/json). That live document reflects routes that declare TypeBox schemas at runtime. The committed OpenAPI inventory is the contract gate for public and admin path coverage; per-route response field completeness is not claimed.

Testing your integration

Before you rely on a public route in production:

  1. Create a draft through the admin or API, publish it, then fetch it through the public API.
  2. Check that tenant A cannot read tenant B content or tenant B cache snapshots.
  3. Check that drafts are hidden from public reads.
  4. Check locale behavior and that cache keys differ per locale.
  5. Check the Cache-Control, CDN-Cache-Control and Cache-Tag headers.
  6. Check the error shape and the no-store headers on misses and validation failures.

To time public reads locally:

bash
bun run perf:public-read:local -- --path /api/tenants/smoke/api/public/smoke-posts/<entry-slug> --expected-kind detail

This reports p50, p95 and pass or fail for your local machine only. To measure a deployed Worker, run perf:public-read:remote with EDGE_CMS_URL set to its URL.

Edit docs/public/public-api.md on GitHub