# Delivery API

> Every endpoint websites read content from, with parameters, example answers, keys, fallback, caching, rate limits, limits, timeouts and errors.

Source: https://docs.bifrostorbit.com/cms/developers/delivery-api/

The delivery API is how a website reads published content. It is read only. Every endpoint answers `GET` requests with JSON.

| | Address |
| --- | --- |
| Production | `https://api.bifrostorbit.com/cms/v1` |
| Staging | `https://api-stage.bifrostorbit.com/cms/v1` |
| GraphQL | `https://api.bifrostorbit.com/cms/graphql`, see [GraphQL](https://docs.bifrostorbit.com/cms/developers/graphql/) |

Try any endpoint in **Configuration › API playground**. Paste a key, pick an endpoint and run it.

## Endpoints

Each endpoint takes the [common parameters](#common-parameters) listed in the last column, and its own parameters in its section. Errors are the same everywhere: see [Errors](#errors).

| Endpoint | Answers with | Common parameters |
| --- | --- | --- |
| [`/resolve`](#get-resolve) | What an address is: a page, a redirect, gone or not found | `region`, `locale`, `strict`, `draft`, `include=references` |
| [`/content`](#get-content) | One page by its address | `region`, `locale`, `strict`, `draft`, `include=references` |
| [`/list`](#get-list) | Entries of one content type | `region`, `locale`, `draft`, `include=fields,references` |
| [`/global`](#get-global) | A global entry, such as the footer | `region`, `locale`, `strict`, `draft`, `include=references` |
| [`/navigation`](#get-navigation) | A menu | `region`, `locale`, `draft` |
| [`/regions`](#get-regions) | The regions and languages the key may read | None |
| [`/taxonomies/{key}`](#get-taxonomies-key) | A taxonomy and its terms | `locale` |
| [`/schema`](#get-schema) | Content types and components with their fields | None |
| [`/banners`](#get-banners) | Banners live now | `region`, `locale`, `draft` |
| [`/popups`](#get-popups) | Popups live now | `region`, `locale`, `draft` |
| [`/products`](#get-products) | Shop products with prices and stock | `region`, `locale`, `draft` |
| [`/sitemap`](#get-sitemap) | The sitemap index, or one region's sitemap | `region`, `locale` |
| [`/robots`](#get-robots) | The robots.txt | None |

Leave the trailing slash off endpoint addresses. `/cms/v1/content/` answers `308` with a redirect to `/cms/api/cdn/v1/content`, the same endpoint under its internal address, which costs a second request.

## Authentication

Send a delivery key with every request, in either header:

```http
Authorization: Bearer cms_pk_…
x-api-key: cms_pk_…
```

When both are sent, `Authorization` wins. A request with no key, an unknown key, a deleted key or an expired key gets `401`.

```bash
curl 'https://api.bifrostorbit.com/cms/v1/content?url=/products/hiking-boots&region=de' \
  -H 'Authorization: Bearer cms_pk_…'
```

## From a browser

Any website may call the API from browser code with a **public key**: every answer allows all origins, and preflight requests are answered. Use it for a static site, a widget or anything that reads published content in the browser.

A **secret key** only works from a server. Sent from another website's browser, it is refused with `401`, because a key in browser code can be read by anyone. Keep drafts and preview on your server.

## Keys and environments

Create keys in **Configuration › API keys**.

| Key | Starts with | Reads |
| --- | --- | --- |
| Public | `cms_pk_` | Published content only |
| Secret | `cms_sk_` | Published content, and drafts when you add `draft=true`. Keep it on the server. |

- Each key belongs to one environment and only ever sees that environment's content. Use a staging key against a staging environment and a production key against production.
- A secret key cannot be created for a production environment. Drafts are read from staging environments only.
- A key can be limited to some regions and languages. Asking for any other region or language gets `401`, and `/regions` lists only the ones the key may read.
- A key can have an expiry date. After it, the key gets `401`.

See [Security and access](https://docs.bifrostorbit.com/cms/developers/security-and-access/).

## Common parameters

Most endpoints take these query parameters. The [endpoints table](#endpoints) says which apply to each. This is the only table of them; the sections below list only each endpoint's own parameters.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `region` | string | The space's first region | Region key or its short code, such as `germany` or `de`. Not case sensitive. |
| `locale` | string | The region's default language | Language code, such as `de-DE`. Not case sensitive. |
| `strict` | `true` | off | Refuse every fallback: the page must exist in the language and region asked for. |
| `draft` | `true` | off | Read drafts instead of published content. Needs a secret key. |
| `include` | list | nothing | Extra content to add to the answer, separated by commas: `fields` and `references`. See [Lists](#lists) and [References](#references). |

Any value other than `true` counts as off for `strict` and `draft`. A region or language that does not exist, or anything other than `fields` or `references` in `include`, is an [error](#errors).

## Pages

### `GET /resolve`

Says what an address is: a page, a redirect, gone or not found. Call it before rendering a page, so redirects and removed pages behave correctly.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `url` | string | `/` | The page address, such as `/products/hiking-boots`. A missing leading slash is added and a trailing slash is removed. |

Optional header: `x-cms-referrer` with the address of the page that linked here. It is recorded with addresses that are not found, so editors can see where broken links come from.

```bash
curl 'https://api.bifrostorbit.com/cms/v1/resolve?url=/products/hiking-boots&region=de' \
  -H 'Authorization: Bearer cms_pk_…'
```

The HTTP status follows the answer's `kind`:

| `kind` | Status | Means |
| --- | --- | --- |
| `page` | `200` | `page` holds the same page as `/content` |
| `redirect` | `200` | Send the visitor to `destination` with `statusCode` |
| `gone` | `410` | The page was published once and has been removed, or a campaign page has ended |
| `notFound` | `404` | Nothing was ever published here |

A page. `page` is the same object [`/content`](#get-content) answers with:

```json
{ "data": { "kind": "page", "page": { "url": "/products/hiking-boots", "name": "Hiking boots", "…": "…" } } }
```

A redirect. `permanent` is true for `301` and `308`, and `hops` counts the redirects that were followed to reach the final address:

```json
{
  "data": {
    "kind": "redirect",
    "destination": "/products/hiking-boots",
    "statusCode": 301,
    "permanent": true,
    "hops": 1
  }
}
```

Gone and not found carry only the kind:

```json
{ "data": { "kind": "notFound" } }
```

### `GET /content`

One page by its address. Use it when you only need the page and handle redirects yourself.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `url` | string | `/` | The page address, read as in `/resolve` |

```bash
curl 'https://api.bifrostorbit.com/cms/v1/content?url=/products/hiking-boots&region=de&locale=de-DE' \
  -H 'Authorization: Bearer cms_pk_…'
```

```json
{
  "data": {
    "id": "6650c0f2a1b2c3d4e5f60718",
    "url": "/products/hiking-boots",
    "name": "Hiking boots",
    "contentType": "page",
    "locale": "de-DE",
    "region": "germany",
    "requestedLocale": "de-DE",
    "requestedRegion": "germany",
    "fallbackApplied": false,
    "regionFallbackApplied": false,
    "draft": false,
    "publishedAt": "2026-10-01T09:30:00.000Z",
    "version": 7,
    "fields": {
      "title": "Find a shop",
      "heroImage": { "id": "6650c1…", "url": "https://…/hero.jpg", "mimeType": "image/jpeg", "width": 1600, "height": 900, "alt": "A pair of hiking boots", "title": null, "subtitles": null }
    },
    "components": [
      {
        "id": "c1",
        "type": "hero",
        "schemaVersion": 1,
        "fields": { "heading": "Free boot fitting" },
        "children": {},
        "variant": null
      }
    ],
    "seo": {
      "title": "Hiking boots | Acme Outdoors",
      "description": "Find a shop near you.",
      "canonical": "https://www.example.com/de/products/hiking-boots",
      "robots": { "index": true, "follow": true },
      "openGraph": { "type": "website", "title": "Hiking boots", "description": "Find a shop near you.", "url": "https://www.example.com/de/products/hiking-boots", "siteName": "Acme Outdoors", "locale": "de_DE", "image": null },
      "twitter": { "card": "summary_large_image", "site": null, "title": "Hiking boots", "description": "Find a shop near you.", "image": null },
      "verification": { "google": null, "bing": null },
      "alternates": [{ "hreflang": "de-DE", "href": "https://www.example.com/de/products/hiking-boots" }],
      "breadcrumbs": [{ "name": "Products", "url": "https://www.example.com/de/products" }],
      "jsonLd": []
    },
    "insights": null
  }
}
```

| Field | Means |
| --- | --- |
| `id` | The entry's id in this region and language, the same as in the management API |
| `url`, `name`, `contentType` | The page's address, its name in the CMS and its content type key |
| `locale`, `region` | The language and region that answered |
| `requestedLocale`, `requestedRegion` | What was asked for, or the defaults when nothing was |
| `fallbackApplied` | `true` when another language answered. See [Fallback](#fallback). |
| `regionFallbackApplied` | `true` when another region answered |
| `draft` | `true` for a draft |
| `publishedAt` | When it was last published, or `null` if never |
| `version` | The published version number, or the draft version for a draft |
| `fields` | The content type's fields. Images become objects with `url`, size and `alt`; links carry the target page's current address. See [Field types](https://docs.bifrostorbit.com/cms/developers/field-types/). |
| `components` | The page body: a tree of components with `id`, `type`, `schemaVersion`, `fields`, `children` (lists by slot) and `variant`. Blocks in a slot arrive both in `children` and on the slot's field. See Components and slots in [Field types](https://docs.bifrostorbit.com/cms/developers/field-types/). Shared sections arrive already in place: their blocks appear where the section was placed, and the `sharedSection` block itself is never delivered. |
| `seo` | Ready-made metadata: title, description, canonical, robots, Open Graph, X card, hreflang alternates, breadcrumbs and JSON-LD |
| `insights` | `{ "site": "…" }` when the space is connected to Insights Orbit, with the site key the script counts visits with; otherwise `null`. The key is public, like the script tag it goes in. See [Insights Orbit](https://docs.bifrostorbit.com/cms/developers/sdk/). |
| `expired` | Only on a campaign page past its end date that is set to stay visible: `{ "message": "This offer has ended." }` |

Nothing at the address is a `404` [error](#errors).

## Lists

### `GET /list`

Entries of one content type, such as products, articles or FAQ entries. Items are summaries without a body; ask `/content` for a full page. Add `include=fields` to get each entry's fields too, which is how you read entries that have no address of their own, such as FAQ entries.

| Parameter | Type | Default | Limits and notes |
| --- | --- | --- | --- |
| `type` | string | | Content type key, such as `product`. Required in practice: without it the list is empty. |
| `page` | integer | `1` | 1 or more |
| `perPage` | integer | `20` | 1 to 100. Larger values are capped at 100. |
| `sort` | string | `sortOrder` | See [Sorting](#sorting) |
| `filter[<field>]` | string | | Exact match on a field. Repeat for several fields; all must match. |
| `tag` | string | | Entries whose `tags` field contains this value |
| `category` | string | | Entries whose `category` field equals this value |
| `q` | string | | Entries whose name contains this text, ignoring case |
| `include` | list | | `fields` adds each entry's fields. `references` also replaces the references in them with the entries they point at, and implies `fields`. |

Lists do not fall back to another language.

```bash
curl 'https://api.bifrostorbit.com/cms/v1/list?type=product&region=de&sort=-publishedAt&perPage=10&filter[activity]=hiking' \
  -H 'Authorization: Bearer cms_pk_…'
```

```json
{
  "data": {
    "items": [
      {
        "id": "66f1c0ffee0000000000a001",
        "url": "/products/hiking-boots",
        "name": "Hiking boots",
        "contentType": "product",
        "publishedAt": "2026-09-14T11:02:00.000Z",
        "seo": { "title": "Hiking boots", "description": "…" }
      }
    ],
    "total": 42,
    "page": 1,
    "hasMore": true
  }
}
```

An entry without its own address has `url` and `seo` set to `null`. Global entries never appear in lists. `id` is the entry's id, the same value a Reference field holds.

### Entries with their fields

```bash
curl 'https://api.bifrostorbit.com/cms/v1/list?type=faqEntry&region=de&category=pricing&include=fields' \
  -H 'Authorization: Bearer cms_pk_…'
```

```json
{
  "data": {
    "items": [
      {
        "id": "66f1c0ffee0000000000b007",
        "url": null,
        "name": "Can I cancel at any time?",
        "contentType": "faqEntry",
        "publishedAt": "2026-09-30T08:00:00.000Z",
        "seo": null,
        "fields": {
          "question": "Can I cancel at any time?",
          "answer": "<p>Yes. Your plan ends at the close of the month.</p>",
          "category": "pricing"
        }
      }
    ],
    "total": 1,
    "page": 1,
    "hasMore": false
  }
}
```

`fields` are delivered as on a page: images become objects and links carry the target page's address. Without `include=fields` the items look exactly as before, so existing websites are not affected.

### Pagination

Ask for `page=1`, then the next page while `hasMore` is `true`. `total` is the number of matching entries across all pages.

### Sorting

| `sort` | Order |
| --- | --- |
| `sortOrder` | The order editors set by hand, then by name. The default. |
| `name`, `-name` | By name, A to Z or Z to A |
| `publishedAt`, `-publishedAt` | Oldest or newest published first, then by name |
| `updatedAt`, `-updatedAt` | Least or most recently changed first, then by name |

Any other value is a `400` [error](#errors).

### Filtering

- `filter[<field>]=<value>` matches the field's key as set on the content type, such as `filter[activity]=hiking`. A key must start with a letter and contain only letters, digits and underscores, otherwise the request is a `400` [error](#errors).
- Values are compared as text and must match exactly. A list field matches when it contains the value.
- `filter`, `tag`, `category` and `q` combine: an entry must match all of them.

## References

Reference and References fields hold entry ids. Add `include=references` to `/resolve`, `/content`, `/global` or `/list` and each id is replaced by the entry it points at, wherever the field is: on the entry itself or in any component of its body.

```bash
curl 'https://api.bifrostorbit.com/cms/v1/resolve?url=/pricing&region=de&include=references' \
  -H 'Authorization: Bearer cms_pk_…'
```

A component with a References field called `questions` then arrives like this:

```json
{
  "id": "c4",
  "type": "faqList",
  "schemaVersion": 1,
  "fields": {
    "heading": "Questions about plans",
    "questions": [
      {
        "id": "66f1c0ffee0000000000b007",
        "url": null,
        "name": "Can I cancel at any time?",
        "contentType": "faqEntry",
        "publishedAt": "2026-09-30T08:00:00.000Z",
        "fields": { "question": "Can I cancel at any time?", "answer": "<p>Yes.</p>", "category": "pricing" }
      }
    ]
  },
  "children": {},
  "variant": null
}
```

- Each entry has its `id`, `url` (or `null` without an address), `name`, `contentType`, `publishedAt` and its `fields`, delivered as on a page. Its body is not included; ask for its page when it has one.
- One level only: references inside the referenced entry stay ids.
- The entry is read in the same region, and in the language of the request, following the same language fallback as the page. An entry that is translated answers in the visitor's language even though editors picked it in another one, and a page promoted from staging points at production's copy of each entry.
- Published content shows published entries only. An entry that is unpublished, deleted, missing in the region or in a language the key may not read is left out of a References list, and a Reference becomes `null`.
- With `draft=true`, the referenced entries' drafts are shown too.
- Without `include=references` references stay ids, as before.

To refresh pages when an entry they show is published, see [Refresh the website when content changes](https://docs.bifrostorbit.com/cms/developers/webhooks/). The SDK does this for you.

## Site-wide content

### `GET /global`

A global entry, such as the footer or the site header, by its key.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `key` | string | | The global entry's key. Required. |

It follows the language fallback, but not the region fallback.

```bash
curl 'https://api.bifrostorbit.com/cms/v1/global?key=footer&region=de' \
  -H 'Authorization: Bearer cms_pk_…'
```

```json
{
  "data": {
    "key": "footer",
    "name": "Footer",
    "contentType": "footer",
    "locale": "de-DE",
    "fields": { "copyright": "© Acme Outdoors" },
    "components": []
  }
}
```

`locale` is the language that answered, which may be a fallback.

### `GET /navigation`

A menu, by its key. A menu can be shared by all regions or belong to one.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `key` | string | | The menu key, such as `main`. Required. |

```bash
curl 'https://api.bifrostorbit.com/cms/v1/navigation?key=main&region=de' \
  -H 'Authorization: Bearer cms_pk_…'
```

```json
{
  "data": {
    "key": "main",
    "name": "Main menu",
    "items": [
      {
        "label": "Services",
        "href": "/services",
        "target": null,
        "icon": null,
        "badge": null,
        "description": null,
        "children": [
          { "label": "Hiking boots", "href": "/products/hiking-boots", "target": null, "icon": null, "badge": "New", "description": "Fitted by people who hike", "children": [] }
        ]
      }
    ]
  }
}
```

Items that link to a page carry its current address, so renaming a page never breaks the menu. Hidden items, and items linking to a page that is not published in this region and language, are left out.

### `GET /regions`

The regions the key may read, with their languages. No parameters.

```json
{
  "data": [
    {
      "key": "germany",
      "name": "Germany",
      "locales": [
        { "code": "de-DE", "name": "German (Germany)", "isDefault": true },
        { "code": "en-DE", "name": "English", "isDefault": false }
      ]
    }
  ]
}
```

### `GET /taxonomies/{key}`

A taxonomy and its terms, in their set order.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `locale` | string | | Language for the term labels. Terms without a label in that language use their default label. |

```json
{
  "data": {
    "key": "activity",
    "name": "Activity",
    "terms": [{ "value": "hiking", "label": "Hiking", "sortOrder": 0 }]
  }
}
```

### `GET /schema`

The space's content types and components, with their fields. The SDK's type generator uses it. No parameters. Deprecated components and hidden fields are left out.

```json
{
  "data": {
    "contentTypes": [
      { "key": "page", "name": "Page", "description": null, "isGlobal": false, "fields": [] }
    ],
    "components": [
      { "key": "hero", "name": "Hero", "description": null, "group": "Layout", "allowedChildren": "*", "fields": [] }
    ]
  }
}
```

Each field has `key`, `name`, `type`, `required`, `description`, `options`, `allowedComponents`, `allowedContentTypes`, `taxonomy` and `validation`.

## Campaigns

Banners and popups are worked out for the moment of the request. Both answers include `serverTime`, so countdowns can tick from the CMS clock rather than the visitor's.

### `GET /banners`

Banners live now for a page, pinned first and then by priority.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `path` | string | `/` | The page being shown. Only banners targeting it are returned. |
| `everyPage` | `true` | off | Return the banners live on any page instead, each with the `paths` it targets, so a website can read them once for all its pages and pick per page itself. `path` is then ignored. |
| `loggedIn` | `true` | off | Whether the visitor is signed in. Blocks meant only for guests or only for signed-in visitors are left out accordingly. |

```bash
curl 'https://api.bifrostorbit.com/cms/v1/banners?path=/products/hiking-boots&region=de' \
  -H 'Authorization: Bearer cms_pk_…'
```

```json
{
  "data": {
    "serverTime": "2026-10-17T10:00:00.000Z",
    "items": [
      {
        "id": "66f1a2…",
        "blocks": [
          { "id": "b1", "type": "message", "text": "Free boot fitting this week", "segments": [{ "text": "Free boot fitting", "highlight": true }, { "text": " this week", "highlight": false }] }
        ],
        "text": "Free boot fitting this week",
        "segments": [{ "text": "Free boot fitting", "highlight": true }, { "text": " this week", "highlight": false }],
        "link": null,
        "background": "#1f2937",
        "foreground": "#ffffff",
        "placement": "aboveHeader",
        "paths": ["/services/*"],
        "pinned": false,
        "priority": 0,
        "trackingLabel": "consult-week",
        "countdown": null,
        "endsAt": "2026-10-20T22:59:00.000Z",
        "next": null
      }
    ]
  }
}
```

`foreground` is the text colour the editor chose, or white or dark, whichever reads best on `background`. `placement` is where the editor wants it: `aboveHeader` or `belowHeader`. `paths` are the pages it targets, with `/*` at the end for a whole section, and empty for every page.

`next` is the banner that takes over when this one ends, with its `startsAt`, so the website can switch without asking again. See [Render banners, popups and countdowns](https://docs.bifrostorbit.com/cms/developers/render-campaigns/).

### `GET /popups`

Popups live now for a page and the visitor's sign-in state, highest priority first.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `path` | string | `/` | The page being shown |
| `loggedIn` | `true` | off | Whether the visitor is signed in. Popups for guests only or signed-in visitors only are filtered by it. |

Each item has `id`, `name`, `layout`, `style`, `minimisedText`, `blocks`, `priority`, `hideForDays` (default 30), `maxViews`, `trackingLabel`, `startsAt` and `endsAt`.

## Shop

### `GET /products`

Shop products with live prices and stock, in their set order.

| Parameter | Type | Default | Limits and notes |
| --- | --- | --- | --- |
| `page` | integer | `1` | 1 or more |
| `perPage` | integer | `24` | 1 to 100. Larger values are capped at 100. |
| `filter` | string | | A taxonomy term value. Products tagged with it in any taxonomy field match. |

The answer has `items`, `total`, `page` and `hasMore`, like `/list`. Each product has `slug`, `url`, `name`, `catalogId`, `size`, `price`, `currency`, `wasPrice`, `inStock`, `onSale`, `badges`, `benefits`, `directions`, `ingredients`, `terms`, `images`, `filters` (with labels), `relatedSlugs`, `seo` and `publishedAt`. `catalogId` links the product to your shop catalogue, which supplies `price` and `inStock`. Both are `null` when the product is not linked or the catalogue cannot be reached, and `currency` is then the region's currency.

## Search engines

### `GET /sitemap`

Without `region`, the index of every region and language. With `region`, that region's entries.

| Parameter | Type | Default | Means |
| --- | --- | --- | --- |
| `region` | string | | Region key or code. Leave out for the index. |
| `locale` | string | The region's default language | Language of the sitemap |
| `page` | integer | `1` | Sitemap file number. Each holds up to 50,000 addresses. |

The index:

```json
{
  "data": {
    "indexable": true,
    "sitemaps": [{ "region": "germany", "locale": "de-DE", "base": "https://www.example.com/de", "pages": 312, "files": 1 }]
  }
}
```

A region's sitemap:

```json
{
  "data": {
    "region": "germany",
    "locale": "de-DE",
    "page": 1,
    "totalPages": 1,
    "entries": [
      {
        "loc": "https://www.example.com/de/products/hiking-boots",
        "lastmod": "2026-10-01T09:30:00.000Z",
        "changefreq": "monthly",
        "priority": null,
        "images": [],
        "alternates": [{ "hreflang": "de-DE", "href": "https://www.example.com/de/products/hiking-boots" }]
      }
    ]
  }
}
```

Only published pages with an address are listed. Pages set to no-index or left out of the sitemap are skipped. Sitemaps always show published content.

### `GET /robots`

The robots.txt for the environment. No parameters.

```json
{
  "data": {
    "indexable": true,
    "content": "User-agent: *\nAllow: /\nSitemap: https://www.example.com/de/sitemap.xml\n"
  }
}
```

An environment that is not indexable, such as staging, always gets `User-agent: *` and `Disallow: /`.

## Fallback

When a page is missing in the language or region asked for, the API can answer with another one rather than `404`. Every page answer says what happened.

1. **Language fallback.** Each language can name a fallback language in the same region. The API tries the language asked for, then its fallback, then that language's fallback, and so on. The first one with published content answers. A space can turn this off with **Allow locale fallback** in its settings, and then every request behaves as if it sent `strict=true`.
2. **Region fallback.** If no language in the region has the page, and the space is set to show another region's page when one is missing, the API tries that region in its default language. See [Space settings](https://docs.bifrostorbit.com/cms/editors/space-settings/).
3. `strict=true` turns both off.

| Endpoint | Language fallback | Region fallback |
| --- | --- | --- |
| Pages (`/content`, `/resolve`) | Yes | Yes |
| `/global` | Yes | No |
| Everything else | No | No |

On a page, compare the fields to see what answered:

| Field | Example | Means |
| --- | --- | --- |
| `requestedRegion`, `requestedLocale` | `germany`, `en-DE` | What was asked for |
| `region`, `locale` | `germany`, `de-DE` | What answered |
| `fallbackApplied` | `true` | Another language answered |
| `regionFallbackApplied` | `true` | Another region answered |

The SDK copies these into `page.meta`, so a website can show a notice such as "This page is not available in English yet." A key limited to some languages never falls back to a language it may not read.

## Drafts

Add `draft=true` with a secret key to read the latest saved draft instead of the published version. Use it for previews only.

- A public key asking for drafts gets `401`. See [Errors](#errors).
- Drafts are never cached.
- Pages that were never published are found, and campaign pages show outside their start and end dates, so editors can preview ahead of time.
- `/regions`, `/schema`, `/sitemap`, `/robots` and `/taxonomies/{key}` always answer with published content.

See [Set up the live preview](https://docs.bifrostorbit.com/cms/developers/preview/).

## Caching

Successful answers tell caches how long they may keep them. Every answer also has `Vary: Authorization`.

| Endpoints | `Cache-Control` |
| --- | --- |
| `/content`, `/resolve`, `/list`, `/banners`, `/popups`, `/products`, `/taxonomies/{key}` | `public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400` |
| `/global`, `/navigation`, `/schema`, `/sitemap`, `/robots` | `public, s-maxage=300, stale-while-revalidate=1500, stale-if-error=86400` |
| `/regions` | `public, s-maxage=3600, stale-while-revalidate=18000, stale-if-error=86400` |
| Any request with `draft=true` | `private, no-store` |
| `/resolve` answering `notFound` | `private, no-store` |
| Errors, including `429` | `no-store` |

In words: a shared cache, such as a CDN in front of your website, may keep a published answer for 60 seconds (5 minutes for menus, globals and sitemaps, an hour for regions). After that it may serve the old answer for five times as long while it fetches a new one, and for a day if the API is failing. A newly created page is found as soon as it is published, because not found is never cached.

The API does not send `ETag` or `Last-Modified`, so conditional requests are not supported.

To show a change at once rather than within these times, listen for publish webhooks and refresh only what changed. See [Refresh the website when content changes](https://docs.bifrostorbit.com/cms/developers/webhooks/).

## Rate limits

Each key and each client IP address may make **15,000 requests a minute**, counted separately. A request counts against both, and the stricter one applies. GraphQL requests count too, against the same limit. Windows are fixed calendar minutes, so the count resets at the start of each minute.

Going over gets `429`:

```http
HTTP/1.1 429 Too Many Requests
Cache-Control: no-store
Retry-After: 23
X-RateLimit-Limit: 15000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 23
```

```json
{ "error": { "code": "rate_limited", "message": "Too many requests; try again shortly" } }
```

Every answer from an endpoint, not only a `429`, says where you stand:

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | The requests allowed this minute, from the stricter of the key and the IP address |
| `X-RateLimit-Remaining` | The requests left this minute, counting this one |
| `X-RateLimit-Reset` | Whole seconds until the minute ends and the count starts again, from 1 to 60 |
| `Retry-After` | On a `429` only: the seconds to wait, the same as `X-RateLimit-Reset` |

Browser code can read all four. An answer served from a cache in front of the API, such as your own CDN, keeps the values it had when it was cached.

Cache answers in your website and you will stay well below the limit.

## Limits and timeouts

| Limit | Value |
| --- | --- |
| Requests | 15,000 a minute per key and per IP address. See [Rate limits](#rate-limits). |
| `perPage` in `/list` and `/products` | At most 100. Larger values are capped at 100. |
| Addresses in one sitemap file | 50,000 |
| References | One level. See [References](#references). |
| GraphQL query | At most 20,000 characters, 10 levels deep and 300 fields. See [GraphQL](https://docs.bifrostorbit.com/cms/developers/graphql/). |

The API sets no limit of its own on the length of the address or query string, the length of `q`, or the number of `filter[…]` parameters. Keep addresses short all the same: very long ones can be refused by the network in front of the API before they reach it.

Timeouts:

- **The SDKs:** In the JavaScript, PHP, Swift and Kotlin SDKs each attempt times out after 5 seconds. Network errors, timeouts and server errors are retried twice, after 250 milliseconds and then 750 milliseconds. A `429` is not retried, and GraphQL requests from the JavaScript SDK are tried once. Each SDK lets you change the timeout and the number of retries; see the [SDK](https://docs.bifrostorbit.com/cms/developers/sdk/).
- **The API:** It sets no time limit of its own on a request.
- **While the API restarts:** During a release the gateway holds a request for up to 30 seconds while it waits for the API to come back, so a request can take that long without failing.

## Errors

Errors from the API come as JSON with the HTTP status, `Cache-Control: no-store` and the same browser headers as every other answer:

```json
{ "error": { "code": "not_found", "message": "Content not found" } }
```

| Status | `code` | When |
| --- | --- | --- |
| `400` | `bad_request` | An unknown `sort`, a `filter[…]` name that is not a valid field key, or anything other than `fields` or `references` in `include` |
| `401` | `unauthorized` | No key, an unknown, deleted or expired key, a public key asking for drafts, a secret key sent from a browser on another website, or a region or language the key may not read |
| `402` | `plan_ended` | The organisation's CMS Orbit plan has ended. An owner can choose a plan under **Billing** in Bifröst Orbit. |
| `404` | `not_found` | Nothing at the address, or no menu, global entry, taxonomy, region or language with that key. The message names which, such as `Region not found`. |
| `429` | `rate_limited` | Too many requests. See [Rate limits](#rate-limits). |
| `500` | `internal_error` | Something failed on our side. Try again; the message is always `The request failed`. |

`/resolve` is different: not found and gone are normal answers, `{ "data": { "kind": "notFound" } }` with `404` and `{ "data": { "kind": "gone" } }` with `410`, not errors.

GraphQL errors have their own shape. See [GraphQL](https://docs.bifrostorbit.com/cms/developers/graphql/).

### Answers that do not come from an endpoint

A few answers come from before a request reaches an endpoint. Check the status before reading the body.

| Request | Answer |
| --- | --- |
| An address outside the API, such as `https://api.bifrostorbit.com/cms/v2/content`, or an endpoint that does not exist under `/cms/v1/`, such as `/cms/v1/pages` | `404` in the usual shape, with a link to these docs: `{ "error": { "code": "not_found", "message": "…" }, "docs": "…" }` |
| An endpoint with a trailing slash, such as `/cms/v1/content/` | `308` redirect. See [Endpoints](#endpoints). |
| Any request while the API cannot be reached for more than 30 seconds | A `5xx` status, such as `502`, without a JSON body |

The JavaScript SDK handles all of these: it follows the redirect, a `404` throws `CmsNotFoundError`, and a `5xx` is retried and then throws `CmsUnavailableError`.
