Delivery API
Public and cached at the edge; read-only apart from form submissions and uploads. Base URL: https://cdn.blokcms.app/v1/<spaceId>.
Authentication
- Public tokens (
blok_pub_…): published content. SendAuthorization: Bearer …or?token=…(avoids CORS preflights). - Preview tokens (
blok_prv_…): also drafts (draft=1) and release previews (release=rel_…). Header only; keep them server-side. - Tokens can be restricted to origins. Requests are rate limited per token (1,200 per minute).
Endpoints
| GET | Returns |
|---|---|
/status | Space id, cache version (cv), locales. |
/entries/by-path/<path> | One entry by its (localized) path, e.g. /entries/by-path/blog/hello. |
/entries/<id> | One entry by id. |
/entries | A page of entries (see list parameters). |
/tree | Navigation tree: under, depth, locale. |
/lists/<key> | An option list (value and localized label). |
/globals/<key> | The single entry of a global (site settings, footer, UI text, …). Takes the common parameters. |
/globals | Several globals at once, by key, with one included: keys (comma-separated, at most 20; each must exist and be published; every published global when omitted) and the common parameters. |
/menus/<key> | A navigation menu with links resolved for locale. |
/menus | Several menus at once: keys (comma-separated; all when omitted), locale. |
/redirects | Every redirect for locale (shared ones and that language's own), targets resolved: see Redirects. |
/sitemap | Published, indexable entries with their path in every locale: locale, types (comma-separated). |
/forms/<key> | A form's fields and texts, ready to render: locale. See Forms. |
/search?q=… | Published entries ranked by meaning (spaces with AI switched on): locale, type, limit (up to 20). |
Common parameters
locale | Language. Missing translations fall back along the locale's fallback chain, then the default. |
include | Comma-separated reference fields to embed (included.entries). |
fields | Only return these root fields. |
cv | Pin the cache version (from a webhook or /status); such responses are immutable. |
draft=1, release | Preview tokens only. Never cached. |
List parameters
type | Content type key. |
under | Folder path, e.g. /blog. |
tags | Comma-separated; all must match. |
search | Full-text search. |
sort | publishedAt, firstPublishedAt, name, path, position or fields.<key>; prefix - for descending; comma-separated. Default -publishedAt. |
filter[field][op] | Operators eq ne in nin contains gt gte lt lte exists; filter[featured]=true means eq. Up to 20. |
limit, cursor | Page size and the nextCursor of the previous page. |
Globals
A type with the kind Global has exactly one entry, outside the page tree: it never appears in /entries, /tree, /sitemap or by path. Use it for what every page shares.
const { entry, included } = await blok.getGlobal("settings");
const { siteName, footerText } = entry.content.fields;
Collections
A collection is a content type managed as a table in the admin (products, blog posts, companies, team members), with bulk publishing and CSV import and export. Its items are ordinary entries, so you read them with /entries: filter, sort and page as usual. Collections whose items aren't pages (switched off in the collection's settings) are left out of /tree and /sitemap.
const { items } = await blok.listEntries({ type: "company", sort: "name", limit: 100 });
Menus
Menus (edited under Site in the admin) are trees of up to three levels. Each item comes with a ready-to-use href (page path in the requested language, URL, mailto: or file URL), a target and its children. Items linking to pages that aren't published are left out, and so are headings left without children; deleting a page takes it out of the menus for good. Saving a menu publishes it and sends a menu.updated webhook.
const { menus } = await blok.getMenus({ keys: ["main", "footer"], locale: "nl" });
menus.main.items.map((item) => ({ ...item, active: isMenuItemActive(item, pathname) }));
Redirects
Redirects (under Site → Redirects in the admin) send an old path on to a page or a URL. When a published page gets another path (a new slug, a move, a renamed folder), its old path redirects to it by itself, in every language; redirects to a page follow it wherever it moves, so there are no chains. A page that takes a path wins over a redirect from it. A redirect to a deleted page answers 410 (gone); one to an unpublished page is left out until it's published again. Matching ignores the query string, letter case and a trailing slash. Every change bumps cv and sends a redirect.updated webhook.
Sites that render every path through one page need no list: /entries/by-path answers an old path with a 404 that carries the redirect, and getEntryByPath throws a BlokError with redirect.
{ "error": { "code": "not_found", "message": "Entry not found" }, "redirect": { "to": "/bedrijven/varo-nv", "status": 301 } }
try {
return await blok.getEntryByPath(path);
} catch (err) {
if (err instanceof BlokError && err.redirect) {
if (err.redirect.to === null) return gone(); // 410
return redirect(err.redirect.to, err.redirect.status); // 301 or 302
}
throw err;
}
Sites that apply redirects before routing (a proxy or middleware, a Worker, _redirects written at build time) fetch them all and match with matchRedirect:
const { redirects } = await blok.getRedirects(); // [{ from: "/varonv", to: "/bedrijven/varo-nv", status: 301 }]
const hit = matchRedirect(redirects, url.pathname); // case, trailing slash and query don't matter
SEO and sitemaps
Give a type an seo field (title, description, sharing image, noindex). /sitemap leaves out entries whose SEO field has noindex, and sitemapXml() in @blokcms/client turns the result into sitemap.xml with hreflang alternates.
const { items } = await blok.getSitemap({ types: ["page", "post"] });
return new Response(sitemapXml(items, "https://www.example.com"), { headers: { "Content-Type": "application/xml" } });
Forms
Forms are built in the admin; the forms guide covers rendering them with <BlokForm>, styling, translations and plain HTML posts. With a public token:
GET /forms/<key> | The form in a locale: fields (labels, options and help resolved along the locale chain), the spam trap's name, Turnstile site key, messages, and status (scheduled, open, closed, full). Closed forms are delivered too, so sites can show their closed message. |
POST /forms/<key>/submissions | A submission, as JSON or form data (application/x-www-form-urlencoded, multipart/form-data). Repeated names become lists. Never cached. |
POST /forms/<key>/uploads | One file for a file field (multipart: field and file). Returns { "id", "name", "size", "type" }; submit the id as the field's value. Unused uploads are deleted after a day. |
form fields in entries are delivered as { "form": "contact", "definition": { … } }: the same form as GET /forms/contact, in the entry's locale (null when the form was deleted).
Besides the form's fields, a submission can carry:
cf-turnstile-response | Turnstile token, required when the form uses Turnstile (the widget adds it to the form for you). |
_page, _locale | Where the form was sent from (defaults to the Referer) and its language (success message, consent wording, confirmation email). |
_redirect, _redirect_error | For plain HTML posts: answer with a 303 to these pages instead of JSON (errors add ?error=<code>). Only URLs on the token's own origins. |
Spam trap (default website) | Render it hidden and empty. Submissions that fill it in get a normal response but are dropped. |
A valid submission returns { "ok": true, "successMessage": … }, is stored in the admin's inbox, emailed to the form's notification addresses and sent as a form.submitted webhook. Invalid fields return 422 validation_failed with a code per field in details.fields:
{
"error": {
"code": "validation_failed",
"message": "Some fields need attention",
"details": {
"fields": {
"email": { "code": "invalid_email", "message": "must be an email address" },
"message": { "code": "too_long", "message": "must be at most 5000 characters", "params": { "max": 5000 } }
}
}
}
}
Forms that don't take submissions answer 409: form_not_open, form_closed, form_full, or already_submitted (one per email). A missing or invalid Turnstile token is 422 turnstile_failed. Requests are rate limited per visitor (10 a minute, uploads included) and per form. All codes are in the forms guide.
await blok.submitForm("contact", { firstName: "Jan", email: "jan@example.com", message: "Hello", privacy: true });
Example responses
What a site gets back, here from the starter template. The admin shows the live response for any page, global, collection, menu or form in its API panels, published or as drafts.
GET /v1/<space>/entries/by-path/
{
"entry": {
"id": "ent_01M4R43N18PTAWZ63SFJDDT7PK",
"type": "page",
"name": "Home",
"slug": "home",
"path": "/",
"parentId": null,
"locale": "en",
"tags": [],
"position": 1,
"publishedAt": "2026-10-12T13:16:22.954Z",
"firstPublishedAt": "2026-10-12T13:16:22.954Z",
"content": {
"id": "blk_01M4R43N17EWVYWY143FHNJKJZ",
"type": "page",
"fields": {
"title": "Duwijckpark",
"body": [
{
"id": "blk_01M4R43N17TRPEB3Y8KH3CZPJ4",
"type": "hero",
"fields": {
"headline": "Welcome to Duwijckpark",
"cta": { "kind": "url", "url": "https://docs.blokcms.app/quickstart.html" },
"ctaLabel": "Read the quickstart"
}
},
{
"id": "blk_01M4R43N17D83JHKMVEYS7CQBY",
"type": "text",
"fields": {
"body": {
"type": "doc",
"content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Pages are built from blocks…" }] }]
}
}
}
]
}
}
},
"included": { "entries": {}, "links": {} },
"cv": 1791810982962
}
GET /v1/<space>/entries?type=company&limit=10
{
"items": [ { "id": "ent_…", "type": "company", "name": "…", "content": { … } } ],
"included": { "entries": {}, "links": {} },
"nextCursor": "eyJ…",
"total": 42,
"cv": 1791810982962
}
GET /v1/<space>/globals/settings
{
"entry": {
"id": "ent_01M4R43N15XWESCCGPMCVXNPKX",
"type": "settings",
"name": "Site settings",
"path": "@settings",
"content": {
"id": "blk_01M4R43N1CCHK0JXP92KWNMVQF",
"type": "settings",
"fields": { "siteName": "Duwijckpark", "footerText": "© Duwijckpark" }
},
…
},
"included": { "entries": {}, "links": {} },
"cv": 1791810982962
}
GET /v1/<space>/globals?keys=settings,ui_text&locale=nl
{
"globals": {
"settings": { "id": "ent_…", "type": "settings", "path": "@settings", "content": { "fields": { "siteName": "Duwijckpark", … } }, … },
"ui_text": { "id": "ent_…", "type": "ui_text", "path": "@ui_text", "content": { "fields": { "texts": { "cart.add": "In winkelmand", "readMore": "Read more" } } }, … }
},
"included": { "entries": {}, "links": {} },
"cv": 1791810982962
}
Dictionary fields (texts above) are translated per key: a key without a translation in the requested locale falls back along the locale's fallbacks to the default text. With @blokcms/client: t(texts, "cart.add").
GET /v1/<space>/menus/main
{
"menu": {
"key": "main",
"name": "Main menu",
"items": [
{
"id": "mni_01M4R43N1G1X6YJJAJ0E424AWE",
"label": "Home",
"href": "/",
"target": null,
"entryId": "ent_01M4R43N18PTAWZ63SFJDDT7PK",
"children": []
}
]
},
"cv": 1791810982962
}
/menus returns { "menus": { "main": { … }, "footer": { … } }, "cv": … }; /sitemap returns { "locale": "en", "items": [{ "id", "type", "path", "updatedAt", "alternates": { "en": "/" } }], "cv": … }.
Responses and caching
Entries have content (the localized block tree, assets expanded with URLs and focal points) and included.links to turn entry links into URLs. Every publish bumps the space's cache version (cv): responses are cached per version at the edge, so a publish is visible worldwide within seconds without purges. X-Blok-Cache shows whether a response came from the edge (HIT-L1), the global cache (HIT-L2) or the origin (MISS).
Images
Asset fields are delivered as { "asset", "url", "filename", "mime", "size", "width", "height", "focal", "crop", "alt", "title", "credit", "placeholder", "color" }. Build other sizes from url with assetUrl(asset, { width }) or assetSrcSet(asset) from @blokcms/client (or <BlokImage>): widths round up to 160, 320, 480, 640, 768, 1024, 1280, 1600, 1920, 2560 or 3840; presets (thumb, card, hero, og and your own, under Settings → Media) crop at the focal point. AVIF or WebP is picked from the browser's Accept header.
When editors crop an image for one usage, crop holds the crop (fractions of the original), width/height and focal describe the cropped image, and url carries a signed crop query. Keep that query string when you build URLs (the helpers do): only crops saved in content render. color is the image's average color, a cheap background while it loads.
Errors
{ "error": { "code": "not_found", "message": "Entry not found" } }
A 404 for a path that has a redirect also carries redirect (see Redirects).
Status codes: 400 invalid query or body, 401 bad token, 403 origin or draft not allowed, 404, 409 form not open, full or already submitted, 413 body too large, 415 unsupported body type, 422 invalid submission, 429 rate limited.