Skip to content

REST API

View markdown

The public API contract for frontends: how to read content for one site over REST (and GraphQL), what shape it comes back in, and which write-style endpoints a public website may call.

KDIGI CMS is built on Payload, so the generic Payload REST conventions apply (where, limit, page, sort, depth, select, locale). This page documents what is specific to this project. Implementation lives in src/collections/*, src/access/*, src/fields/markdown-html-field.ts and src/endpoints/*; the architecture is in architecture.md.

All examples use https://cms.example.com for the CMS and example-site for a site key.

  • Read anonymously. Frontends must call the API with no cookie and no Authorization header. Anonymous reads are filtered to public content. An authenticated request (admin session, or a user API key — users has useAPIKey: true) switches to tenant-scoped access, which returns drafts and scheduled content for the sites that user belongs to.
  • REST is the contract. Markdown→HTML conversion, shortcode expansion, the menu tree projection and the OG-image fallback only run for REST requests. GraphQL and the Local API return the raw stored shape (GraphQL).
  • Filter by site yourself. Public read predicates do not restrict by site; one CMS serves many sites (multi-site.md). Always add a tenant filter.
  • Responses are Payload's standard paginated list: docs, totalDocs, limit, page, totalPages, hasNextPage, hasPrevPage, nextPage, prevPage, pagingCounter.

Each site is a sites document with a unique siteKey and a domain. The content collections reference it through a relationship field named tenant (the Sites collection is the tenant boundary), whose value is the site's numeric id.

  1. Resolve the site once and cache it:
Terminal window
curl -s 'https://cms.example.com/api/sites?where[siteKey][equals]=example-site&depth=1&limit=1'
  1. Filter every content query by that id:
Terminal window
curl -s 'https://cms.example.com/api/posts?where[tenant][equals]=1&sort=-publishedAt&limit=10&depth=1'

sites is publicly readable; secret leaf fields (r2.accessKeyId, r2.secretAccessKey) have access.read: false and never appear in REST or GraphQL. Public settings such as branding, contact, footer, seoDefaults, knowledgeGraph, seoTitles, breadcrumbs, pageGroup and defaultLanguage are readable — see collections.md for the field list.

CollectionAnonymous readNotes
pages, posts, products, landingsstatus = published, publishedAt empty or <= now, not trashedScheduled posts go live automatically when publishedAt passes. ?trash=true does not widen the result: deletedAt is part of the access predicate (src/access/published-and-live.ts). Do not send trash
taxonomiesall documentsNo status/trash. Filter by type (category, product-category, tag, custom)
authorsall documentsemail is never returned
menusall documentsShape is projected, see Menus
faqs, shortcodesall non-trashed documents
redirectsall documents (including disabled)Filter where[isEnabled][equals]=true
commentsstatus = approved onlyCreate through /api/comments/create
mediaall documents
sitesall documentsSecrets hidden, see above
rating-events, usersnone

Version history (/api/<collection>/versions) requires login and is tenant-scoped; anonymous callers get 403. Frontends must not call it — snapshots contain internal SEO fields.

Terminal window
# list, newest first, one site
curl -s 'https://cms.example.com/api/posts?where[tenant][equals]=1&sort=-publishedAt&limit=12&page=1&depth=1'
# one document by slug (slug is unique per site, not globally)
curl -s 'https://cms.example.com/api/pages?where[tenant][equals]=1&where[slug][equals]=about&limit=1&depth=2'
# only some fields
curl -s 'https://cms.example.com/api/posts?where[tenant][equals]=1&select[title]=true&select[slug]=true&select[publishedAt]=true'
// Node / edge fetch
const res = await fetch(
'https://cms.example.com/api/posts?' +
new URLSearchParams({
'where[tenant][equals]': '1',
'where[slug][equals]': 'hello-world',
limit: '1',
depth: '2',
locale: 'en',
}),
)
const { docs } = await res.json()
const post = docs[0]

Slugs are unique per site among non-trashed documents (partial unique index on deletedAt IS NULL), so always combine a slug filter with the tenant filter.

depth controls how far relationships are populated (featuredImage, categories, author, seo.openGraph.image, …). depth=0 returns bare ids; depth=1 populates one level (enough for media and taxonomies); Payload's REST default is 2. For listings use depth=1 or select to keep payloads small. The OG-image fallback needs no extra depth: it resolves ids itself.

Content locales are vi (default) and en, with fallback enabled in payload.config.ts.

QueryResult
(none)vi
locale=enen; a field with no en value falls back to vi
locale=en&fallback-locale=noneen only; missing values come back empty
locale=allevery locale as { vi, en } objects

Menu item labels also follow locale; see Menus.

Editors write Markdown in the Milkdown editor (content-markdown.md). The API gives frontends ready-to-render HTML at the original field key:

CollectionSource field (Markdown)Field you read (HTML)
posts, pages, products, taxonomies, shortcodescontentMdcontent
productsnoticeMdnotice
landings (blocks of blockType: 'content' in layout[])bodyMdbody
faqs (items[].answer)the stored answer (Markdown)the same key, rendered to HTML on REST

Rules (src/fields/markdown-html-field.ts):

  1. If *Md has text, it wins: shortcodes are expanded, the Markdown is rendered to sanitised HTML, and <img> tags get width/height from the site's media records when known.
  2. If *Md is the empty string, the field is null — the legacy rich-text value is not resurrected.
  3. If *Md is null/absent (document never saved since the Markdown migration), the legacy Lexical value is converted to HTML.
  4. The *Md source field is also present in the response; do not render it directly.

taxonomies.description is plain text. Long descriptions with headings or images belong in contentMd/content.

A shortcode is a reusable Markdown snippet in the shortcodes collection, referenced from Markdown as [sc name="my-snippet"/] (or [sc name="my-snippet"][/sc]). Expansion happens on the REST read, before Markdown rendering:

  • Looked up by name within the document's own site, in the request locale.
  • Nested expansion up to 3 levels. A name that is not found, loops back on itself or exceeds the depth is replaced by nothing.
  • If the document has no site or the tag does not match the syntax, the text is left as is.

Every pages, posts, products, landings and taxonomies document carries a seo group. The shape is a stable contract; details and semantics are in seo.md.

PathNotes
seo.title, seo.descriptionOften empty by design: the CMS stores what the editor typed and the frontend fills the rest from the site's templates (Sites.seoTitles) at render time
seo.focusKeywords[], seo.robots[], seo.canonicalOverride, seo.breadcrumbTitle
seo.breadcrumbs[]{ url, text, isHidden }; auto-generated on save when empty. Skip entries with isHidden: true
seo.openGraph.{title,description,image,imageAlt,siteName,url,type,locale}image is filled at read time: own OG image → featuredImage → Sites.seoDefaults.ogImage
seo.openGraph.twitterMeta.{useFacebookData,card,title,description,image}Nested inside openGraph
seo.articleMeta.{publishedTime,modifiedTime}, seo.updatedTime
seo.jsonLd.rawA complete <script type="application/ld+json">…</script> element containing one schema.org @graph, to be injected verbatim
const jsonLd = post.seo?.jsonLd?.raw // already includes the <script> tag
// <div dangerouslySetInnerHTML={{ __html: jsonLd ?? '' }} /> (or inject into <head> as-is)

Inside the element every < of the JSON payload is escaped as \u003c, so a </script> inside a title cannot break out of the tag. The value is empty until the document has been saved at least once with a resolvable site.

menus documents are one per location per site. Locations: PRIMARY, SECONDARY_MENU, MOBILE_MENU, FOOTER_MENU, LOGGEDIN_ACCOUNT_MENU.

Terminal window
curl -s 'https://cms.example.com/api/menus?where[tenant][equals]=1&where[location][equals]=PRIMARY&locale=vi&limit=1'

Storage is a canonical tree field, but REST never returns tree: it returns items, projected from the tree for the requested locale (src/lib/menu-tree-to-rest.ts). Each item:

{
"id": "6f1c…",
"label": "Services",
"url": "/services",
"path": "/services",
"uri": "/services",
"order": 0,
"parentId": null,
"children": [ { "id": "…", "label": "Web design", "url": "/services/web", "path": "/services/web", "uri": "/services/web", "order": 0, "parentId": "6f1c…", "children": [], "childItems": { "nodes": [] } } ],
"childItems": { "nodes": [ /* same objects as children */ ] }
}
  • url, path and uri carry the same value; children and childItems.nodes carry the same array. Read whichever suits your frontend.
  • label: requested locale → the other locale → the node id (never empty).
  • Nesting is capped at 10 levels. An empty or corrupt tree yields the legacy stored items (usually []) instead of an error.

