Meridian handbook
Integrating

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.yamlDefaultLimitsMeaning
server.api.content.enabledfalseServes /api/content/v1/. A headless site with it off is a check error.
server.api.content.public_readsfalsetrue 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_maxage60s0s to 24hHow long a shared cache (a CDN) may serve a published response.
server.api.content.cache.stale_while_revalidate10m0s to 168hHow long past that a cache may serve it while fetching a fresh one.
server.api.content.cache.tag_headerCache-TagCache-Tag, Surrogate-Key or noneThe response header the cache tags travel in.
server.api.content.preview_ttl30m1m to 2hA preview token’s life.
server.api.content.change_retention90d1d to 400dHow 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

PublishableSecret
Prefixmrd_pub_mrd_sec_
Lives inBrowser code, an app, a static buildThe frontend’s server, and nowhere else
Reads published contentYesYes
Capability preview (redeems preview tokens)Not allowedOptional
Capability events (posts analytics events)Not allowedOptional
Capability forms (form descriptions and posts)Optional, from an allowed originOptional, forwarding the visitor’s address
Reads the change feedNoYes
CORSFor its listed origins onlyNever: a request with an Origin header is refused and the key is marked exposed
Rate limitedPer key and per visitor addressPer 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. rotate issues 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 Origin header) is refused, marked exposed in api keys list and the admin, and reported in engine-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-Origin naming it, with ETag, RateLimit, RateLimit-Policy and Retry-After exposed. Every site operation answers Vary: Origin. A preflight (OPTIONS) answers 204 for an allowed origin with GET, HEAD (and POST with Content-Type for a form), the Authorization and If-None-Match headers, and a ten minute Access-Control-Max-Age. Meridian-Preview is 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 createMeaning
--kindpublishable or secret.
--nameWhat the key is for.
--siteA site the key reads; repeat or comma separate for several.
--capabilitypreview (secret keys), events (secret keys) or forms; reading published content is implied.
--originA publishable key’s allowed browser origin; repeat for several. None allows each headless site’s frontend origin.
--expiresA 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 pathAnswersNotes
GET /openapi.jsonThe OpenAPI 3.1 contract, the install’s types includedNo 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}/schemaJSON Schema (draft 2020-12) of each of the site’s types as deliveredGenerate typed clients from it
GET /sites/{site}/resolve?path=A path’s outcome, as the rendered site would resolve itSee Resolve
GET /sites/{site}/documents/{id}One published documentexpand, 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 chainAs above
GET /sites/{site}/singletons/{type}The site’s one document of a singleton type404 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 documentsSee Queries
GET /sites/{site}/curations/{id}A guide’s items, or a page of a collection’s list, as summariespage
GET /sites/{site}/lists/{key}A public smart list as the site sees itA list that is not public, or another site’s, is not_found
GET /sites/{site}/search?q=The search serviceSee Search
GET /sites/{site}/tagsThe public tag vocabulary as a tree, labelled in the locale
GET /sites/{site}/sitemapSitemap entries, 1,000 a page: every published, indexable, unredirected URL with its alternates and imageslocale, cursor
GET /sites/{site}/redirectsThe compiled redirect rules in the order they are tried, for an edge functionresolve applies them anyway
GET /sites/{site}/stringsThe interface strings of config/strings resolved down the locale’s chain, plural forms intact
GET /sites/{site}/previewThe document a preview token names, with its working revisionsSecret key with preview; see Headless sites
GET, POST /sites/{site}/forms/{key}A declared public post endpoint’s form description, and its submissionKey with forms; see Headless sites
POST /sites/{site}/eventsAnalytics events from the frontend’s serverSecret key with events; see Headless sites
GET /sites/{site}/changes?since=The change feedSecret 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
ParameterMeaning
pathThe visitor’s request path with its query string, URL-encoded as one parameter, so facets, page and sort resolve as on a rendered site.
hostThe request host, for a site whose locales are by domain.
recordfalse records no miss in the 404 report: for builds and link checkers.
expand, fields, occurrences_windowAs 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:

