DocsAPI reference

Using Polaris

API reference

The Polaris CMS HTTP API: authentication, every action of /api/cms/posts with its request and response fields, uploads, media, backups, errors and status codes.

The admin and your scripts use the same API. Every endpoint lives on your own site, under /api/cms/.

Conventions#

  • Every endpoint is POST with a JSON body { "action": "…", … }, except upload (multipart form data) and the optional cron endpoint (GET).
  • Responses are JSON with Cache-Control: no-store.
  • Errors are { "error": "message" } with a 4xx or 5xx status. See Errors.
  • The newest version answers both /api/cms/posts and /api/cms/posts/, so a client never hits a redirect on POST.
  • Dates are ISO 8601 strings. Ids of posts and backups are integers; media ids are UUIDs.

In the examples:

SITE=https://example.com
KEY=sc_0123456789abcdef0123456789abcdef01234567   # Admin → API Keys

Authentication#

Send an API key in either header:

Authorization: Bearer sc_0123…
x-api-key: sc_0123…

A key is a prefix plus 40 hex characters (sc_ in the newest version, cms_ in the kits). Create keys in Admin → API Keys: the raw key is shown once and only its SHA-256 hash is stored. Revoked keys stop working at once, and each key records when it was last used.

The admin itself authenticates with the studio_session cookie set by login. Every endpoint below accepts the cookie; most also accept a key.

Endpoint Method API key Session
/api/cms/posts POST yes yes
/api/cms/upload POST (multipart) yes yes
/api/cms/media POST yes yes
/api/cms/backup POST yes yes
/api/cms/indexnow POST yes yes
/api/cms/indexing POST yes yes
/api/cms/keys POST no yes
/api/cms/login, /api/cms/logout POST n/a n/a
/api/cron/daily GET Bearer $CRON_SECRET n/a

A key can never create, list or revoke keys.

Posts#

POST /api/cms/posts with one of seven actions.

action Body Returns
list lang? Live posts as link targets
list-all lang? Every post, drafts and scheduled included, without the body
get id One full post
save post Creates, or updates when post.id is set
set-status id, status, publishedAt? { "ok": true }
delete id { "ok": true }
recompute-scores none { "updated": n }

lang ("en" or "pl") filters list and list-all in the newest version; leave it out for every language. The kits have no lang.

list#

Live posts (published, or scheduled and due), newest first. Built for agents that need internal-link targets.

curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"action":"list","lang":"en"}'
{
  "posts": [
    {
      "id": 12,
      "lang": "en",
      "title": "How a static Astro site keeps LCP under a second",
      "slug": "how-a-static-astro-site-keeps-lcp-under-a-second",
      "url": "/blog/how-a-static-astro-site-keeps-lcp-under-a-second/",
      "absoluteUrl": "https://example.com/blog/how-a-static-astro-site-keeps-lcp-under-a-second/",
      "category": "building",
      "translationKey": "how-a-static-astro-site-keeps-lcp-under-a-second",
      "isPillar": false,
      "intent": "informational",
      "focusKeyword": "astro lcp",
      "publishedAt": "2026-11-12T08:00:00.000Z"
    }
  ]
}

The kits return the same fields without lang and translationKey.

list-all#

Every post in every status, most recently updated first: all post fields except content, plus url (the kits don't add url). Use it to find a post by slug before you write.

curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"action":"list-all"}'

get#

curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"action":"get","id":12}'

Returns { "post": { … } } with every field of the post object and its url. An unknown id is 404 Article not found.

save#

Creates a post, or updates one when post.id is set.

save replaces the whole post. Fields you leave out are reset to their defaults (an empty excerpt, no tags, the default category…). To edit, get the post first, change what you need and send every field back.

curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{
  "action": "save",
  "post": {
    "lang": "en",
    "title": "How a static Astro site keeps LCP under a second",
    "focusKeyword": "astro lcp",
    "category": "building",
    "excerpt": "What loads first, what waits for the load event, and the numbers.",
    "content": "## The budget\n\nAn astro lcp under a second starts with …",
    "featuredImage": "/media/2b0c3f6e-…/lighthouse.webp",
    "featuredImageAlt": "Lighthouse report for the home page",
    "status": "published"
  }
}'
{
  "ok": true,
  "id": 12,
  "lang": "en",
  "slug": "how-a-static-astro-site-keeps-lcp-under-a-second",
  "status": "published",
  "translationKey": "",
  "url": "/blog/how-a-static-astro-site-keeps-lcp-under-a-second/",
  "absoluteUrl": "https://example.com/blog/how-a-static-astro-site-keeps-lcp-under-a-second/",
  "seoScore": 64
}

