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.
| Key | Reads | Use it |
|---|---|---|
ck_pub_… | Published content | On 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.
# 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"const { data, meta, nextCursor } = await citron.collection('products').list({
locale: 'bg',
populate: ['related'],
richtext: 'html',
});
for (const product of data) console.log(product.slug, product.data.name);
console.log(`page ${meta.page} of ${meta.pageCount}`, nextCursor);{
"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,altandvariants(named renditions such asthumb). Image fields addfocal, the editor’s crop hint from 0 to 1. - A relation field holds the related entry’s id. Name it in
populateand it holds the entry instead, with itscollectionslug. 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
404for 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"// Throws CitronError (status 404) when there is no such published entry
const product = await citron.collection('products').get('bio-6', { locale: 'en', richtext: 'json' });
// Resolves null instead of throwing on 404
const maybe = await citron.collection('products').find('bio-6', { locale: 'en' });
console.log(product.id, maybe?.slug);{
"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"const settings = await citron.collection('site-settings').single({ locale: 'bg' });
console.log(settings.data);Query parameters#
| Parameter | Values | Notes |
|---|---|---|
locale | A workspace locale, e.g. bg | Flattens localized fields to that locale. An unknown locale answers 400. |
filter[field], filter[field][op] | See filters | At most 10. |
sort | field or -field | One field. publishedAt, createdAt, updatedAt, sortOrder, slug or any field that is not an object or a list. |
page | 1, 2, … | Default 1. |
pageSize | 1 to 100 | Default: the collection’s own list size. |
populate | Relation field keys, comma-separated | Up to 10, top-level relation fields. |
fields | data keys, comma-separated | id, slug, publishedAt, updatedAt and sortOrder always come. |
richtext | html, json, both | Default both. |
status | draft | Only with a ck_prev_ key; otherwise 403. |
preview | A preview token | Same 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.
{
"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 type | Operators | Notes |
|---|---|---|
| Text, select, date, slug, rich text, media, relation | eq ne lt lte gt gte contains in notIn | contains is a case-insensitive substring match. in and notIn take comma-separated values. Comparison is textual, so ISO dates sort correctly. |
| Number | eq ne lt lte gt gte in notIn | Values must be numbers. |
| Boolean | eq ne | true or false. |
| Multi-select, multi-relation, list | eq contains | Both 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"const { data, meta } = await citron.collection('products').list({
locale: 'en',
filter: {
category: 'station', // filter[category]=station
capacity: { gte: 5 }, // filter[capacity][gte]=5
code: { in: ['BIO-6', 'BIO-10'] }, // filter[code][in]=BIO-6,BIO-10
},
sort: '-publishedAt', // or { field: 'publishedAt', order: 'desc' }
page: 1,
limit: 20, // sent as pageSize
});
console.log(meta.total, data.length);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.
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.
# 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 -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#
Signed preview links#
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 header | Meaning |
|---|---|
draft | The token is valid; drafts were served. |
invalid | The token is malformed, expired or from another workspace; published content was served. |
other-collection | The 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-storeimport { citronTags } from '@citron/sdk/next';
/** previewToken: the ?preview= value the console put in your preview URL. */
export async function getProduct(slug: string, previewToken?: string) {
return citron.collection('products').find(
slug,
previewToken
? { headers: { 'x-citron-preview': previewToken }, fetchOptions: { cache: 'no-store' } }
: { fetchOptions: { next: { revalidate: 3600, tags: citronTags('products', slug) } } },
);
}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.
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.