outcomestatus to sendCarries
document200document, page, analytics
index200type, items (summaries), pagination, facets, page
redirect301, 302, 307 or 308location, and the origin of the rule that matched (or the engine’s own answer: auto_case, auto_segment)
gone410page
not_found404Nothing; 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:

KeyMeaning
title, descriptionThe title (with the site’s seo.title_template applied) and description.
canonicalThe canonical URL, for paginated and filtered pages too.
robotsSuch as index, follow; noindex on staging and past the facet depth.
content_languageThe page’s language.
alternates{hreflang, url} for each locale published on the site, and x-default.
open_graphtitle, description, type, url, site_name, locale and image (url, width, height, alt).
twitter_cardThe card type.
breadcrumbs{title, url} from the root.
prev, nextPagination links, or null.
json_ldThe 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 fields by the schema’s own field names, so a field never collides with an envelope key.
  • locale is the language the content is in; requested_locale what was asked for. fallback is true when the whole document fell back along the site’s locale chain.
  • translations lists the other locales published on this site.
  • tags are 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 the resolve answer’s analytics token (see Analytics); the tracked member links a rendered page writes are not available.
  • variant is the entry variant where the type declares variants, and fields holds that variant’s fields.
  • Site overrides are applied for the site in the path.
  • Every field of the type is present under fields, null when empty, and every key of a delivered object is present, null when it has no value: test values, not keys.

Field values

Field typeDelivered as
text, slugA string.
numberA number.
booleanA 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.
relationA 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.
assetAn 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}.
repeaterA list of objects, each with _key and its fields.
blocksA 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,organiser replaces 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.title limits fields, inside expansions too. The envelope is always present. A name that is not a field is invalid_parameter naming it.
  • occurrences_window=180d delivers 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
ParameterMeaning
typeRequired; may repeat.
tagA public tag’s key, or key.* for it and every tag below; may repeat, and each must match.
where[field]=value, where[field][op]=valueA 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, radiusA point lat,lon over the type’s indexed geo field, and kilometres (10 by default).
ordertitle, published_at, -published_at, updated_at, distance (with near), or an indexed field; - for descending. The type’s declared ordering by default.
page, per_pageNumbered pages: per_page is 24 by default and at most 100; numbered pages reach the first 2,000 results.
cursorIterates 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.
facetsIndexed fields to count values of over the filtered set, comma separated.
fields, expand, occurrences_windowAs 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.

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 strong ETag; a request with a matching If-None-Match is answered 304. They do not vary by key (the site is in the path), so a CDN may cache them despite the Authorization header. They vary by Origin.
  • Cache tags in the configured header (Cache-Tag, comma separated, or Surrogate-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_hours days or recurrence occurrences caps s-maxage at its earliest valid_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]"
}
codeStatusWhen
invalid_key401Missing, unknown, expired or revoked key
key_exposed401A secret key sent with an Origin header
key_not_for_site403The key does not name the site
origin_not_allowed403A publishable key from an origin it does not allow, or a disallowed preflight
capability_missing403Preview, events, forms or the change feed without the capability or key kind
preview_expired401A preview token past its life, or its staff session has ended
preview_invalid401A preview token altered, from another install, or for another site
unknown_site404No such site
not_found404A document, list or form that does not exist or is not visible, answered alike
unindexed_filter400A filter or order on a field without index: true
invalid_parameter400A bad parameter, named in parameter; unknown names in fields and expand included
limit_exceeded400per_page, the 2,000 result window, or the expansion ceiling
validation_failed422A form post’s input, with field_errors as {path, code, message}
rate_limited429With Retry-After and RateLimit headers
internal500, 503The engine could not answer; 503 when the key store could not be read, which a client retries

Rate limits

SurfaceLimit
Publishable key1,200 weighted requests a minute per key, and 120 a minute per visitor address
Secret key6,000 weighted requests a minute per key
Keyless reads under public_readsPer visitor address
WeightsA query 2, a search 5, every other operation 1; facets add 1; each level of expand adds 1
Events20 requests a second per key
Form postsThe endpoint’s own rate_limit, keyed on the visitor
Failed key authentication20 an hour per address, then refused before the key is looked up
Preview token redemption600 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.