The kits return ok, id, slug, status, url and seoScore.

On every save the previous version is kept as an auto backup, the SEO score is recomputed, and a save that ends published pings IndexNow (when INDEXNOW_KEY is set). A slug already used in the same language is refused with 409, so repeating a create can't make a duplicate.

set-status#

Changes only the status, without resending the post.

curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"set-status","id":12,"status":"scheduled","publishedAt":"2026-11-20T08:00:00+01:00"}'

status is draft, scheduled (needs publishedAt) or published (sets publishedAt to now if the post has none). It takes an auto backup first and pings IndexNow on published.

delete#

curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"action":"delete","id":12}'

A pre-delete backup is taken first, and posts that named it as their pillar go back to automatic clustering.

recompute-scores#

Reruns the 11 SEO checks on every post and stores the new scores. updated is the number of posts checked.

Post fields#

Fields accepted by save (and by the admin editor). Everything except title is optional.

Field Type Notes
id number Set to update; leave out to create
lang "en" | "pl" Newest version. Default en, or the stored language on update
translationOf number Newest version. Id of the same post in the other language; pairs the two
translationKey string Newest version. Shared key of a pair; "" unpairs. translationOf is easier
title string Required. Up to 200 characters
slug string From the title when empty; transliterated, up to 120 characters
content string Markdown, up to 100,000 characters
excerpt string Up to 600 characters
metaDescription string Up to 400 characters; falls back to the excerpt
focusKeyword string Up to 120 characters
category string A category key from the site's config
categories string[] Up to 6 keys; the first is primary and overrides category. Unknown keys are dropped
tags string[] or comma-separated string Up to 20, 60 characters each
summary string[] The "In short" box: up to 8 items, 300 characters each
featuredImage string A /media/… URL from an upload. Also the share image's source
featuredImageAlt string Up to 300 characters
authorName, authorBio string Default to the site's byline (per language in the newest version)
intent string informational (default), commercial, transactional, navigational
isPillar boolean Marks a pillar post; clears pillarId
isHighlighted boolean Editor's pick flag for the site's templates
pillarId number | null The pillar this post belongs to
status string draft (default), scheduled, published
publishedAt ISO string Required for scheduled; optional backdate for published

Strings are cut to their limits (and trimmed, except content); lists are trimmed and de-duplicated.

The post object#

What get returns (and list-all without content):

Field Type
id, lang, slug, translationKey number, string, string, string
title, excerpt, metaDescription, focusKeyword string
category, categories, tags string, string[], string[]
authorName, authorBio string
content string (Markdown)
featuredImage, featuredImageAlt string
summary string[]
readingTime number (minutes)
status draft | scheduled | published
intent, isPillar, isHighlighted, pillarId string, boolean, boolean, number | null
seoScore number (0 to 100)
publishedAt, createdAt, updatedAt ISO string (publishedAt may be null)
url string, the post's path

Upload#

POST /api/cms/upload with multipart form data: file (an image up to 12 MB) and an optional alt.

curl -s -X POST "$SITE/api/cms/upload" -H "Authorization: Bearer $KEY" \
  -F "file=@lighthouse.png" -F "alt=Lighthouse report for the home page"
{
  "id": "2b0c3f6e-4a1d-4d7e-9b8a-5f1c2e3d4a5b",
  "url": "/media/2b0c3f6e-4a1d-4d7e-9b8a-5f1c2e3d4a5b/lighthouse.webp",
  "absoluteUrl": "https://example.com/media/2b0c3f6e-4a1d-4d7e-9b8a-5f1c2e3d4a5b/lighthouse.webp",
  "filename": "lighthouse.webp",
  "width": 1600,
  "height": 1000,
  "size": 84210,
  "alt": "Lighthouse report for the home page",
  "createdAt": "2026-11-13T09:12:44.000Z"
}

The image is auto-rotated, fitted inside 1600 × 1600 px and stored as WebP (see Media for formats). Use url in featuredImage or in Markdown: ![alt](url "caption").