Reading: GET /api/comments?where[tenant][equals]=1&where[post][equals]=42&sort=createdAt returns approved comments only; authorEmail is never returned. Posts and products expose commentCount (approved comments, maintained by the CMS).

Creating — do not POST /api/comments; use the dedicated public endpoint:

Terminal window
curl -s -X POST 'https://cms.example.com/api/comments/create' \
-H 'Content-Type: application/json' \
-d '{
"postId": "42",
"authorName": "Ada",
"authorEmail": "ada@example.com",
"content": "Great article!",
"tenantSiteKey": "example-site",
"recaptchaToken": "<token from your frontend>"
}'
FieldRequiredNotes
postId or productIdexactly oneDocument id as a string
authorNameyesTruncated to 100 characters
contentyesHTML-sanitised before storage
tenantSiteKeyyesMust equal the parent document's site siteKey, otherwise 403 Tenant mismatch
authorEmailnoTruncated to 200 characters; stored, never exposed
recaptchaTokennoSee below

Response 201: { "success": true, "comment": { "id": …, "status": "pending" } }. Errors: 400 (bad/missing fields or invalid JSON), 403 (site mismatch), 404 (parent not found), 500 (create failed).

Comments are always created pending and appear on the API only after a moderator approves them. If RECAPTCHA_SECRET is set and a token is sent, the endpoint verifies it with Google (5 s timeout) and logs a failing verdict; it never blocks the request. A missing token, a failed verification or a network error only produces a warning. There is no per-IP rate limit on this endpoint, so moderation is the spam control. Setting MODERATION_WEBHOOK_URL makes the CMS POST { "text": "…" } to that URL for each new pending comment.

