Reference

REST delivery API

Two read-only routes serve every collection in your workspace. Your site calls them with an API key; editors never need to know they exist.

Endpoints#

The base URL is https://api.console.citronos.com. {collection} is the collection’s slug, its API name in the console. Renaming the slug changes the path, so the console warns before it lets you.

GET/v1/content/{collection}

Published entries of a collection, one page at a time. For a singleton collection it answers with the document itself.

GET/v1/content/{collection}/{slugOrId}

One published entry, by slug or by id.

Authentication#

Send a delivery key in Authorization: Bearer … or in x-api-key. Create keys in the console under Settings → Developers → API keys. The full key is shown once; Citron keeps only its hash.

KeyReadsUse it
ck_pub_…Published contentOn a server, at the edge or at build time. Rate-limited per key.
ck_prev_…Published content and drafts (status=draft)On a server you control only. Never ship it to a browser.

A missing, unknown or revoked key answers 401.

curl
# Either header works
curl https://api.console.citronos.com/v1/content/products \
  -H "Authorization: Bearer $CITRON_API_KEY"

curl https://api.console.citronos.com/v1/content/products \
  -H "x-api-key: $CITRON_API_KEY"

List entries#

curl "https://api.console.citronos.com/v1/content/products?locale=bg&populate=related&richtext=html" \
  -H "Authorization: Bearer $CITRON_API_KEY"
Response
{
  "data": [
    {
      "id": "cmf3k9x2p0004s601q8w7e2rt",
      "slug": "bio-6",
      "publishedAt": "2026-09-21T09:30:00.000Z",
      "updatedAt": "2026-09-21T09:30:00.000Z",
      "sortOrder": 0,
      "data": {
        "code": "BIO-6",
        "category": "station",
        "inCapacityTable": true,
        "name": "Пречиствателна станция BIO 6",
        "body": "<p>Компактна станция за една къща.</p>",
        "capacity": 6,
        "specRows": [{ "label": "Капацитет", "value": "6 ЕЖ" }],
        "cover": {
          "id": "cmf3k7a1b0001s601m2n3b4v5",
          "url": "https://cdn.example.com/media/bio-6.webp",
          "mimeType": "image/webp",
          "width": 1600,
          "height": 900,
          "alt": "Станция BIO 6",
          "variants": { "thumb": "https://cdn.example.com/media/bio-6-thumb.webp" },
          "focal": { "x": 0.5, "y": 0.4 }
        },
        "related": [
          {
            "id": "cmf3k9x2p0007s601z5x4c3v2",
            "slug": "bio-10",
            "publishedAt": "2026-09-18T12:00:00.000Z",
            "updatedAt": "2026-09-18T12:00:00.000Z",
            "sortOrder": 1,
            "collection": "products",
            "data": {
              "code": "BIO-10",
              "name": "Пречиствателна станция BIO 10",
              "capacity": 10,
              "related": ["cmf3k9x2p0004s601q8w7e2rt"]
            }
          }
        ]
      }
    }
  ],
  "meta": { "total": 2, "page": 1, "pageSize": 20, "pageCount": 1 }
}

Every entry has id, slug, publishedAt, updatedAt, sortOrder and data, which holds the collection’s fields. meta carries total, page, pageSize and pageCount. Without sort, the order set on the collection in the console applies.

  • Image and media fields arrive as assets: id, url, mimeType, width, height, alt and variants (named renditions such as thumb). Image fields add focal, the editor’s crop hint from 0 to 1.
  • A relation field holds the related entry’s id. Name it in populate and it holds the entry instead, with its collection slug. Populated entries are shallow (their own relations stay ids) and always published.
  • Live collections that read from an external system are console-only; the delivery API answers 404 for them.

Get one entry#

curl "https://api.console.citronos.com/v1/content/products/bio-6?locale=en&richtext=json" \
  -H "Authorization: Bearer $CITRON_API_KEY"
