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.
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.
| Token | Sees |
|---|---|
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
| Endpoint | Returns |
|---|---|
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-model | The 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,localealso 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, default1. Past it, a reference is{ id, contentType }only.richtext:json(default) orhtml; 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 andorder(below). Entries come in the order of the space's tree unlessordersays 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| Filter | Matches |
|---|---|
fields.x=value | Equal; on a field with several values, contains it |
fields.x[ne]=value | Not equal; on a field with several values, doesn't contain it |
fields.x[in]=a,b | One of them; on a field with several values, contains any |
fields.x[gt]=value | Also gte, lt, lte, on number and date fields with one value |
fields.x[exists]=true | Has 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,inandexists), a boolean astrueorfalse, a date asYYYY-MM-DDor 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=1With 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=-publishedAtEach 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
slugorpath(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 thedepth; 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.
Links
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
}
}titleis the page'stitlefield in the requested locale (its name when it has none), andpathits path in that locale.- There is no ready-made address: how a page's
pathand locale become a URL (/nl/pricing,nl.example.com/pricing, thehomepage at/) is the site's routing. Build it frompath, then?andquery, then#andhash(targetasks fortarget="_blank"), or let the SDK'slinkHrefdo it with your routing. - A product link's
entryis its product page (the published one, or for a preview token the draft too), ornullwhen it has none: link to your own product route byproduct.handlethen. The link itself is nevernull; 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": "…" }:
| Status | When |
|---|---|
400 | A parameter or filter is invalid |
401 | The token is missing, invalid, expired or revoked, or for another space or environment than Obelism-Space / Obelism-Environment |
403 | A management token; a draft token from a browser on another origin than the preview URL; or Content is turned off for the organization |
404 | No page at that path (with redirect when one moved from it), or no entry with that id |
429 | The 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.