API

Delivery API

Read one space environment's content from a site with a delivery or preview token. Endpoints, locale and depth parameters, the entry shape, errors and caching.

12 min read

Read access to one space environment's content, for the site that shows it. Create a token under Settings → API tokens (in develop and staging an agent can create one with createApiToken; production tokens are made in the dashboard) and send it in the Obelism-Token header, or as a bearer token.

curl -H "Obelism-Token: cms_d_…" \
  "https://content.obelism.studio/api/delivery/v1/pages?path=blog/hello&locale=nl"

Both headers take every kind of token. Only Obelism-Token lets a delivery token's responses be answered from the edge cache: a request with Authorization always reaches the API. The JavaScript SDK sends Obelism-Token.

TokenSees
Delivery (cms_d_…)Published content only
Preview (cms_p_…)The latest drafts of everything
Site (cms_s_…)Drafts, as the editor signed in on your site
Canvas (cms_v_…)Drafts, as the person on the canvas, for 8 hours

A token is tied to one space and one environment. Name the ones you expect in the Obelism-Space and Obelism-Environment headers (space and environment on the SDK's ContentClient) and a token for any other gets a 401: do that wherever the token comes from the request, like the canvas's cms_token. Revoking it takes effect on the next request (within 60 seconds for a delivery token answered from the edge cache). Management tokens (cms_m_…) are refused here.

CORS is open to any origin, so a page on any site can read published content with a delivery token. Tokens that see drafts (preview, canvas and site tokens) belong on your server: from a browser they only work on the environment's preview URL, and any other origin gets a 403. The JavaScript SDK wraps every endpoint.

Endpoints

EndpointReturns
GET /api/delivery/v1/pages?path=…{ entry }: the page at a path
GET /api/delivery/v1/entries/{id}{ entry }
GET /api/delivery/v1/entries{ items, total, limit, skip }
GET /api/delivery/v1/content-modelThe environment's model file
GET /api/delivery/v1/redirects{ items: [{ locale, from, to }], total, limit, skip }: moved pages

Query parameters:

  • locale: a locale code; * for every locale (localized fields become { [locale]: value }); the space's default when left out. An empty value falls back along the locale's fallback chain.
  • On /pages, locale also picks the paths the page is looked up in: each locale can have its own paths. * and leaving it out look in the default locale's.
  • depth: how many levels of references to inline, 0–5, default 1. Past it, a reference is { id, contentType } only.
  • richtext: json (default) or html; see rich text.
  • On /entries: contentType, parentPath (direct children of the entry at that path in the requested locale, '' for the root), limit (1–100, default 20), skip, select (below), field filters and order (below). Entries come in the order of the space's tree unless order says otherwise.

Filtering and ordering

With contentType, /entries filters on that type's fields:

# The 10 newest news posts
GET /api/delivery/v1/entries?contentType=post&fields.category=news&order=-fields.date&limit=10

# Posts by one author, from this year
GET /api/delivery/v1/entries?contentType=post&fields.author=entry_…&fields.date[gte]=2026-01-01

# The product page of a Shopify product
GET /api/delivery/v1/entries?contentType=productPage&fields.product=gid://shopify/Product/42
FilterMatches
fields.x=valueEqual; on a field with several values, contains it
fields.x[ne]=valueNot equal; on a field with several values, doesn't contain it
fields.x[in]=a,bOne of them; on a field with several values, contains any
fields.x[gt]=valueAlso gte, lt, lte, on number and date fields with one value
fields.x[exists]=trueHas a value (false: has none)
  • Text, textarea, select, reference, asset, number, date, boolean and Shopify fields can be filtered: a reference or asset by its id, a Shopify item by its GID (gid://shopify/Product/42, whatever its handle; eq, ne, in and exists), a boolean as true or false, a date as YYYY-MM-DD or an ISO 8601 date-time. Several filters must all match.
  • Localized fields, rich text, blocks and JSON can't be filtered yet, nor can a field shown only sometimes: its hidden values are delivered as null.
  • Preview tokens filter drafts, delivery tokens published content.

order takes publishedAt, firstPublishedAt, updatedAt, name, path or fields.<apiId> (a field with one value, not localized), with - in front for descending. Entries without a value come last.

Settings, navigation and other single entries

Find a data entry by its type and a field rather than its id, so the site's code never holds an entry_…:

# The one entry of a settings type
GET /api/delivery/v1/entries?contentType=settings&limit=1

# The navigation with key "main"
GET /api/delivery/v1/entries?contentType=navigation&fields.key=main&limit=1

With the SDK: content.getFirstEntry({ contentType: 'navigation', fields: { key: 'main' } }).

Selecting keys

/entries returns each entry with its fields, references inlined and files looked up. A navigation, a sitemap or a listing of a hundred pages rarely needs all of that, and the content is the bulk of the response: select names the keys to return instead, without reading any of the entry's content.

# A sitemap: every page's path
GET /api/delivery/v1/entries?contentType=page&limit=100&select=path

# A list of posts: their path, title and date
GET /api/delivery/v1/entries?contentType=post&select=path,title,publishedAt&order=-publishedAt

Each item carries only the keys named, in the shape they have in a full entry: id, contentType, parentId, name, slug, path (the requested locale's), paths (every locale's), position, publishedAt, firstPublishedAt, updatedAt, and any field of contentType by its apiId. Field keys need contentType and return the stored value as it is — a reference or an asset stays its id — resolved to the requested locale when the field is localized. Rich text, blocks and JSON can't be selected: they need resolving. depth and richtext do not apply.

Moved pages

When a page moves (a new slug or parent), its old path, and those of the pages below it, keep pointing at it. /pages?path=<old path> answers 404 with where the page is now:

{
	"error": "Entry not found: it moved",
	"redirect": { "path": "blog/new-slug" }
}

so a site can answer with a 301 instead of a 404. Redirects are per locale: a page's old path is kept in each locale whose path changed, and /pages?path=…&locale=nl answers with the page's path in that locale. GET /api/delivery/v1/redirects lists every old path with its current one and its locale, paths without a leading / as everywhere in the API (?locale=nl for one locale; up to 1000 at a time, limit and skip), for redirect rules built ahead of time. A to that is an absolute URL is a redirect someone recorded by hand (agents and management tokens with POST …/redirects/create), for an old path of an import no page here ever had. Only redirects to entries the token can see are included. A page created or moved onto an old path takes it over, and deleting the entry removes its redirects. Manage them under Settings → Preview and site → Redirects; see Redirects for answering them on your site.

Paths per locale

A page has a path in every locale of its space, in paths, and path is the requested locale's (the default locale's for locale=*). A locale without its own slug uses the one of the next locale along its fallback chain, so every page exists in every locale. The paths never contain the locale itself: whether a site serves /nl/over-ons, nl.example.com/over-ons or example.nl/over-ons is its own routing. Use paths for hreflang links, language switchers and sitemaps.

{
	"path": "over-ons",
	"paths": { "en": "about-us", "nl": "over-ons" }
}

Set a page or folder's slug per locale under Move in its sidebar, or with the agent API's moveEntry (slugs). Data entries have no path: path is null and paths is {}.

Entries

{
	"id": "entry_…",
	"contentType": "page",
	"name": "Home",
	"slug": "home",
	"path": "home",
	"paths": { "en": "home", "nl": "home" },
	"publishedAt": "2026-10-12T19:35:12.473Z",
	"firstPublishedAt": "2026-10-12T19:35:12.473Z",
	"updatedAt": "2026-10-12T19:35:12.419Z",
	"fields": {
		"title": "Thuis",
		"body": [
			{
				"_uid": "b_…",
				"_type": "hero",
				"heading": "Welkom",
				"image": {
					"id": "asset_…",
					"url": "https://content.obelism.studio/assets/asset_…/photo.png?w=1600&q=80",
					"filename": "photo.png",
					"mimeType": "image/png",
					"size": 48211,
					"width": 1600,
					"height": 900,
					"title": null,
					"alt": "An orange test pattern"
				}
			}
		],
		"author": {
			"id": "entry_…",
			"contentType": "author",
			"name": "Ada Lovelace",
			"fields": { "name": "Ada Lovelace" }
		}
	}
}
  • Every field of the content type is present; empty ones are null.
  • Data entries have no slug or path (null).
  • Blocks keep _uid (stable, for the canvas) and _type.
  • A shared block is expanded inline into the block the site already renders, with _sharedFrom: { id, name } so it can tell where it came from. Blocks nested inside it get uids prefixed with the use's ({ useUid }:{ uid }), so a page's delivered tree never repeats one.
  • Any blocks list can hold the improve block, { _uid, _type: "_improve", kind, slug, variants }.
  • References and assets are inlined; a referenced entry has the same shape as any entry (shortened above). Publishing only accepts references to published entries; one that can't be resolved anyway (an entry promoted without what it points at) is null, or left out of a list.
  • Links name their page ({ id, title, slug, path }) without inlining it, whatever the depth; a link to a product names its product page the same way.
  • Shopify fields are returned as stored: { id, handle }.
  • Folders are never returned themselves; they only group entries by path.

A link field is answered as type: "entry" with the page it goes to, type: "url" with its address, or type: "product" with a Shopify product, each with query and hash (null when not set) and target ("_blank", or null when the content doesn't ask for a new tab):

{
	"cta": {
		"type": "entry",
		"entry": {
			"id": "entry_…",
			"title": "Pricing",
			"slug": "pricing",
			"path": "pricing"
		},
		"query": "plan=pro",
		"hash": "compare",
		"target": null
	},
	"footer": {
		"type": "url",
		"url": "https://github.com/Obelism",
		"query": null,
		"hash": null,
		"target": "_blank"
	},
	"buy": {
		"type": "product",
		"product": { "id": "gid://shopify/Product/42", "handle": "mug" },
		"entry": {
			"id": "entry_…",
			"title": "The mug",
			"slug": "mug",
			"path": "products/mug"
		},
		"query": null,
		"hash": "reviews",
		"target": null
	}
}
  • title is the page's title field in the requested locale (its name when it has none), and path its path in that locale.
  • There is no ready-made address: how a page's path and locale become a URL (/nl/pricing, nl.example.com/pricing, the home page at /) is the site's routing. Build it from path, then ? and query, then # and hash (target asks for target="_blank"), or let the SDK's linkHref do it with your routing.
  • A product link's entry is its product page (the published one, or for a preview token the draft too), or null when it has none: link to your own product route by product.handle then. The link itself is never null; whether the product still exists is the store's to say.
  • A link to a page the token can't see is null: a delivery token only sees published pages, a preview token drafts too. Publishing refuses a link to a draft and unpublishing a page that published content links to, so this only happens with content promoted without its page.

Shopify fields

A Shopify field holds a collection or product of the space's Shopify store, as its id and the handle it had when picked (a list when multiple):

{
	"collection": {
		"id": "gid://shopify/Collection/451234",
		"handle": "new-arrivals"
	}
}

Nothing else about the item is stored or inlined, whatever the depth: read its title, price, stock and images from Shopify's Storefront API by the id, or with the SDK's fetchShopifyCollection and fetchShopifyProducts. Delivery's cache never has to follow the store.

Errors

Errors are { "error": "…" }:

StatusWhen
400A parameter or filter is invalid
401The token is missing, invalid, expired or revoked, or for another space or environment than Obelism-Space / Obelism-Environment
403A management token; a draft token from a browser on another origin than the preview URL; or Content is turned off for the organization
404No page at that path (with redirect when one moved from it), or no entry with that id
429The token made more than 1,000 requests in 10 seconds; see rate limits

Rate limits and usage

Each token can make 1,000 requests per 10 seconds: far more than a site's traffic (most of it is answered from the cache) or a static build fetching every page, but a loop stops there. Over it, requests get 429 with Retry-After and RateLimit-Policy: "delivery";q=1000;w=10 until the window has passed.

Every request with a valid token that reaches the API is counted, per environment and day, and so is how many the cache answered. Requests answered from the edge cache don't reach it and aren't counted. See them under Settings → API tokens → Usage (this month per environment, and the last 30 days), or with GET /api/agent/organizations/{org}/spaces/{space}/usage. Counts are written every few seconds, so the latest requests may take a moment to show.

Caching

A delivery token sent as Obelism-Token is answered from Cloudflare's edge, close to where the request comes from, without reaching the API: per path, query and token, for 30 seconds and up to 30 more while a fresh copy is fetched. Publishing, unpublishing, moving or deleting, a model or locale change and revoking a token clear the environment's copies right away, so a change usually shows on the next request and is never more than 60 seconds old. CF-Cache-Status shows whether the edge answered. Preview, site and canvas tokens are never cached.

Behind the edge, responses are cached per environment and invalidated the moment anything is published, so whatever reaches the API is current. X-Cache: HIT | MISS shows which you got. Don't put responses in your own shared CDN cache keyed only on the URL: they belong to the token. To rebuild or revalidate your site when content changes, use a webhook.

Asset files are public and immutable (a private file's url is signed and lasts a day), cached for a year, with ETag and Range support (see assets). They are answered on /assets/{id}/{filename}, the URL above, where images are optimized; the raw originals stay on /files/{id}/{filename}, which keeps serving them for the files that existed before media took over.

On this page

Marks in the margin

  • Note
  • Watch out
  • Updated

Scribes marked doubtful lines in the margin. That practice is called obelism.

Stuck on a step?