REST API
The REST API every krantho site serves under /api — authentication, roles, the endpoint groups, and the OpenAPI 3.1 spec and Scalar reference generated from the router.
Every krantho site serves a REST API under /api. The admin dashboard is a client of it, and so is the krantho CLI. Your own scripts and services can use it the same way: list and edit content, publish, upload media, read SEO data.
The API comes from @krantho/api. You do not wire it route by route — the server package for your framework mounts all of it from one catch-all route:
import { createApiRouteHandlers } from '@krantho/server-next'
import config from '../../../krantho.config'
import { collections } from '../../../krantho.collections'
// The content API at /api/*: content, media, SEO, auth and the sitemap
export const { GET, POST, PUT, DELETE, PATCH } = createApiRouteHandlers({
config,
collections,
})
// The admin at /admin, in app/admin/[[...slug]]/route.ts:
//
// import { createAdminRouteHandler } from '@krantho/server-next'
// export const { GET } = createAdminRouteHandler()import { createApiHandlers } from '@krantho/server-sveltekit'
import config from '../../../../krantho.config'
import { collections } from '../../../../krantho.collections'
// The content API at /api/*: content, media, SEO, auth and the sitemap
export const { GET, POST, PUT, DELETE, PATCH } = createApiHandlers({
config,
collections,
// Editors preview drafts through a short-lived signed link
sitePreview: 'signed-url',
})
// The admin at /admin, in src/routes/admin/[...path]/+server.ts:
//
// import { createAdminHandler } from '@krantho/server-sveltekit'
// export const { GET } = createAdminHandler()import express from 'express'
import { createAdminServer, createHandler, toWebRequest, writeResponseHeaders } from '@krantho/server-node'
import config from './krantho.config'
import { collections } from './krantho.collections'
const api = createHandler({ config, collections, sitePreview: 'signed-url' })
const admin = createAdminServer(config)
const app = express()
async function send(res: express.Response, response: Response): Promise<void> {
res.status(response.status)
writeResponseHeaders(res, response)
res.end(Buffer.from(await response.arrayBuffer()))
}
const owns = (prefix: string, path: string) => path === prefix || path.startsWith(`${prefix}/`)
// Mounted without a path: the handler routes on the full URL
app.use(async (req, res, next) => {
if (owns('/api', req.path)) return send(res, await api.handle(await toWebRequest(req)))
if (!owns('/admin', req.path)) return next()
const url = new URL(`http://localhost${req.originalUrl}`)
return send(res, admin({ url, cookie: req.headers.cookie }))
})The API follows the configuration you pass there: auth decides who may call it, media.provider whether the media routes exist, site.baseUrl where the generated spec says the API lives.
Your API's own reference#
Two routes describe the API of the running site, and neither needs authentication:
| Route | What it serves |
|---|---|
GET /api/openapi.json | An OpenAPI 3.1 document generated from the site's router |
GET /api/docs | A Scalar API reference that loads that document, where you can read and try the routes |
The spec covers every route this site registered at startup behind the authentication check — content, versions, schema, SEO and administration, plus the media routes when media is on, site preview when it is enabled, and the routes your plugins add. The public routes for signing in and capabilities are not part of it. Each operation is tagged by group (Content, Versions, Media, SEO and so on). The servers entry is site.baseUrl, and when auth.mode is not none the spec declares bearer and cookie authentication.
The spec names paths, methods and path parameters. Request bodies are declared as plain JSON objects and responses by status code only, so the sections below give the shapes you will use most. Every GET route also answers HEAD, with the same status and headers and no body.
Authentication#
auth.mode picks one of three modes.
| Mode | Reads | Writes |
|---|---|---|
none (default) | Open | Open — every caller is treated as an admin |
token | Open, unless auth.requireAuthForReads is on | A session from the shared secret KRANTHO_API_TOKEN |
oidc | Open, unless auth.requireAuthForReads is on | A session from your OpenID Connect provider |
A session is a signed JWT. The API looks for it first in the krantho_access cookie (the name follows auth.session.cookiePrefix), then in an Authorization: Bearer <token> header. In token mode you exchange the shared secret for a session with POST /api/auth/login; the answer sets the session cookies:
# Log in with the shared secret; the cookies land in cookies.txt
curl -c cookies.txt -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"token":"'"$KRANTHO_API_TOKEN"'"}'
# Who am I?
curl -b cookies.txt http://localhost:3000/api/auth/meThe access token lives for auth.session.accessTtlSec (15 minutes by default). POST /api/auth/refresh exchanges the refresh cookie for a new pair; GET /api/auth/refresh?returnTo=/path does the same for a page navigation and redirects back, or to a new sign-in when the refresh token is no longer valid, and POST /api/auth/logout clears the session. In OIDC mode, GET /api/auth/login/oidc starts the sign-in and GET /api/auth/callback completes it — see The admin dashboard for setting up a provider.
Roles#
Each user has roles: admin, editor or viewer, in that order of rank. A higher role includes the ones below it.
- viewer — reads. A caller with no session on an open API is an anonymous viewer.
- editor — creates, updates, publishes and unpublishes content, sees what a publish refreshes, imports documents into a collection, takes and releases their own editing locks, edits, replaces, duplicates and moves media and manages media folders, creates checkpoints, regenerates SEO files and opens site preview.
- admin — deletes content, restores versions, releases anyone's editing lock, edits runtime schema fields, SEO configuration and enrichment, exports and imports the whole-site bundle, deletes media in bulk and deletes media folders, restores and deletes checkpoints, and manages users, settings, version purging and the webhook delivery log, including retrying a failed delivery.
A few writes ask for a session but no particular role: uploading, registering and deleting a single media asset, and resolving a content migration conflict.
A request without the role it needs gets 403; a write without a session gets 401.
Public routes#
These answer without a session in every mode:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/capabilities | What this site supports: persistence, auth mode, features |
| GET | /api/openapi.json | The generated OpenAPI 3.1 spec |
| GET | /api/docs | The Scalar API reference |
| GET | /api/auth/capabilities | The auth mode and how to sign in |
| POST | /api/auth/login | Exchange the shared secret for a session (token mode) |
| POST | /api/auth/refresh | Rotate an expiring session |
| GET | /api/auth/refresh | Rotate the session and redirect back (?returnTo=/path), or to a new sign-in |
| GET | /api/auth/login/oidc | Start OIDC sign-in (optional ?returnTo=/path) |
| GET | /api/auth/callback | Complete OIDC sign-in |
| POST | /api/auth/logout | End the session |
| GET | /api/auth/me | The signed-in identity |
Content#
| Method | Path | Purpose |
|---|---|---|
| GET | /api/content/:collection | List documents |
| GET | /api/content/:collection/:slug | One document |
| POST | /api/content/:collection | Create a document; 409 when the slug already has one in that locale |
| PUT | /api/content/:collection/:slug | Update a document |
| DELETE | /api/content/:collection/:slug | Delete a document |
| POST | /api/content/:collection/:slug/publish | Publish, unpublish or archive a document |
| GET | /api/content/:collection/:slug/publish-impact | What publishing a document would refresh |
| GET | /api/content/search | Search every collection (?q=) |
The list takes status, locale, sort, limit and offset as query parameters and answers { items, total }. A single document takes locale, and resolve=true resolves its reference fields. Each document has the shape described in Content fields: meta, fields, body, html and sections.
The content API returns the current version of a document: the newest one, which is what the admin edits. Your pages read the live version through the content loader instead. meta.version is the current version and meta.liveVersion the version visitors see, or null when nothing is live:
meta.liveVersion | What visitors see |
|---|---|
equal to meta.version | The current version |
lower than meta.version | An older version; the current one has unpublished changes |
null | Nothing; the document is not published |
meta.status follows from the live version: published while a version is live, otherwise draft, or archived for an archived document. When the collection's schema has a status field, it states the same draft or published state; for an archived document it says archived when the schema allows that value, otherwise draft.
Create and update take JSON:
# Create a document; nothing is live yet
curl -b cookies.txt -X POST http://localhost:3000/api/content/pages \
-H 'Content-Type: application/json' \
-d '{"slug":"privacy","fields":{"title":"Privacy","description":"What krantho.com stores about its visitors."},"body":"## Scope\n\nThis page covers krantho.com."}'
# Publish it
curl -b cookies.txt -X POST http://localhost:3000/api/content/pages/privacy/publish \
-H 'Content-Type: application/json' \
-d '{"status":"published"}'Fields are validated against the collection's schema; a document that does not pass gets 422 with the problems in details. An update may carry _version — the version you read. If someone saved a newer version in the meantime, the update answers 409 with currentVersion and yourVersion instead of overwriting their work. An update without a body key keeps the body and merges the fields you send into the current ones.
Saving and publishing#
Create and update save: each appends a new current version and never changes what visitors see. A status you send is not used — the document's status is decided by publishing. Both answer with the saved version and the liveVersion, so you can tell whether the document now has unpublished changes:
{ "slug": "privacy", "collection": "pages", "version": 3, "liveVersion": 2, "message": "Updated" }The publish route changes what visitors see of one document in one locale (?locale=, the site's default locale when absent). Its body's status says what to do:
status | Effect |
|---|---|
published | The current version becomes live |
draft | Unpublish: nothing is live |
archived | Archive: nothing is live, and the document is kept as archived |
| absent | Toggle between published and draft |
Each one appends a version, so the history shows who published or unpublished and when. It answers with the document's state afterwards:
{ "slug": "privacy", "collection": "pages", "locale": "en", "status": "published", "version": 4, "liveVersion": 4, "message": "Published" }POST /api/content/:collection/bulk-publish takes { "slugs": [...] } and an optional locale (the default locale when absent), and publishes the current version of each document on its own; one that fails does not stop the rest. It answers { results, published }, with published: true or an error per slug.
Create and update fire the create or update webhook, publishing fires publish, and unpublishing or archiving fires update; a restore or an import fires none. There is no scheduled publishing.
What a publish refreshes#
GET /api/content/:collection/:slug/publish-impact?locale= (editor) tells you, before you publish, which cached pages and data the publish would refresh:
{
"collection": "pages",
"slug": "privacy",
"locale": "en",
"paths": ["/privacy"],
"lists": ["pages"],
"everyPageReading": false,
"caching": "next-isr",
"tags": ["k:d:pages:privacy", "k:c:pages"],
"cachedEntries": 3
}paths holds the document's page, empty when its collection renders no pages. everyPageReading is true for such a document, such as a global: every page that reads it refreshes. caching is cache.pages, and tags are the cache tags the publish refreshes. cachedEntries counts the cached pages and data that carry those tags; it is a number only when the instances share their page cache in the database (cache.shared: 'database') and page caching is on, and null otherwise, because krantho does not know which pages a CDN or a single instance holds.
Versions, locks and bulk operations#
| Method | Path | Purpose |
|---|---|---|
| GET | /api/content/:collection/:slug/versions | Version history of a document |
| GET | /api/content/:collection/:slug/versions/:version | One version |
| POST | /api/content/:collection/:slug/restore/:version | Restore a version as the newest one |
| POST | /api/content/:collection/:slug/lock | Take or renew the editing lock |
| GET | /api/content/:collection/:slug/lock | Who holds the lock |
| DELETE | /api/content/:collection/:slug/lock | Release the lock |
| GET | /api/locks | Every editing lock currently held |
| POST | /api/content/:collection/bulk-publish | Publish several documents |
| DELETE | /api/content/:collection/bulk | Delete several documents |
A restore is a save: the restored content becomes the current version, and the live version stays as it was. Publish it to make it live.
An editing lock is held by one user per document and locale (?locale= on each lock route), and expires after content.lock.leaseMs. While someone else holds an unexpired lock, saving, publishing, unpublishing, archiving, restoring and deleting the document answer 423:
{ "error": "pages/privacy is being edited by another user", "lockedBy": "editor:ada@example.com", "expiresAt": "2026-10-02T09:15:00.000Z" }The holder, and anyone once the lock has expired or been released, can write. A bulk delete leaves such a document in place and reports it with deleted: false and the error, and meta-description enrichment skips it. Taking a lock someone else holds also answers 423. An editor can release only their own lock; an admin can release anyone's, such as a lock left behind. Releasing another user's lock as an editor answers 403. GET /api/locks (viewer) answers { locks }, each with collection, slug, locale, lockedBy, lockedAt and expiresAt; expired locks are not listed.
Import and export#
| Method | Path | Purpose |
|---|---|---|
| GET | /api/content/:collection/export | Export a collection as JSON, or Markdown with ?format=markdown |
| POST | /api/content/:collection/import | Import documents into a collection |
| GET | /api/export/bundle | Export the whole site as one bundle |
| POST | /api/import/bundle | Import a whole-site bundle |
Importing into a collection saves each document, so what visitors see changes only when you publish it. A whole-site bundle also publishes the items it marks published.
Schema, pages and site#
| Method | Path | Purpose |
|---|---|---|
| GET | /api/collections | The registered collections, with their pages flag and designed sections |
| GET | /api/schema/:collection | A collection's fields, from code and runtime |
| GET | /api/schema/:collection/runtime | Only the runtime fields |
| GET | /api/schema/:collection/export | The merged schema as Zod source |
| PUT | /api/schema/:collection/fields | Add or update a runtime field; 400 when the definition does not hold, 409 when it changes a field's type |
| DELETE | /api/schema/:collection/fields/:field | Remove a runtime field |
| POST | /api/validate | Validate all content against its schemas |
| GET | /api/page | Resolve a URL path to a document (?route=) |
| GET | /api/site-config | The site configuration and the collections |
A runtime field can add to a collection but never change a field its code defines; that attempt answers 409. Each collection in /api/collections lists the ## sections its page template designs, in order, as the collection declares them — an empty array when it declares none. See Templates.
Media#
The media routes exist only when media.provider is set — see Media.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/media | List assets (limit, offset, search, folder, tag) |
| POST | /api/media/upload | Upload files as multipart form data |
| POST | /api/media/register | Register an asset hosted elsewhere by its absolute URL |
| GET, PUT, DELETE | /api/media/:id | Read, update or delete one asset |
| POST | /api/media/:id/replace | Replace the file and keep the id |
| POST | /api/media/:id/duplicate | Copy an asset |
| PUT | /api/media/:id/move | Move an asset to another folder |
| GET | /api/media/:id/usage | Documents that use an asset |
| GET | /api/media/usage?ids=a,b | Documents that use each of several assets, read in one pass |
| GET | /api/media/:id/image | An optimized image variant |
| GET | /api/media/:id/image/info | Image dimensions and format |
| GET, POST | /api/media/folders | List or create folders |
| PUT, DELETE | /api/media/folders/:id | Rename or delete a folder |
| PUT, DELETE | /api/media/bulk | Update or delete several assets |
| GET | /api/media/tags | Tags in use |
| GET | /api/media/info | The file store in use, and the library's file count, total size and each folder's count |
Pages embed the stable image URL, /api/media/:id/image?preset=xl. It redirects to a versioned URL that is cached for a year, so a replaced image shows on the next page view while unchanged images stay cached. preset is xs, sm, md (the default), lg, xl or full; fmt picks avif, webp, jpeg or png, and without it the format is negotiated from the browser's Accept header.
SEO#
| Method | Path | Purpose |
|---|---|---|
| GET | /api/seo/status | Which generated SEO files exist |
| POST | /api/seo/generate | Regenerate the files the server can build |
| GET | /api/seo/structured-data | Generated JSON-LD |
| GET | /api/seo/preview/:collection/:slug | The JSON-LD of one document, in its locale (?locale=) |
| GET | /api/seo/resolved/:collection/:slug | Each structured-data property of a document, with its value and where it comes from |
| GET | /api/seo/findings/:collection/:slug | One document's SEO findings |
| GET | /api/seo/findings | The SEO of every published page |
| GET | /api/seo/sitemap | XML sitemap |
| GET | /api/seo/sitemap.json | JSON sitemap |
| GET | /api/seo/robots.txt | robots.txt naming the sitemap at /sitemap.xml, or the saved override |
| GET | /api/seo/llms.txt | llms.txt for AI assistants |
| GET | /api/seo/indexnow/:key | The IndexNow key file, served at /<key>.txt |
| GET | /api/seo/catalog | The Schema.org archetype catalog |
| GET, PUT, DELETE | /api/seo/config | All SEO configurations |
| GET, PUT, DELETE | /api/seo/config/:id | One SEO configuration |
| GET | /api/seo/collection/:name/impact | What a collection's configuration affects |
| POST | /api/seo/enrich | Start an AI enrichment job |
| GET | /api/seo/enrich/status | Progress of the enrichment job |
In /api/seo/resolved/:collection/:slug, each property carries a scope that says where its value comes from: site, collection, route (a URL-pattern configuration), document, computed (derived by krantho from the page and its history) or linked (a reference to another entity).
GET /api/seo/findings (viewer) checks every published page in the default locale, as search engines read it. It answers { pages, readers }: per page its collection, slug and path, its findings counted per reader (readers, each { errors, warnings, info }; title and meta description findings count under page, Schema.org vocabulary findings under schema.org), and the search features its entities can appear in. Each feature names the reader, feature, type and nodeId, its status (active, or restricted when the reader shows it only under conditions), whether the entity is eligible, and the properties it is missing; a feature a reader has retired is not listed. readers lists the site's active readers with their names. Without @krantho/seo the route answers 501.
The sitemap lists the published version of each document in every collection except globals, leaving out pages marked noindex.
The robots.txt override is the robotsTxt of the site's SEO configuration, kept beside its entities, so no page's structured data carries it. An admin saves it with PUT /api/seo/config/site, sending the stored configuration with robotsTxt set, and removes it by saving the configuration without it; any other scope refuses robotsTxt with 400. While it is set, GET /api/seo/robots.txt serves it as written.
GET /api/seo/llms.txt serves the snapshot saved through the assistant when there is one, otherwise a guide generated from the live pages. The x-krantho-llms-origin response header says which: snapshot or generated. A site serves /llms.txt by routing it to this path.
Enrichment#
Enrichment runs as a background job on the server and needs a language model configured under llm — see The SEO pipeline. POST /api/seo/enrich (admin) takes { type, scope, collection, slug, force }:
typeismeta-descriptions(the default) orarchetype, for the Schema.org properties of the document's types.scopeisall,collectionoritem. Without it, aslugmeansitem, acollectionalone meanscollection, and neither meansall. Acollectionscope enriches only that collection's documents, and anitemonly the document with that slug in that collection.force: truewrites over values already set: forarchetypethe Schema.org properties, formeta-descriptionsthe document's description field, but never a description held in its SEO configuration. Without it, enrichment fills only what is missing.
It answers 202 with { jobId, status, type, totalItems }, or 200 with jobId: null when the scope has nothing to enrich. An unknown scope, a collection or item scope without a configured collection, an item without a slug, or a language model that cannot be created answers 400; a second job while one runs answers 409. Poll GET /api/seo/enrich/status?jobId= for the progress and each document's outcome.
Enrichment saves each meta description as a new version of the document, and also writes it to the document's SEO configuration unless that already holds a description; archetype writes the Schema.org properties there too. That configuration has no versions, so the live page's meta description and structured data change at once, without a publish. A document someone else holds the edit lock of keeps its meta description.
Administration#
| Method | Path | Purpose |
|---|---|---|
| GET, POST | /api/checkpoints | List or create checkpoints of the content store |
| GET, DELETE | /api/checkpoints/:version | Read or delete a checkpoint |
| POST | /api/checkpoints/:version/restore | Restore the content store to a checkpoint |
| GET, POST | /api/users | List or create users |
| GET, PUT, DELETE | /api/users/:id | Read, update or delete a user |
| GET, PUT | /api/users/:id/permissions | A user's permissions |
| GET, PUT | /api/settings/locales | The site's locales, and which configured ones are active |
| GET, PUT, DELETE | /api/settings/llm | Language model settings |
| POST | /api/settings/llm/test | Test the language model connection |
| GET | /api/versions/stats | Version storage statistics |
| POST | /api/versions/purge | Purge old versions, keeping content.versions.keep per document |
| GET | /api/translations/status | Translation coverage across locales |
| GET | /api/webhooks/deliveries | Webhook delivery log |
| POST | /api/webhooks/deliveries/:id/retry | Send a failed delivery again |
| GET | /api/migrations/status | Database schema version |
| GET | /api/migrations/conflicts | Unresolved content migration conflicts |
| POST | /api/migrations/conflicts/:id/resolve | Resolve a conflict |
GET /api/settings/locales answers { defaultLocale, configuredLocales, supportedLocales, source }. defaultLocale is always site.defaultLocale from the configuration, and every request that names no locale uses it. configuredLocales is site.supportedLocales; supportedLocales are the active ones, either those an admin saved or the configured list. PUT (admin) takes { "supportedLocales": [...] } and answers 400 with the reason when the list is empty, repeats a locale, names a locale the configuration does not list, or leaves out the default locale. The default locale is changed only in the configuration.
POST /api/webhooks/deliveries/:id/retry (admin) puts a failed delivery — one that used all its attempts without being delivered — back in the queue and sends it again at once, signed afresh, to the configured webhook with the same URL. It answers 202 with { delivery }, now pending; the delivery log then shows whether it was delivered or failed again. A delivery that is not failed, or whose URL no configured webhook has any more, answers 409, and a site that logs no deliveries answers 404.
Site preview#
When the site renders drafts for editors, GET /api/preview?collection=&slug= sends an editor to the document's page in preview. @krantho/server-next always turns it on, with Next.js draft mode. SvelteKit and Express sites turn it on by passing sitePreview: 'signed-url' to the handler, which sends the editor through a short-lived signed link. /api/capabilities reports the mode as features.sitePreview, and Studio's Live mode and Open on site use the route when it is enabled. The route takes no locale: a preview shows the document's version in the site's default locale. See The admin dashboard, and Live editing for the marks the admin's Live mode reads from a preview.
Plugins#
Plugins add their own routes under /api/plugins/<plugin name>/, and GET /api/plugins lists the loaded plugins with their routes, admin pages and field types. Plugin routes appear in the generated spec like any other.
Errors#
An error answers with a status code and a JSON body:
{ "error": "Validation failed", "details": [] }| Status | Meaning |
|---|---|
400 | The request is malformed, such as a body that is not a JSON object |
401 | A session is required, or it has expired |
403 | The session lacks the role this route needs, or an editor tried to release another user's lock |
404 | The collection, document, version or asset does not exist |
409 | A conflict: a newer version exists, a job is already running, or a code-defined field was targeted |
422 | The fields failed the collection's schema |
423 | Another user holds the document's editing lock |
501 | The persistence backend does not support this operation |