Terminal window
curl -s -X POST 'https://cms.example.com/api/ratings/submit' \
-H 'Content-Type: application/json' \
-H 'X-Client-IP: 203.0.113.7' \
-d '{ "collection": "posts", "id": "42", "rating": 5, "tenantSiteKey": "example-site" }'
FieldRules
collectionposts or products
idDocument id as an integer-valued string
ratingInteger 1–5
tenantSiteKeyMust match the document's site, else 403

Responses: 201 { "success": true, "kkStarRating": { "avg": 4.5, "count": 12 } } for a new vote, 200 with the unchanged aggregate when this client already voted. The aggregate is also readable on the document as kkStarRating (avg, count, ratings).

Deduplication stores only a SHA-256 hash of the client IP in rating-events. The IP is taken from X-Client-IP (set it from your frontend server so the real browser IP is used), then the rightmost X-Forwarded-For entry, then X-Real-IP. With no resolvable IP, dedup is skipped. No reCAPTCHA is used. Votes do not trigger a frontend revalidate.

Terminal window
curl -s 'https://cms.example.com/api/redirects?where[tenant][equals]=1&where[isEnabled][equals]=true&limit=1000&depth=0'

Fields: from (path with leading /), to (internal path; external URLs are rejected on save), statusCode ('301' or '302', a string), isEnabled. The CMS creates a 301 automatically when a document slug changes (from = old public path with trailing slash, to = new one). Apply the list in your frontend middleware and cap the number of hops — the CMS only warns about chains.

