Content API
The read-only JSON content API at /api/content/v1: turning it on, publishable and secret keys and their capabilities, CORS, every operation and parameter, the document representation and each field type's delivered shape, queries with filters, facets and cursors, caching headers and cache tags, every error code, and the rate limits.
The content API delivers a site’s published content as JSON, for a frontend you build yourself (see Headless sites), a static site generator, a mobile app or a sync job. It is read-only: it answers what an anonymous visitor to the site may see, never drafts (except through a preview token), never CRM records, and nothing a key’s holder could not see on a rendered page. Its only writes are form posts and analytics events, each behind a capability.
engine-cms serves the same API.
A first request
Turn it on in engine.yaml (it is off by default):
# config/engine.yaml
server:
api:
content:
enabled: true Issue a key for the site. The value is printed once:
engine-dmo api keys create --name "Astro frontend (production)" --kind secret --site main --project . Read with it, sending the key as a bearer token:
curl -H "Authorization: Bearer $MERIDIAN_KEY" \
"https://cms.example.org/api/content/v1/sites/main/resolve?path=%2Fwhats-on%2Fsummer-fair" The answer to resolve tells the frontend what the path is (a document, an index, a redirect, gone or not found), with the document’s representation and the page’s metadata. Headless sites walks through building a frontend on it.
Configuration
Key in engine.yaml | Default | Limits | Meaning |
|---|---|---|---|
server.api.content.enabled | false | Serves /api/content/v1/. A headless site with it off is a check error. | |
server.api.content.public_reads | false | true serves a published read that carries no key as the anonymous visitor, rate limited per address, with CORS for the site’s frontend origin only. A key, when sent, is checked as always; preview, events, forms and the change feed always need a key. | |
server.api.content.cache.s_maxage | 60s | 0s to 24h | How long a shared cache (a CDN) may serve a published response. |
server.api.content.cache.stale_while_revalidate | 10m | 0s to 168h | How long past that a cache may serve it while fetching a fresh one. |
server.api.content.cache.tag_header | Cache-Tag | Cache-Tag, Surrogate-Key or none | The response header the cache tags travel in. |
server.api.content.preview_ttl | 30m | 1m to 2h | A preview token’s life. |
server.api.content.change_retention | 90d | 1d to 400d | How far back the change feed reaches. |
The API answers on any host that reaches the engine. You may give it a host of its own (such as api.example.org) so a CDN can cache and limit it without path rules.
Keys
| Publishable | Secret | |
|---|---|---|
| Prefix | mrd_pub_ | mrd_sec_ |
| Lives in | Browser code, an app, a static build | The frontend’s server, and nowhere else |
| Reads published content | Yes | Yes |
Capability preview (redeems preview tokens) | Not allowed | Optional |
Capability events (posts analytics events) | Not allowed | Optional |
Capability forms (form descriptions and posts) | Optional, from an allowed origin | Optional, forwarding the visitor’s address |
| Reads the change feed | No | Yes |
| CORS | For its listed origins only | Never: a request with an Origin header is refused and the key is marked exposed |
| Rate limited | Per key and per visitor address | Per key |
- Sent as
Authorization: Bearer <key>, never in a query string. - A key names the sites it reads. A request for another site is
key_not_for_site. - Shown once. The engine keeps only a hash; lists show the prefix and the last four characters.
- Rotation needs no downtime. Any number of keys can be live for a site, each with an optional expiry.
rotateissues a new key like an existing one and keeps the old one working for a grace period. - An exposed secret key (one sent from a browser with an
Originheader) is refused, marked exposed inapi keys listand the admin, and reported inengine-dmo doctor. It is not revoked automatically: rotate it. - CORS. A publishable key’s origins are its allowlist; a key with none allows the frontend origin of each headless site it reads, and nothing else. An allowed origin gets
Access-Control-Allow-Originnaming it, withETag,RateLimit,RateLimit-PolicyandRetry-Afterexposed. Every site operation answersVary: Origin. A preflight (OPTIONS) answers 204 for an allowed origin withGET, HEAD(andPOSTwithContent-Typefor a form), theAuthorizationandIf-None-Matchheaders, and a ten minuteAccess-Control-Max-Age.Meridian-Previewis never allowed from a browser. - A key carries no person. It cannot reach any staff or member capability.
Keys are managed by staff holding api.keys.manage (seeing them needs api.keys.view), in the admin under Settings, API keys (<admin_path>/api/keys) or from the command line. Each change is audited with the key’s id, never its value.
engine-dmo api keys create --kind publishable --name "Site search widget" --site main \
--capability forms --origin https://www.example.org --expires 2160h --project .
engine-dmo api keys list [--site main] --project .
engine-dmo api keys rotate --id <key-id> --grace 24h --project .
engine-dmo api keys revoke --id <key-id> --confirm --project . Flag of create | Meaning |
|---|---|
--kind | publishable or secret. |
--name | What the key is for. |
--site | A site the key reads; repeat or comma separate for several. |
--capability | preview (secret keys), events (secret keys) or forms; reading published content is implied. |
--origin | A publishable key’s allowed browser origin; repeat for several. None allows each headless site’s frontend origin. |
--expires | A duration (2160h) or a time (2027-01-01T00:00:00Z); never when absent. |
rotate --grace is how long the old key keeps working (default 24h; 0 ends it now). revoke needs --confirm; requests with a revoked key answer invalid_key.
Operations
Every path is under /api/content/v1. Every read takes locale (a locale the site enables; the site’s default when absent, invalid_parameter for any other).
| Method and path | Answers | Notes |
|---|---|---|
GET /openapi.json | The OpenAPI 3.1 contract, the install’s types included | No key; cacheable |
GET /sites/{site} | The site: key, name, delivery_mode, origin, default_locale, locales with their fallback chains, direction and prefix, routes with their URI patterns and index paths, singletons by type (ids), navigation trees in the locale, SEO defaults, robots rules | |
GET /sites/{site}/schema | JSON Schema (draft 2020-12) of each of the site’s types as delivered | Generate typed clients from it |
GET /sites/{site}/resolve?path= | A path’s outcome, as the rendered site would resolve it | See Resolve |
GET /sites/{site}/documents/{id} | One published document | expand, fields, occurrences_window |
GET /sites/{site}/documents/{type}/{slug} | One document of a type by its slug in the locale, or down the site’s locale chain | As above |
GET /sites/{site}/singletons/{type} | The site’s one document of a singleton type | 404 when the type is not a singleton on the site, or nothing is published in the locale |
GET /sites/{site}/documents?type= | A query over published documents | See Queries |
GET /sites/{site}/curations/{id} | A guide’s items, or a page of a collection’s list, as summaries | page |
GET /sites/{site}/lists/{key} | A public smart list as the site sees it | A list that is not public, or another site’s, is not_found |
GET /sites/{site}/search?q= | The search service | See Search |
GET /sites/{site}/tags | The public tag vocabulary as a tree, labelled in the locale | |
GET /sites/{site}/sitemap | Sitemap entries, 1,000 a page: every published, indexable, unredirected URL with its alternates and images | locale, cursor |
GET /sites/{site}/redirects | The compiled redirect rules in the order they are tried, for an edge function | resolve applies them anyway |
GET /sites/{site}/strings | The interface strings of config/strings resolved down the locale’s chain, plural forms intact | |
GET /sites/{site}/preview | The document a preview token names, with its working revisions | Secret key with preview; see Headless sites |
GET, POST /sites/{site}/forms/{key} | A declared public post endpoint’s form description, and its submission | Key with forms; see Headless sites |
POST /sites/{site}/events | Analytics events from the frontend’s server | Secret key with events; see Headless sites |
GET /sites/{site}/changes?since= | The change feed | Secret key; see Headless sites |
engine-dmo api openapi prints the generic contract from this binary; with --types, the document /openapi.json serves, your types included. engine-dmo api schema --site <key> [--format jsonschema|openapi] prints a site’s types alone.
Resolve
GET /api/content/v1/sites/main/resolve?path=%2Fja%2Fwhats-on%2Fsummer-fair | Parameter | Meaning |
|---|---|
path | The visitor’s request path with its query string, URL-encoded as one parameter, so facets, page and sort resolve as on a rendered site. |
host | The request host, for a site whose locales are by domain. |
record | false records no miss in the 404 report: for builds and link checkers. |
expand, fields, occurrences_window | As for a document. |
The locale comes from the path by the site’s strategy. Redirects are matched first, in the rendered site’s precedence. The answer is always HTTP 200; what to send the visitor is in the body:
outcome | status to send | Carries |
|---|---|---|
document | 200 | document, page, analytics |
index | 200 | type, items (summaries), pagination, facets, page |
redirect | 301, 302, 307 or 308 | location, and the origin of the rule that matched (or the engine’s own answer: auto_case, auto_segment) |
gone | 410 | page |
not_found | 404 | Nothing; the miss is recorded in the 404 report unless record=false |
Every answer also carries site, path and locale.
page is the page metadata the rendered site’s head() and jsonld() would print:
| Key | Meaning |
|---|---|
title, description | The title (with the site’s seo.title_template applied) and description. |
canonical | The canonical URL, for paginated and filtered pages too. |
robots | Such as index, follow; noindex on staging and past the facet depth. |
content_language | The page’s language. |
alternates | {hreflang, url} for each locale published on the site, and x-default. |
open_graph | title, description, type, url, site_name, locale and image (url, width, height, alt). |
twitter_card | The card type. |
breadcrumbs | {title, url} from the root. |
prev, next | Pagination links, or null. |
json_ld | The page’s structured data as one @graph. |
analytics is {view, collect}: a signed view token for the document and the path to report it to. See Headless sites.
engine-dmo api resolve <path> [--site key] [--host host] --project . prints the same answer from the command line, recording no miss.
The document representation
{
"id": "0191f0c2-7a51-7c3e-9d0a-5b2f6c1e4a88",
"type": "event",
"variant": null,
"site": "main",
"locale": "en-GB",
"requested_locale": "en-GB",
"fallback": false,
"uri": "/whats-on/summer-fair",
"url": "https://www.example.org/whats-on/summer-fair",
"slug": "summer-fair",
"title": "Summer fair",
"published_at": "2026-08-14T09:12:00Z",
"updated_at": "2026-09-02T16:40:11Z",
"translations": {
"ja-JP": { "uri": "/ja/whats-on/summer-fair", "url": "https://www.example.org/ja/whats-on/summer-fair" }
},
"tags": [ { "key": "family", "label": "Family", "kind": "topic", "parent": null } ],
"fields": {
"summary": "Stalls, music and a dog show on the green.",
"location": { "lat": 52.0629, "lon": -1.3398, "precision": "exact" },
"ticketPrice": { "amount": "5.00", "currency": "GBP", "formatted": "£5.00" }
}
} - Envelope keys are snake_case. Field values are under
fieldsby the schema’s own field names, so a field never collides with an envelope key. localeis the language the content is in;requested_localewhat was asked for.fallbackis true when the whole document fell back along the site’s locale chain.translationslists the other locales published on this site.tagsare public vocabulary tags only, labelled in the response locale.- The representation carries no organisation, no parent document and no analytics impression or click tokens. On
engine-dmo, the member report’s referral figures for a listing delivered through the API come only from the events the frontend reports with theresolveanswer’s analytics token (see Analytics); the tracked member links a rendered page writes are not available. variantis the entry variant where the type declares variants, andfieldsholds that variant’s fields.- Site overrides are applied for the site in the path.
- Every field of the type is present under
fields,nullwhen empty, and every key of a delivered object is present,nullwhen it has no value: test values, not keys.
Field values
| Field type | Delivered as |
|---|---|
text, slug | A string. |
number | A number. |
boolean | A boolean. |
date | "YYYY-MM-DD". |
datetime | {iso, zone, local, all_day}: RFC 3339 UTC, the IANA zone, the wall clock as YYYY-MM-DDTHH:MM:SS, and whether it is a whole day. |
select | {value, label} with the label in the locale, or a list of them. |
portable_text | {blocks, plain}: Portable Text JSON (no HTML), and the plain text for descriptions and snippets. A link annotation is resolved as a link field is; a mention is reduced to {_key, _type: "mention"}; an object block is {_type, _key, ...fields}, each field delivered by its own type. |
relation | A summary {id, type, title, slug, uri, url, locale}, or a list. A target not visible on the site in the locale is null, or left out of a list; slug, uri and url are null for a target with no route on the site. Expanded, the full representation. |
asset | An image: {id, kind, url, mime, alt, alt_lang, decorative, caption, caption_lang, credit, width, height, focal, hotspot, crop, preview, dominant, transforms}. transforms holds each of the site’s configured image transforms as {width, height, src, srcset: [{url, width, format}]}; there are no size parameters. preview is a blur placeholder as a data URI and dominant the colour as #rrggbb. A document, audio or video file is {id, kind, url, filename, bytes, mime}. A list with multiple: true. |
link | {kind, of, url, uri, label, title, aria_label, new_tab, rel, style, external, live, document, asset, tracked, tracked_url}, or a list. url is the address to write; uri the site-relative path for a router. A link whose target is not live is null, or left out of a list. tracked and tracked_url are always null in this release, including for a member’s destination link. |
video | {id, source, url, watch_url, embed_url, title, poster, duration, start, captions, transcript}. |
geo | {lat, lon, precision}. |
geo_shape | {geometry, bbox, centroid, area_km2}: GeoJSON geometry, bbox as [west, south, east, north]. |
address | {lines, one_line, country, country_name, components, geo}. |
phone | {e164, national, international, display, region, type, tel_href}; a number that does not parse keeps what was typed as display, with e164 and tel_href null. |
opening_hours | {timezone, by_appointment, note, days, rules, valid_until}. days is the next 14 days, resolved through seasons, exceptions and holidays: {date, closed, source, note, periods: [{opens, closes, opens_at, closes_at}]}, wall times and UTC instants both, so “open now” is a comparison. valid_until is the next local midnight. |
recurrence | {rule, occurrences, valid_until}: the next 90 days of occurrences (more with occurrences_window), cancelled and moved ones included with their status and note. |
money | {amount, currency, formatted}: the amount as a decimal string, the ISO 4217 currency, and the amount written for the locale. |
seo | {title, description, og_title, og_description}. |
repeater | A list of objects, each with _key and its fields. |
blocks | A list of {_type, _key, ...fields}; a block the field no longer declares is left out. |
The exact schema of every shape is in engine-dmo api openapi under components.schemas (Field.<type>), and your own types’ in engine-dmo api schema.
Expansion and sparse fields
expand=area,organiserreplaces the named relations with full representations, to two levels (expand=area.parent) and at most 100 expanded documents per response. An expanded document obeys the same visibility.fields=title,heroImage,area.titlelimitsfields, inside expansions too. The envelope is always present. A name that is not a field isinvalid_parameternaming it.occurrences_window=180ddelivers that many days of recurrence occurrences (default 90), up to the range the engine has materialised.
Queries
GET /api/content/v1/sites/main/documents
?type=event
&locale=en-GB
&tag=family
&where[free]=true
&where[startDate][gte]=2026-11-01
&near=52.0629,-1.3398&radius=10
&order=distance
&per_page=24&page=2
&facets=category
&fields=title,startDate,heroImage | Parameter | Meaning |
|---|---|
type | Required; may repeat. |
tag | A public tag’s key, or key.* for it and every tag below; may repeat, and each must match. |
where[field]=value, where[field][op]=value | A filter on an index: true field. op is eq (the default), ne, in (comma separated), gt, gte, lt, lte or exists (true or false). A field that is not indexed is unindexed_filter, naming the field. |
near, radius | A point lat,lon over the type’s indexed geo field, and kilometres (10 by default). |
order | title, published_at, -published_at, updated_at, distance (with near), or an indexed field; - for descending. The type’s declared ordering by default. |
page, per_page | Numbered pages: per_page is 24 by default and at most 100; numbered pages reach the first 2,000 results. |
cursor | Iterates the whole set by id in a stable order with no total: empty to begin, then each answer’s next_cursor. Use it for a build or a sync. |
facets | Indexed fields to count values of over the filtered set, comma separated. |
fields, expand, occurrences_window | As for a document. |
The answer is {items, pagination, facets, next_cursor}: items are full representations, pagination is {page, pages, total, per_page} (null when iterating by cursor), and facets maps each field to its values with counts. Only published documents of types on the site, available in the locale with its fallback, are ever returned.
Search
GET /sites/{site}/search calls the site’s search service, with its query log, visibility and facets. Parameters: q (below the configured minimum length it is browse), type and tag (each may repeat), near and radius, sort (relevance, name, distance or recent), page and per_page (20 by default, at most 100). The answer is {query, sort, backend, degraded, total, items, pagination, facets}; each item is {id, type, title, summary, uri, url, tags, position, distance, highlight}. degraded: true means the search backend is down and the answer is browse mode, so the frontend can say so.
Caching
- Published responses carry
Cache-Control: public, max-age=0, s-maxage=<s_maxage>, stale-while-revalidate=<stale_while_revalidate>and a strongETag; a request with a matchingIf-None-Matchis answered 304. They do not vary by key (the site is in the path), so a CDN may cache them despite theAuthorizationheader. They vary byOrigin. - Cache tags in the configured header (
Cache-Tag, comma separated, orSurrogate-Key, space separated) name what the response holds:site:<key>,type:<key>,doc:<id>, and the like. The change feed and the frontend webhooks carry the tags to purge. - Time-bounded values: a response holding
opening_hoursdays orrecurrenceoccurrences capss-maxageat its earliestvalid_until, so a cached week of opening times is never served past the midnight it describes. - Preview, form, events and change feed responses are
private, no-store.
Errors
Every failure is an RFC 9457 problem with Content-Type: application/problem+json:
{
"type": "urn:meridian:content-api:problem:unindexed_filter",
"code": "unindexed_filter",
"title": "...",
"status": 400,
"detail": "...",
"parameter": "where[price]"
} code | Status | When |
|---|---|---|
invalid_key | 401 | Missing, unknown, expired or revoked key |
key_exposed | 401 | A secret key sent with an Origin header |
key_not_for_site | 403 | The key does not name the site |
origin_not_allowed | 403 | A publishable key from an origin it does not allow, or a disallowed preflight |
capability_missing | 403 | Preview, events, forms or the change feed without the capability or key kind |
preview_expired | 401 | A preview token past its life, or its staff session has ended |
preview_invalid | 401 | A preview token altered, from another install, or for another site |
unknown_site | 404 | No such site |
not_found | 404 | A document, list or form that does not exist or is not visible, answered alike |
unindexed_filter | 400 | A filter or order on a field without index: true |
invalid_parameter | 400 | A bad parameter, named in parameter; unknown names in fields and expand included |
limit_exceeded | 400 | per_page, the 2,000 result window, or the expansion ceiling |
validation_failed | 422 | A form post’s input, with field_errors as {path, code, message} |
rate_limited | 429 | With Retry-After and RateLimit headers |
internal | 500, 503 | The engine could not answer; 503 when the key store could not be read, which a client retries |
Rate limits
| Surface | Limit |
|---|---|
| Publishable key | 1,200 weighted requests a minute per key, and 120 a minute per visitor address |
| Secret key | 6,000 weighted requests a minute per key |
Keyless reads under public_reads | Per visitor address |
| Weights | A query 2, a search 5, every other operation 1; facets add 1; each level of expand adds 1 |
| Events | 20 requests a second per key |
| Form posts | The endpoint’s own rate_limit, keyed on the visitor |
| Failed key authentication | 20 an hour per address, then refused before the key is looked up |
| Preview token redemption | 600 an hour per token |
Rate limits are operational and fail open if they cannot be counted, except failed authentication, which fails closed.
Versioning
The path carries the major version, /api/content/v1/. Changes within a version are additive: new operations, parameters, envelope keys and field shape keys may appear, so a client must ignore keys it does not know. info.version in the OpenAPI document names the contract’s revision. Your own types are part of the contract: removing or renaming a field of a type a frontend reads changes what the API delivers, so plan schema changes with the frontend.
Static export
engine-dmo export content --site <key> --out ./content --project . writes the API’s own answers to disk for a static build: site.json, strings/<locale>.json, sitemap.json, redirects.json, pages/<path>.json (the resolve outcome of every index and every published path in every enabled locale, the root as pages/index.json) and manifest.json, which records the change feed’s cursor when the export began. --since <cursor> writes only what changed after a previous export’s cursor, removes the files of paths that went away and lists them in deleted.json; a cursor older than the change retention fails and asks for a full export. Images are not exported: they stay on the engine’s (or your CDN’s) origin.
Integrating
How other systems reach a Meridian site: the read-only content API for headless frontends and builds, outbound webhooks, upstream data sources and scheduled feed imports, the content MCP for AI agents, the member API for a custom portal, and moving content in from WordPress or Sanity.
Headless sites
How to run a site's public pages from a frontend you build (Astro, Next.js, Nuxt or a static generator) on the content API: delivery.mode headless in site.yaml and what check enforces, the resolve loop, preview from the editor, revalidation webhooks and the change feed, forms, analytics collection, and static export.