The delivery API is served by the platform's public web app (apps/web) under /api/v1/*. It is the only way a custom-code site talks to the platform: the SDK client is a thin typed wrapper around it, and everything the SDK does can be done with plain HTTP.
The base URL is the value kwisple link writes to KWISPLE_URL (production: the platform's web address, for example https://sites.kwisple.com; locally http://localhost:4101). See Environment variables and tokens.
Authentication
Every /api/v1/* call carries a site API token as a bearer token:
GET /api/v1/entries?section=news HTTP/1.1
Authorization: Bearer kwi_…Tokens belong to one site and have one or more scopes:
| Scope | Allows |
|---|---|
read | every read (site, entries, entry, tree, globals, brand, redirects/match, consent, schema GET) and the site-side writes forms, not-found, agent, staging/login |
preview | draft content through x-kwisple-preview and GET /api/v1/preview/verify |
schema | POST /api/v1/schema and both seed endpoints (the content model and site kits) |
Where tokens come from:
kwisple linkcreates a token withread + preview + schemafor your machine and project (running it again replaces it). See CLI reference.kwisple ci-token(or Website → Instellingen → Ontwikkeling → Geavanceerd → CI-token maken) creates aread + schematoken for CI.- The API-tokens card under Website → Instellingen → Ontwikkeling lists the site's tokens with their prefix, scopes and last use, and revokes them.
Tokens start with kwi_; tokens issued before the rename (spk_…, rva_…) keep working. Only a hash is stored, the secret is shown once. A revoked token stops working within 60 seconds (the API keeps verified tokens in memory that long). Tokens of a site in the trash, or of a client in the agency's trash, stop working immediately with that purge.
The API never sets cookies and has no session: a token is the whole identity. Keep it on your server. Only the browser endpoints listed under Public browser endpoints run without a token.
Request headers
| Header | Where | Meaning |
|---|---|---|
Authorization: Bearer <token> | every /api/v1/* call except the public browser endpoints | the site token |
x-kwisple-preview: <preview token> | GET /api/v1/entry | serve the previewed entry from the editor's draft. Needs the preview scope. The preview token is the one the dashboard puts in the preview URL; it is signed by the platform and lives one hour. See Live preview and revalidation |
x-kwisple-schema: dev | any read | read with the site's development model (what kwisple dev pushes) instead of the live one. Never cached. The SDK sends it when KWISPLE_SCHEMA=dev. See Workflow: dev, push and changes |
x-kwisple-client-ip: <ip> | POST /api/v1/forms, POST /api/v1/staging/login | the visitor's IP as your server sees it, so the rate limit applies per visitor instead of per site |
content-type: application/json | every POST | bodies are JSON |
Response conventions
Success
Every endpoint answers JSON. The shapes are listed per endpoint below; the TypeScript types are in @kwisple/sdk (SiteInfo, TreeNode, Brand, PrivacyInfo, PushResult, SiteSeed, SeedResult, RemoteSchema, and Entry<…> from your schema).
Errors
Failures return { "error": { "code", "message", …extra } } with the matching HTTP status. Messages are Dutch sentences meant for a developer's log, not for visitors.
| Status | code | When |
|---|---|---|
| 400 | bad_request | a missing or malformed parameter or body; for /entries also a where/orderBy the content model cannot answer; for /forms a typed form that does not validate (problems: string[] is added) |
| 401 | unauthorized | no Authorization header, an unknown or revoked token, an invalid or expired preview token, or a wrong staging password |
| 403 | forbidden | the token exists but lacks the scope (Dit token heeft geen "preview"-rechten.) |
| 404 | not_found | no live entry, no redirect, no brand kit, an unknown form entry, a site that is not live (/forms), a staging site without a password |
| 409 | conflict | a booking slot that is already taken, a schema or seed push on a site that is not custom-code, a seed on a site that already has content |
| 422 | — | POST /schema and POST /seed return their normal result shape with problems filled in and status 422 |
| 429 | rate_limited | rate limited; a retry-after header says after how many seconds to try again |
| 500 | server_error | anything unexpected; details are only logged on the platform |
Caching headers
Reads that may be cached answer with:
cache-control: private, max-age=60, stale-while-revalidate=600
vary: authorization, x-kwisple-previewThey are deliberately private: every answer is bearer-authenticated, so a shared cache (CDN) must never store it. Your own server-side cache (Next data cache, the Astro memory cache) is the right place; see Caching. Answers that must not be cached at all (/entry in preview, /redirects/match, /forms/{form}/slots, the staging and schema endpoints) carry cache-control: private, no-store.
Server-side the platform caches every read per site and query for at most an hour and purges it on publish, schema push and Cache legen (clear cache). Reads with x-kwisple-schema: dev bypass that cache.
CORS
Every /api/v1/* route answers OPTIONS with:
access-control-allow-origin: *
access-control-allow-methods: GET, POST, OPTIONS
access-control-allow-headers: authorization, content-type, x-kwisple-preview, x-kwisple-schema
access-control-max-age: 86400This exists for tooling and previews, not as an invitation to call the API from a browser: the token would be visible to every visitor. Call it from your server. POST /api/v1/staging/login has no OPTIONS handler. The public browser endpoints (collect, consent) use origin-specific CORS instead (below).
Rate limits
Limits are token buckets per site and client IP (apps/web/src/lib/public-limits.ts). "Burst" is the bucket size, "refill" how fast it fills back up. Without a trusted proxy header on the platform (TRUST_PROXY=1) the client IP is unknown and the whole site shares one bucket ten times the size. Over the limit you get 429 with retry-after.
| Scope | Endpoints | Burst | Refill | Keyed by |
|---|---|---|---|---|
forms | POST /api/v1/forms, hosted POST /api/forms | 5 | 1 per 20 s | site + x-kwisple-client-ip (or the caller's IP) |
staging | POST /api/v1/staging/login | 5 | 1 per 30 s | site + x-kwisple-client-ip (or the caller's IP) |
agent | POST /api/v1/agent, POST /api/agent | 300 | 5 per s | site + caller's IP |
collect | POST /api/v1/collect | 60 | 2 per s | site + visitor IP |
consent | POST /api/v1/consent | 10 | 1 per 10 s | site + visitor IP |
notFound | hosted POST /api/not-found | 30 | 1 per s | site + visitor IP |
POST /api/v1/not-found and the content reads are not rate limited beyond the cache; they are authenticated.
Body size caps: /forms 64 KB, /staging/login 4 KB, /collect 2 KB, /consent 1 KB; over the cap is 413.
Endpoints
All paths below are relative to KWISPLE_URL. "Scope" is the token scope needed.
GET /api/v1/site
Scope read. Basic facts about the site; the SDK calls it once per client to map request paths to locales and to drive the staging gate.
Response:
{
"site": {
"id": "site_…",
"name": "Bakkerij Janssens",
"mode": "headless",
"siteUrl": "https://bakkerijjanssens.be",
"locales": ["nl", "fr"],
"defaultLocale": "nl",
"isLive": true,
"staging": null
}
}staging is null for a production site. For a staging site it is { url, protected, version }: its address, whether a password is set, and a string naming the current password (sessions from before a password change stop working). See Staging. Cached (public profile).
GET /api/v1/entries
Scope read. Live entries of the site, filtered, sorted and paginated. Every parameter is optional. Full semantics in Querying entries.
| Parameter | Type | Meaning |
|---|---|---|
section | handle, or comma-separated list (repeatable) | only these sections |
type | handle, or list | only these entry types |
locale | locale code | which translation; default the site's default locale |
id | id, or list | only these entries |
slug | string | exact slug |
parentId | id, null or empty | structures: children of this entry; null (or empty) = top level |
level | integer | structures: only this level |
search | string | case-insensitive match on title and indexed text |
relatedTo | id | entries that reference this entry or asset |
where | JSON object | field filters, e.g. {"price":{"gte":10},"tags":"music"} |
orderBy | postDate desc, postDate asc, title asc, structure, updatedAt desc, or ` | desc` |
select | handle list | return only these fields (metadata always included) |
limit | integer | default 100, clamped to 1…500 |
offset | integer | default 0 |
Response: { "items": Entry[], "total": number }. total is the count before limit/offset. Status is always "live" (enabled, not before postDate, not after expiryDate); drafts are only reachable through /entry with a preview token.
Errors: 400 bad_request when where is not JSON or not an object, names a field the queried sections do not have, uses an unknown operator, or when orderBy names a field that cannot be sorted. Cached.
GET /api/v1/entry
Scope read (preview for drafts). One live entry.
| Parameter | Meaning |
|---|---|
uri | the entry's URI (over-ons, nieuws/opening, __home__ for the homepage) |
id | or its id |
locale | translation; default the site's default locale |
One of uri or id is required (400). Response { "entry": Entry }; 404 not_found when nothing live lives there.
With x-kwisple-preview: <token> and a token that has the preview scope, the platform checks that the preview token belongs to this site (401 otherwise) and, when the requested entry is the one the token was issued for, answers { "entry": Entry, "preview": true } from the editor's draft (never cached, no-store). Any other entry in the same request is served live as usual, so a previewed page's header and footer stay as published. A preview request without the scope is 403.
GET /api/v1/tree
Scope read. The structure tree of a section, for menus.
| Parameter | Meaning |
|---|---|
section | required (400 without) |
locale | default the default locale |
maxLevel | drop entries deeper than this level |
Response { "items": TreeNode[] } where a node is { id, section, type, title, slug, uri, url, children }. url is the public path with locale prefix. Live entries only, in structure order. Cached.
GET /api/v1/globals
Scope read. Every global set, resolved like entries (assets, links and matrix blocks included).
| Parameter | Meaning |
|---|---|
locale | default the default locale |
Response { "globals": { "<handle>": { …fields } } }. Cached; also purged when a global is saved.
GET /api/v1/brand
Scope read. The workspace's brand kit compiled for a site: CSS variables, fonts, logos, colours, elements and voice. Response { "brand": Brand }; 404 not_found when the workspace has no brand kit yet. The shape is documented in Brand kit and privacy data. Cached; purged when the brand is saved.
GET /api/v1/redirects/match
Scope read. Redirect lookup for a path that has no page (what the dashboard calls Doorverwijzingen).
| Parameter | Meaning |
|---|---|
path | required; the request path (/oude-pagina). A scheme and host are stripped, a trailing slash removed |
Response { "to": "/nieuwe-pagina", "statusCode": 301 } with statusCode one of 301, 302, 410; 404 not_found when no redirect matches. An exact match wins over the longest prefix match; for a prefix redirect the rest of the path is appended to to unless to is absolute. Every hit is counted (hits, lastHitAt in the dashboard). Not cached on the platform (no-store); the SDK caches answers and misses for five minutes.
POST /api/v1/not-found
Scope read. Logs a 404 so editors can add a redirect from the dashboard.
Body: { "path": string (1…500), "referrer"?: string (≤500) }. Response 202 { "recorded": true }. The log keeps at most 5000 distinct paths per site; the least recently hit are pruned. The 404 is also counted in the statistics (Pagina's die niet bestaan). The SDK skips this call on staging.
POST /api/v1/agent
Scope read. Your server saw a request from an AI agent; the platform decides which user agents count. Rate limit agent.
Body: { "path": string (1…500), "userAgent": string (≤500) }. Response 202 { "recorded": boolean, "agent": string | null } with the agent's key (gptbot, claudebot, …) or null when the user agent is not a known AI agent. Nothing about a person is stored. See Statistics and consent.
GET /api/v1/preview/verify
Scope preview. Used by the site's own /api/preview route to check the token the dashboard sent.
| Parameter | Meaning |
|---|---|
token | the preview token |
Response { "entryId", "locale", "url", "expiresAt" }. url is the entry's public path, or /__preview?entry=<id>&locale=<locale> for an entry that was never published and has no URI yet. 401 when the token is invalid, expired or for another site; 404 when the entry no longer exists. Never cached.
POST /api/v1/forms
Scope read. Stores a form submission from your server. Rate limit forms, body ≤ 64 KB. Full guide: Forms, bookings and confirmations.
Body:
| Field | Type | Meaning |
|---|---|---|
form | string, ^[a-zA-Z][a-zA-Z0-9_-]*$, ≤60 | your form handle (contact, afspraak); the dashboard groups by it |
values | object of string (≤5000) or boolean, ≤50 keys (≤100 chars each) | what the visitor typed |
entryId | string | optional: the entry the form is on; 404 when it is not this site's |
website | string ≤200 | honeypot field as submitted; should be empty |
startedAt | integer (ms epoch) | when the visitor opened the form, for the timing heuristic |
slot | string, ^[A-Za-z0-9:_.T-]+$, ≤60 | something only one submission may claim (a booking moment) |
confirm | { to: email ≤200, subject: 1…150, text: 1…4000 } | a confirmation mail to the visitor; to must be one of the values |
Responses: 201 { "ok": true, "id": "fsub_…" } when stored; 202 { "ok": true } when the submission was dropped as spam (a bot learns nothing); 400 bad_request with problems: string[] when the form is declared in the content model and the values do not match it; 404 not_found when the site is not live (a staging site accepts forms while not live); 409 conflict when slot is already taken.
GET /api/v1/forms/{form}/slots
Scope read. Slots already claimed by non-spam submissions of a form, for a booking calendar.
| Parameter | Meaning |
|---|---|
from | ≤60 chars; keep slots >= from (string comparison) |
to | ≤60 chars; keep slots <= to including every slot that starts with to (2026-11 covers the whole month) |
Response { "slots": string[] } sorted ascending, at most 2000. 400 for an invalid form handle or period. no-store.
POST /api/v1/staging/login
Scope read, staging sites only. The SDK's staging gate asks whether a typed password is right. Rate limit staging, body ≤ 4 KB.
Body { "password": string (1…200) }. Response { "ok": true, "version": "<password version>" }; 401 unauthorized when wrong; 404 not_found when this token's site is not a staging site or has no password. See Staging.
GET /api/v1/schema
Scope read. The content model the platform serves for this token (the development model when x-kwisple-schema: dev is sent and one exists).
Response { "version": number, "source": "code" | "standard" | "none", "hash": string | null, "schema": SchemaDef | null }. hash is the first 16 hex characters of the SHA-256 of the model's JSON; the SDK compares it with your cms.config.ts. Never cached.
POST /api/v1/schema
Scope schema, custom-code sites only (409 conflict otherwise). What kwisple push and kwisple dev call.
Body:
| Field | Meaning |
|---|---|
schema | the serialised SchemaDef (sections required, globals, forms, apps optional) |
force | apply destructive changes (removed or retyped fields, removed sections) |
dryRun | validate and diff without storing |
env | "dev": store as the development model; "production" or omitted: the live model |
Response: PushResult plus hash: { applied, version, changes: SchemaChange[], problems: string[], unchanged, hash, env? }. Status 200, or 422 when problems is not empty (then nothing was applied). A production push that applied clears the platform cache of the site. See Workflow: dev, push and changes.
GET /api/v1/seed and POST /api/v1/seed
Scope schema. Site kits (kwisple export / kwisple seed), see Site kits: seed and export.
GETreturns the site's live content as aSiteSeed:{ version: 1, locale?, entries: [{ key?, section, type?, parent?, title?, slug?, fields?, seo? }], globals? }. Files and links between entries are left out.POST { "seed": SiteSeed, "force"?: boolean }writes a kit into a custom-code site (409otherwise). At most 500 entries.409 conflictwhen the site already has content, unlessforce. ResponseSeedResult:{ created, globals, problems }, status422when nothing could be created. The site's cache is purged afterwards.
GET /api/v1/consent
Scope read, or public (below). The site's consent configuration and statistics settings, generated by the platform, in one locale.
| Parameter | Meaning |
|---|---|
locale | banner language; falls back to the default locale |
site | public calls only: the site key (site_…) |
Response: PrivacyInfo ({ consent: ConsentConfig, analytics: { enabled, requiresConsent, scriptUrl, collectUrl } }), documented in Brand kit and privacy data. With a token the answer is private, max-age=60, stale-while-revalidate=600; the public variant is public, max-age=300, stale-while-revalidate=3600.
Public browser endpoints
These run in the visitor's browser without a token. The site is found by the Host header (hosted sites) or by the public site key k/site (the site id), and the request's Origin and page URL must belong to the site (its platform subdomain, verified domains or configured site URL; localhost on any port in development). CORS answers echo the allowed origin with access-control-allow-credentials: true. Details in Statistics and consent.
| Endpoint | What | Responses |
|---|---|---|
GET /api/v1/a/<version>.js | the statistics script; the current version is immutable for a year, other names get the current script with a 5-minute cache | 200 JavaScript, 404 for a non-.js name |
POST /api/v1/collect | statistics beacon (text/plain JSON, ≤ 2 KB, sendBeacon-compatible) | 204 in every "not counted" case too; 413 too large, 400 bad JSON, 403 wrong origin, 429 |
GET /api/v1/consent?site=&locale= | consent configuration for the banner | see above |
POST /api/v1/consent | logs a consent choice (≤ 1 KB) | 204; 400, 403, 404, 409 (a version newer than the current one), 413, 429 |
Other routes on the web app
Not part of the delivery API, listed so you recognise them:
| Route | Who calls it |
|---|---|
POST /api/revalidate | the dashboard, after every content change, with the platform's own REVALIDATE_SECRET; purges the platform caches. Your site has a route of the same name for the signed publish webhook, see Caching |
POST /api/agent | the web app's own proxy over loopback, signed, for AI agents on hosted sites |
POST /api/forms, POST /api/not-found, GET /api/preview, GET /api/preview/exit | hosted (legacy block) sites rendered by the platform |
GET /api/health | { "ok": true } for monitoring |
Example: plain fetch
const res = await fetch(`${process.env.KWISPLE_URL}/api/v1/entries?section=news&limit=5&select=intro,image`, {
headers: { authorization: `Bearer ${process.env.KWISPLE_TOKEN}` },
});
if (!res.ok) {
const { error } = (await res.json()) as { error: { code: string; message: string } };
throw new Error(`${error.code}: ${error.message}`);
}
const { items, total } = (await res.json()) as { items: unknown[]; total: number };The same call with the SDK is client.entries({ section: "news", limit: 5, select: ["intro", "image"] }), typed by your cms.config.ts; see SDK client.