When the file's type is missing or generic (application/octet-stream), the Astro versions decide by decoding the file. The Next.js kit refuses a generic type, so name it there: -F "file=@photo.webp;type=image/webp".

Media#

POST /api/cms/media

action Body Returns
list none { "media": [ { id, url, filename, width, height, size, alt, createdAt } ] }, newest 200
delete id (UUID) { "ok": true }

Deleting an image breaks every post that still shows it; check first.

Backups#

POST /api/cms/backup

action Body Returns
list type? (auto, pre-delete, scheduled, manual) { "backups": [ { id, type, slug, createdAt, size } ], "counts": { … } } (newest 1,000)
full-backup none { "ok": true, "id": n, "count": posts } (type manual)
download id { "snapshot": { … }, "meta": { id, type, slug, createdAt } }
restore id { "ok": true, "restored": n }
delete id { "ok": true }

A full snapshot is { "articles": [ … ] } with slug full; a single-post snapshot is one post row. A restore upserts by language and slug. If some posts fail, it answers 500 with { "ok": false, "restored": n, "error": "…" }.

IndexNow and indexing#

POST /api/cms/indexnow with { "urls": [ … ] } submits up to 10,000 URLs of your own host (paths or absolute URLs; other hosts are dropped) and returns { ok, status, response, submitted, urls }. Needs INDEXNOW_KEY.

POST /api/cms/indexing with { "url": "…" } runs Google Search Console's URL Inspection for one URL and returns { url, verdict, coverageState, lastCrawlTime, robotsTxtState, indexingState, googleCanonical } (or verdict: "error" with error). It only reads. Needs GOOGLE_SERVICE_ACCOUNT_JSON, otherwise 503.

Keys#

POST /api/cms/keys, session cookie only.

action Body Returns
list none { "keys": [ { id, name, keyPrefix, revoked, createdAt, lastUsedAt } ] }
create name (up to 60 characters) { "key": "sc_…", "record": { … } }: the only time the raw key is returned
revoke id { "ok": true }

Login and logout#

POST /api/cms/login with { "username", "password" } returns { "ok": true } and sets the studio_session cookie (httpOnly, SameSite=Lax, Secure in production, 30 days). POST /api/cms/logout clears it.

curl -s -c jar.txt -X POST "$SITE/api/cms/login" -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"…"}'

Cron (optional)#

GET /api/cron/daily with Authorization: Bearer $CRON_SECRET flips due scheduled posts to published (with IndexNow pings) and takes a full scheduled backup:

{ "ok": true, "published": ["en/my-post"], "backup": { "id": 41, "count": 37 }, "ts": "2026-11-13T03:00:00.000Z" }

Without CRON_SECRET the endpoint refuses every call. The newest version does this work on its own; in the kits, schedule it daily. See Scheduling without cron.

Kit-only endpoints#

The kit versions add:

  • POST /api/cms/comments (session only): list (filter, order, limit, offset), stats, hide, unhide, delete.
  • POST /api/cms/ads (session or key): list, and save with a slot.
  • Next.js kit: the set-highlight post action ({ "id", "isHighlighted": true | false }, flips the editor's-pick flag without touching updatedAt) and POST /api/cms/messages for the contact inbox.

Errors#

Status When error examples
400 Bad request or invalid field Body must be JSON, Unknown action, post is required, Title is required, Invalid id, publishedAt is not a valid date, Pick a publish date to schedule, status must be draft, scheduled or published, No file provided, Image is over 12 MB, Only image files can be uploaded, Could not process image
401 No valid key or session, or wrong login Unauthorized, Invalid username or password
404 Unknown id Article not found, Backup not found, translationOf: article not found
409 Conflict An EN article with that slug already exists., Another EN article already uses translation key "…".
429 Too many login attempts Too many attempts. Try again later.
500 Server error (details only in the server log) Something went wrong, Upload failed, INDEXNOW_KEY is not set
503 Not configured Polaris CMS is not configured: set ADMIN_USERNAME and ADMIN_PASSWORD. (kits: Studio is not configured: …), Search Console not set up

Retry 5xx with a backoff. Don't retry 4xx unchanged: fix the request.

Open source under MIT. Public release on GitHub and npm in preparation. Roadmap.