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
POSTwith a JSON body{ "action": "…", … }, exceptupload(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/postsand/api/cms/posts/, so a client never hits a redirect onPOST. - 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.
savereplaces the whole post. Fields you leave out are reset to their defaults (an empty excerpt, no tags, the default category…). To edit,getthe 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: .
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, andsavewith aslot.- Next.js kit: the
set-highlightpost action ({ "id", "isHighlighted": true | false }, flips the editor's-pick flag without touchingupdatedAt) andPOST /api/cms/messagesfor 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.