GET /api/media?where[tenant][equals]=1 returns url, alt, width, height, mimeType, filesize, filename. Behaviour of url:

  • When externalUrl is set, url is replaced by it.
  • After an image edit (version > 1) the URL gets ?v=<version> so CDNs refetch.
  • Local-disk storage (the default for a site without R2 settings): url is absolute, {PAYLOAD_PUBLIC_SERVER_URL}/api/media/file/<filename> (plus ?t=<site> for site-scoped files), served by the CMS itself.
  • R2 storage (site has Sites.r2 settings): url is {Sites.r2.publicBaseUrl}/media/<siteId>/… when a public base URL is configured.

Details and the cache-bust interaction are in media-storage.md. The cloud-storage plugin may add a nullable _objectKey field to media documents.

GET /api/sites/:siteKey/env-page?key=FRONT_PAGE returns the pages document that the site's pageGroup maps to that key (FRONT_PAGE, BLOG, PROFILE, CONTACT, ABOUT, SEARCH, SIGN_IN, SIGN_UP, FORGOT_PASSWORD, CONFIRM_EMAIL, PASSWORD_CHANGE, TERM_CONDITION, PRIVACY_POLICY). The page is looked up by slug within that site and read with the caller's access, so anonymous callers get published pages only. content is already HTML with shortcodes expanded. Response is null for an unknown site, key or page; 400 if key is missing.

When content changes the CMS tells the frontend to refresh its cache. This is a webhook your frontend must expose:

POST https://{Sites.domain}/api/cms/revalidate
Content-Type: application/json
{ "collection": "posts", "id": "42", "slug": "hello-world" }
  • Sent by the revalidateFrontend job (architecture.md) after a save or delete of pages, posts, products, landings, taxonomies, faqs, shortcodes, menus, authors; after a site settings change; and when a comment on a post becomes (or stops being) approved. A slug rename sends a second request for the old slug. Collections without a slug (menus, faqs) send the document id as slug; treat those as "revalidate the whole site".
  • The URL is always https:// plus the site's domain field. A site with no domain triggers nothing.
  • Timeout 5 s. Any non-2xx response or network error fails the job and is retried (3 attempts, exponential backoff).
  • The request is not authenticated. Make your handler idempotent and cheap, and rate-limit or verify it at your edge if that matters to you.
  • Requires ENABLE_JOBS_AUTORUN=true on exactly one instance.

Admins can also trigger the same ping from the editor (POST /api/revalidate-frontend, logged-in only; body { "manual": true } is sent to the frontend). Outside production it falls back to http://localhost:3002/api/cms/revalidate when the site has no domain.

GET /api/frontend-doc-url?collection=posts&slug=hello-world&tenantId=1 (logged-in only) returns { "url": "https://example.com/hello-world" } for the editor's "View on site" link. The base URL is FRONTEND_BASE_URL, else https://{Sites.domain}, else http://localhost:3002. Paths are built by publicPathFor in src/lib/frontend-public-path.ts: /product/{slug} for products, /{slug} for posts, pages and landings. If your frontend uses different routes, change that one function.

POST /api/graphql exposes the same collections and access rules. Production disables the playground (/api/graphql-playground) and caps query complexity at 500.

Terminal window
curl -s https://cms.example.com/api/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ Posts(where: { tenant: { equals: 1 } }, limit: 5) { docs { title slug contentMd } } }"}'

Because the Markdown, menu and OG-fallback hooks are REST-only, GraphQL returns the raw shape:

FieldGraphQL gives youREST gives you
content, notice, bodyhidden legacy rich-text JSONHTML
contentMd, noticeMd, bodyMdMarkdown source — render it yourselfMarkdown source
menus.treecanonical tree JSONnot returned
menus.itemsstored legacy arrayprojected from the tree
seo.openGraph.imagestored value onlywith featured-image / site-default fallback

Shortcodes are not expanded over GraphQL. Prefer REST for rendering pages.

GET /api/health → 200 {"status":"ok"}. It does not query the database, so it stays green during restores and migrations.