Response
{
  "data": {
    "id": "cmf3k9x2p0004s601q8w7e2rt",
    "slug": "bio-6",
    "publishedAt": "2026-09-21T09:30:00.000Z",
    "updatedAt": "2026-09-21T09:30:00.000Z",
    "sortOrder": 0,
    "data": {
      "code": "BIO-6",
      "category": "station",
      "name": "Treatment plant BIO 6",
      "body": {
        "type": "doc",
        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "A compact plant for one house." }] }]
      },
      "capacity": 6,
      "datasheet": {
        "id": "cmf3k7a1b0002s601p9o8i7u6",
        "url": "https://cdn.example.com/media/bio-6.pdf",
        "mimeType": "application/pdf",
        "width": 0,
        "height": 0,
        "alt": "Datasheet",
        "variants": {}
      },
      "related": ["cmf3k9x2p0007s601z5x4c3v2"]
    }
  }
}

The last path segment matches the slug or the id. An entry that does not exist, or has never been published, answers 404.

Singleton collections#

A singleton holds one document, such as site settings or a home page. Its list URL answers { "data": { … } } with the document instead of a list, and 404 until the document is published.

curl "https://api.console.citronos.com/v1/content/site-settings?locale=bg" \
  -H "Authorization: Bearer $CITRON_API_KEY"

Query parameters#

ParameterValuesNotes
localeA workspace locale, e.g. bgFlattens localized fields to that locale. An unknown locale answers 400.
filter[field], filter[field][op]See filtersAt most 10.
sortfield or -fieldOne field. publishedAt, createdAt, updatedAt, sortOrder, slug or any field that is not an object or a list.
page1, 2, …Default 1.
pageSize1 to 100Default: the collection’s own list size.
populateRelation field keys, comma-separatedUp to 10, top-level relation fields.
fieldsdata keys, comma-separatedid, slug, publishedAt, updatedAt and sortOrder always come.
richtexthtml, json, bothDefault both.
statusdraftOnly with a ck_prev_ key; otherwise 403.
previewA preview tokenSame as the x-citron-preview header, see draft preview.

Locales and rich text#

Without locale, every localized field is a map of all your locales, and rich text carries both formats. With locale=bg you get the Bulgarian value; where it is empty, the default locale’s value.

Response without locale (abridged)
{
  "data": [
    {
      "id": "cmf3k9x2p0004s601q8w7e2rt",
      "slug": "bio-6",
      "publishedAt": "2026-09-21T09:30:00.000Z",
      "updatedAt": "2026-09-21T09:30:00.000Z",
      "sortOrder": 0,
      "data": {
        "code": "BIO-6",
        "name": { "bg": "Пречиствателна станция BIO 6", "en": "Treatment plant BIO 6" },
        "body": {
          "bg": { "html": "<p>Компактна станция.</p>", "json": { "type": "doc", "content": [] } },
          "en": { "html": "<p>A compact plant.</p>", "json": { "type": "doc", "content": [] } }
        }
      }
    }
  ],
  "meta": { "total": 2, "page": 1, "pageSize": 20, "pageCount": 1 }
}

richtext=html returns rich text as an HTML string, richtext=json as the editor document (ProseMirror JSON), and both, the default, as { "html", "json" }. Older entries may hold a plain HTML string whatever you ask for.

Filters#

filter[field]=value means equals; filter[field][op]=value picks an operator. You can filter on id, slug and any field except object, geo and JSON fields. Filters combine with AND. An unknown field or an operator the field type does not support answers 400.

Field typeOperatorsNotes
Text, select, date, slug, rich text, media, relationeq ne lt lte gt gte contains in notIncontains is a case-insensitive substring match. in and notIn take comma-separated values. Comparison is textual, so ISO dates sort correctly.
Numbereq ne lt lte gt gte in notInValues must be numbers.
Booleaneq netrue or false.
Multi-select, multi-relation, listeq containsBoth match entries whose list contains the value.

A localized field is compared in the requested locale, or in the default locale when there is none. There is no fallback here: an entry with no value in that locale does not match.

# -g stops curl from treating [ ] as a glob
curl -g "https://api.console.citronos.com/v1/content/products?locale=en\
&filter[category]=station\
&filter[capacity][gte]=5\
&filter[code][in]=BIO-6,BIO-10\
&sort=-publishedAt&page=1&pageSize=20" \
  -H "Authorization: Bearer $CITRON_API_KEY"

