Kwisple
Browse the docs

Content and data

Delivery API

The HTTP API a custom-code site reads its content, brand, privacy settings and forms through, with every endpoint, header, error code and rate limit.

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:

ScopeAllows
readevery read (site, entries, entry, tree, globals, brand, redirects/match, consent, schema GET) and the site-side writes forms, not-found, agent, staging/login
previewdraft content through x-kwisple-preview and GET /api/v1/preview/verify
schemaPOST /api/v1/schema and both seed endpoints (the content model and site kits)

Where tokens come from:

  • kwisple link creates a token with read + preview + schema for your machine and project (running it again replaces it). See CLI reference.
  • kwisple ci-token (or Website → Instellingen → Ontwikkeling → Geavanceerd → CI-token maken) creates a read + schema token 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

HeaderWhereMeaning
Authorization: Bearer <token>every /api/v1/* call except the public browser endpointsthe site token
x-kwisple-preview: <preview token>GET /api/v1/entryserve 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: devany readread 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/loginthe visitor's IP as your server sees it, so the rate limit applies per visitor instead of per site
content-type: application/jsonevery POSTbodies 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.

StatuscodeWhen
400bad_requesta 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)
401unauthorizedno Authorization header, an unknown or revoked token, an invalid or expired preview token, or a wrong staging password
403forbiddenthe token exists but lacks the scope (Dit token heeft geen "preview"-rechten.)
404not_foundno live entry, no redirect, no brand kit, an unknown form entry, a site that is not live (/forms), a staging site without a password
409conflicta 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
429rate_limitedrate limited; a retry-after header says after how many seconds to try again
500server_erroranything 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-preview

They 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: 86400

This 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.

ScopeEndpointsBurstRefillKeyed by
formsPOST /api/v1/forms, hosted POST /api/forms51 per 20 ssite + x-kwisple-client-ip (or the caller's IP)
stagingPOST /api/v1/staging/login51 per 30 ssite + x-kwisple-client-ip (or the caller's IP)
agentPOST /api/v1/agent, POST /api/agent3005 per ssite + caller's IP
collectPOST /api/v1/collect602 per ssite + visitor IP
consentPOST /api/v1/consent101 per 10 ssite + visitor IP
notFoundhosted POST /api/not-found301 per ssite + 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.

ParameterTypeMeaning
sectionhandle, or comma-separated list (repeatable)only these sections
typehandle, or listonly these entry types
localelocale codewhich translation; default the site's default locale
idid, or listonly these entries
slugstringexact slug
parentIdid, null or emptystructures: children of this entry; null (or empty) = top level
levelintegerstructures: only this level
searchstringcase-insensitive match on title and indexed text
relatedToidentries that reference this entry or asset
whereJSON objectfield filters, e.g. {"price":{"gte":10},"tags":"music"}
orderBypostDate desc, postDate asc, title asc, structure, updatedAt desc, or ` ascdesc`
selecthandle listreturn only these fields (metadata always included)
limitintegerdefault 100, clamped to 1…500
offsetintegerdefault 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.

ParameterMeaning
urithe entry's URI (over-ons, nieuws/opening, __home__ for the homepage)
idor its id
localetranslation; 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.

ParameterMeaning
sectionrequired (400 without)
localedefault the default locale
maxLeveldrop 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).

ParameterMeaning
localedefault 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).

ParameterMeaning
pathrequired; 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.

ParameterMeaning
tokenthe 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:

FieldTypeMeaning
formstring, ^[a-zA-Z][a-zA-Z0-9_-]*$, ≤60your form handle (contact, afspraak); the dashboard groups by it
valuesobject of string (≤5000) or boolean, ≤50 keys (≤100 chars each)what the visitor typed
entryIdstringoptional: the entry the form is on; 404 when it is not this site's
websitestring ≤200honeypot field as submitted; should be empty
startedAtinteger (ms epoch)when the visitor opened the form, for the timing heuristic
slotstring, ^[A-Za-z0-9:_.T-]+$, ≤60something 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.

ParameterMeaning
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:

FieldMeaning
schemathe serialised SchemaDef (sections required, globals, forms, apps optional)
forceapply destructive changes (removed or retyped fields, removed sections)
dryRunvalidate 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.

  • GET returns the site's live content as a SiteSeed: { 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 (409 otherwise). At most 500 entries. 409 conflict when the site already has content, unless force. Response SeedResult: { created, globals, problems }, status 422 when 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.

ParameterMeaning
localebanner language; falls back to the default locale
sitepublic 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.

EndpointWhatResponses
GET /api/v1/a/<version>.jsthe statistics script; the current version is immutable for a year, other names get the current script with a 5-minute cache200 JavaScript, 404 for a non-.js name
POST /api/v1/collectstatistics 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 bannersee above
POST /api/v1/consentlogs 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:

RouteWho calls it
POST /api/revalidatethe 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/agentthe 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/exithosted (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.

Last updated 2026-10-30