# Delivery API (/docs/api/delivery)

Read access to one space environment's content, for the site that shows it. Create a [token](/docs/guides/api-tokens) 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.

```sh
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](#caching): a request with `Authorization` always reaches the API. The [JavaScript SDK](/docs/sdk/javascript) 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](/docs/guides/site-editing) |
| Canvas (`cms_v_…`)   | Drafts, as the person on the [canvas](/docs/guides/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](#caching)). 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](/docs/sdk/javascript) wraps every endpoint.

## Endpoints [#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](/docs/guides/content-model#the-model-file)            |
| `GET /api/delivery/v1/redirects`     | `{ items: [{ locale, from, to }], total, limit, skip }`: [moved pages](#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](/docs/guides/locales#fallbacks).
- On `/pages`, `locale` also picks the paths the page is looked up in: each locale can have [its own paths](#paths-per-locale). `*` 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](/docs/api/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 [#filtering-and-ordering]

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

```sh
# 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`, `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](/docs/guides/content-model#fields-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 [#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_…`:

```sh
# 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](/docs/sdk/javascript#filtering-and-ordering): `content.getFirstEntry({ contentType: 'navigation', fields: { key: 'main' } })`.

## Selecting keys [#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.

```sh
# 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 [#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:

```json
{
	"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](/docs/guides/redirects) for answering them on your site.

## Paths per locale [#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](/docs/guides/locales#fallbacks), 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.

```json
{
	"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 [#entries]

```json
{
	"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](/docs/guides/content-model#shared-blocks) 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](/docs/guides/ab-tests), `{ _uid, _type: "_improve", kind, slug, variants }`.
- References and [assets](/docs/guides/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](#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](#shopify-fields) are returned as stored: `{ id, handle }`.
- Folders are never returned themselves; they only group entries by path.

### Links [#links]

A [`link` field](/docs/guides/content-model#links) 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):

```json
{
	"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`](/docs/sdk/javascript#links) do it with your routing.
- A product link's `entry` is its [product page](/docs/guides/shopify#product-pages) (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 [#shopify-fields]

A [Shopify field](/docs/guides/shopify) 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`):

```json
{
	"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`](/docs/sdk/javascript#shopify). Delivery's cache never has to follow the store.

## Errors [#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)                                       |

## Rate limits and usage [#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 [#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](/docs/guides/webhooks).

Asset files are public and immutable (a [private file](/docs/guides/assets#private-files)'s `url` is signed and lasts a day), cached for a year, with `ETag` and `Range` support (see [assets](/docs/guides/assets#serving)). 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.