curl reads [ and ] as a glob pattern. Pass -g, or encode them as %5B and %5D.

Pagination#

Pages are numbered from 1 and hold at most 100 entries. Stop when page reaches meta.pageCount. The SDK adds an opaque nextCursor and an all() iterator on top of the same page numbers.

TypeScript SDK
import type { ListResult } from '@citron/sdk';

// Page by page with the opaque cursor
let cursor: string | null = null;
do {
  const page: ListResult<Record<string, unknown>> = await citron.collection('products').list({ limit: 100, cursor });
  console.log(page.data.length);
  cursor = page.nextCursor;
} while (cursor);

// Or let the SDK walk every page
for await (const product of citron.collection('products').all({ fields: ['code', 'name'] })) {
  console.log(product.slug);
}

Choosing fields#

fields keeps only the data keys you list, which makes list pages smaller. It combines with populate and locale.

curl
# Only these data keys (id, slug, publishedAt, updatedAt, sortOrder always come)
curl "https://api.console.citronos.com/v1/content/products?locale=en&fields=code,name,cover" \
  -H "Authorization: Bearer $CITRON_API_KEY"

Caching and ETags#

Published responses carry Cache-Control: public, max-age=60, stale-while-revalidate=300 and an ETag. Send the ETag back in If-None-Match and an unchanged answer is an empty 304. Draft and preview answers are no-store. To show changes the moment an editor publishes, cache longer on your side and revalidate with webhooks.

curl
curl -i "https://api.console.citronos.com/v1/content/products?locale=en" \
  -H "Authorization: Bearer $CITRON_API_KEY" \
  -H 'If-None-Match: "3f1c9e0a8b7d6c5e4f3a2b1c0d9e8f7a6b5c4d3e"'
# HTTP/1.1 304 Not Modified   (when nothing changed)

Draft preview#

Give a collection a preview URL in the console, with any of the placeholders {slug}, {id}, {locale}, {token} and {collection}, for example https://example.com/{locale}/products/{slug}?preview={token}. Open preview on an entry opens that address with a signed token (cpt1.…). If the template has no {token}, the token is added as ?preview=.

Your page hands the token back in the x-citron-preview header (or ?preview=) together with its ordinary ck_pub_ key. The API then serves drafts of that one collection, uncached. Tokens expire after 30 minutes by default (24 hours at most) and only work for your workspace and the collection they were issued for.

X-Citron-Preview response headerMeaning
draftThe token is valid; drafts were served.
invalidThe token is malformed, expired or from another workspace; published content was served.
other-collectionThe token was issued for another collection; published content was served.

A bad token never causes an error: the answer is exactly the published one, so an old preview link simply shows the live page.

# The token arrives in your preview URL: https://example.com/products/bio-6?preview=cpt1.…
curl -i "https://api.console.citronos.com/v1/content/products/bio-6?locale=en" \
  -H "Authorization: Bearer $CITRON_API_KEY" \
  -H "x-citron-preview: cpt1.eyJvIjoi….k1Qm…"
# X-Citron-Preview: draft
# Cache-Control: no-store

Preview keys#

A ck_prev_ key with status=draft (in the SDK, preview: true) reads the drafts of every collection. Use it for build-time previews on a server you control, never in a browser.

TypeScript SDK
import { createClient } from '@citron/sdk';

// Server only: a ck_prev_ key reads every draft of the workspace
const drafts = createClient({
  baseUrl: 'https://api.console.citronos.com',
  apiKey: process.env.CITRON_PREVIEW_KEY, // ck_prev_…
  preview: true, // sends status=draft
});

const draft = await drafts.collection('products').find('bio-6');
console.log(draft?.updatedAt);

Limits#

Each key may make 300 requests per minute by default; REST and GraphQL share the budget. See errors and rate limits for what a 429 looks like.

This reference covers the public API only and follows the API’s compatibility contract: public responses only ever gain fields. The OpenAPI description of these routes is at /developers/openapi.json.