Repository navigation
Headless and REST API
How to use AstroBaaS as a backend for a frontend you build yourself: how callers authenticate, what the JSON API looks like, which endpoints exist, and how a browser on another origin can submit public forms. It is written for frontend developers and for anyone wiring a script or an agent to an install.
The full reference is
INTEGRATION.md
in the repository, and every install describes its own API at /openapi.json.
- Every endpoint lives under
/apion the same process that serves the admin. - Anonymous
GETrequests work on public data and only ever return published content. - A browser that is signed in to the admin uses a session cookie plus a CSRF
token. Anything else (a server, a build script, an agent) uses a bearer API
key that starts with
abk_. - A browser on another origin can place orders and submit contact, newsletter
and public content-type forms without any credential, as long as its origin
is listed in
CORS_ORIGINS. - Every answer uses one envelope:
{ success, data, meta }or{ success: false, error }.
There are three kinds of caller. The middleware decides which one a request is before any route runs.
| Caller | Credential | CSRF token needed for writes | Typical use |
|---|---|---|---|
| Anonymous | none | only same-origin form posts (see below) | public reads, guest checkout, public forms |
| Signed-in user |
astrobaas_session cookie |
yes | the admin, a same-origin script |
| API key | Authorization: Bearer abk_… |
no | a storefront server, a build, the CLI, the MCP server |
A same-origin page gets the astrobaas_csrf cookie when it loads an HTML page.
It sends the value back on every write as the X-CSRF-Token header (a classic
form post may send it as a _csrf field instead). This is the double-submit
pattern. A JSON sign-in with POST /api/auth/login also sets the cookie, so a
script that never loads a page can still get one.
API responses do not set cookies, so anonymous GET answers stay cacheable by
a CDN. A write that fails the check answers 403 with
error.code: "CSRF_FAILED".
An admin mints keys in Admin → API Keys or with POST /api/keys:
curl -X POST https://cms.example.com/api/keys \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ADMIN_KEY" \
-d '{"name":"my-frontend","role":"editor","scopes":["posts:read","content:read"],"expires_in_days":90}'The full key (abk_…) is returned once. It is stored as a SHA-256 hash, so it
cannot be shown again. Bearer requests are exempt from CSRF because they carry
no ambient browser credential.
| Field | Meaning |
|---|---|
name |
A label for the admin list. |
role |
admin, editor, author, manager or viewer. Defaults to editor. |
scopes |
Optional list that narrows the key further (below). Omit for role-only access. |
expires_in_days |
Optional, 1 to 3650. An expired key authenticates as nobody (401). |
forward_client_ip |
Optional. Lets a storefront server name the shopper's address in X-AstroBaaS-Client-IP so per-shopper limits apply. Never give this key to a browser. |
Key management endpoints:
| Method and path | Purpose |
|---|---|
GET /api/keys |
List keys (prefix, role, scopes, last use; never the secret). |
POST /api/keys |
Mint a key. |
PATCH /api/keys/{id} |
Change forward_client_ip without rotating. |
POST /api/keys/{id}/rotate |
New secret, same id, role and scopes. The old secret stops working at once. |
DELETE /api/keys/{id} |
Revoke. |
All of these are admin only.
A scope is resource:action, where the action is read, write or *, or
the single global *. The scoped resources are:
posts, content, media, products, orders, customers, messages
/api/commerce/*, /api/shipping-methods and /api/coupons fall under
products. /api/contact falls under messages.
A key with scopes is deny-by-default. It may only touch the resources it names,
plus GET /api/auth/me. Anything else answers 403 INSUFFICIENT_SCOPE. The one
softening: on a public GET (for example /api/settings/get or
/api/categories/get, which map to no scope), a scoped key that does not cover
the path is treated as anonymous rather than refused, so it still gets the
public view but never sees drafts.
To check what a credential resolves to, call GET /api/auth/me.
If your frontend runs on a different origin, list it when you start the server. Origins are matched exactly, including scheme and port:
CORS_ORIGINS="https://www.example.com http://localhost:4321"Credentials are never allowed over CORS, so no other site can read a signed-in
admin's data even with CORS_ORIGINS=*. Allowed request headers are
Content-Type, Authorization, X-CSRF-Token and Idempotency-Key; exposed
response headers are the RateLimit-* set, Retry-After and
Idempotent-Replayed. Errors carry the CORS headers too, so your code can read
a 401, 403, 429 or maintenance 503 instead of seeing a generic CORS
failure.
The allow-list does a second job. A visitor's browser on your site cannot hold
the CSRF cookie of another origin, so these POST endpoints accept a request
without a CSRF token when it carries no session cookie and its Origin is
on the list:
| Endpoint | What it is |
|---|---|
POST /api/contact |
The built-in contact form |
POST /api/newsletter |
Newsletter signup (double opt-in) |
POST /api/products/{ref}/notify-me |
Back-in-stock request |
POST /api/orders/quote |
Price a basket |
POST /api/orders |
Place an order (guest checkout) |
POST /api/payments/start |
Open a payment session for an order |
POST /api/content/{type} |
Submit a public form (a content type with writable: 'public') |
POST /api/forms/{type}/upload |
Upload a file for such a form |
POST /api/assistant/chat |
The embeddable assistant widget |
The commerce rows only exist while commerce is switched on. A content type
that has not opted in to public writes answers 404 exactly like an unknown
type.
With CORS_ORIGINS=* any website can make a visitor's browser do these things.
No session is involved, but you lose control over which sites embed your
forms. The server logs a warning at startup and the deep health check reports
cors_origins: warn. Origin is only meaningful for browsers: curl can send
any value, which is why these endpoints also have rate limits, input bounds and
server-side pricing.
-
error.codefollows the HTTP status:BAD_REQUEST,UNAUTHORIZED,FORBIDDEN,NOT_FOUND,CONFLICT,VALIDATION_ERROR(422),RATE_LIMITED,INTERNAL_SERVER_ERROR, plus middleware codes such asCSRF_FAILED,INSUFFICIENT_SCOPE,LENGTH_REQUIREDandPAYLOAD_TOO_LARGE. -
error.reasonis the specific, stable cause for a client to translate, with its values inerror.params. -
error.detailscarries per-field validation messages on a422.
A write with a body must send Content-Length. A chunked or streamed body is
refused with 411, and bodies over the per-route ceiling with 413.
GET /api/posts and GET /api/content/{type} page the same way:
| Query | Meaning |
|---|---|
limit=N |
At most N rows, capped at 200 |
offset=N |
Skip N rows |
page=N |
1-based page, only together with limit
|
Without limit, at most 1000 rows come back. meta always carries total,
count, limit, offset, page and hasMore. If hasMore is true, keep
going with offset.
GET /api/content/{type} also filters on declared fields with
where.<field>=<value> (exact match; an array field matches when any item
matches).
Paths below are relative to your install. "Public" means anonymous GET
works. The authoritative list, with request and response shapes, is
/openapi.json on your own install.
| Method and path | Access | Notes |
|---|---|---|
GET /api/posts |
public | Articles by default. kind=page for pages, kind=all for both. Also status, category, author, locale, paging. |
GET /api/posts/{slug} |
public | One article or page. |
GET /api/posts/{slug}/related |
public | Related articles. |
POST /api/posts |
author+ | Create. HTML is sanitised on save. |
PUT /api/posts/{ref} |
author+ | Update by id or slug. Authors can only edit their own. |
DELETE /api/posts/{ref} |
author+ | Delete. |
GET/POST /api/posts/{slug}/revisions |
staff | Revision history and restore points. |
Anonymous callers only ever see published posts. Each post in a list carries
content_rendered (plugin filters applied, sanitised) beside the raw
content. The older /api/posts/get, /create, /update and /delete
aliases still work for the admin; new code should use the paths above.
| Method and path | Access | Notes |
|---|---|---|
GET /api/categories/get |
public | Post categories. |
POST /api/categories/create, PUT …/update, DELETE …/delete
|
admin, editor | Manage categories. |
GET /api/taxonomies |
signed in | Custom taxonomies; defining them is admin only. |
GET /api/search?q= |
public | Published articles only. At least 2 characters. |
GET /api/locales |
public | Languages the site serves and the default. |
GET /api/settings/get |
public | Anonymous callers get only public keys; admins and editors get all. |
POST /api/settings/update |
admin | Update settings. |
GET /api/themes/get |
public | Active theme and its design tokens. |
| Method and path | Access | Notes |
|---|---|---|
GET /api/media/get |
staff | The library. Not public on purpose: filenames alone can leak. |
POST /api/media/upload |
staff | Multipart upload. |
PATCH /api/media/update |
staff | Alt text, title, folder. |
POST /api/media/replace, POST /api/media/delete
|
staff | Replace or remove a file. |
The files themselves are public at /uploads/…, and their URLs appear in
published content. See Content and media.
| Method and path | Access | Notes |
|---|---|---|
GET /api/content-types |
staff | Every registered type, including private ones. |
GET /api/content/{type} |
depends on the type | Public only when the type declares visibility: 'public'; otherwise 404 to anonymous callers. |
GET /api/content/{type}/{id} |
depends on the type | One entry. |
POST /api/content/{type} |
editor+, or anyone when writable: 'public'
|
Schema-validated. |
PUT/DELETE /api/content/{type}/{id} |
editor+ | Update or delete. |
GET /api/forms/{type} |
public | The form schema for a publicly writable type. |
POST /api/forms/{type}/upload |
public | File for a form's file field. |
GET /api/content/changes |
public | Change feed for revalidation (see Webhooks). |
An entry looks like { id, type, data: { … }, created_at, updated_at }. See
Custom content types.
| Method and path | Access |
|---|---|
GET /api/auth/me |
any caller |
POST /api/auth/login, POST /api/auth/logout
|
public / session |
POST /api/auth/forgot, POST /api/auth/reset, POST /api/auth/magic-link
|
public |
POST /api/2fa/setup, /enable, /disable
|
signed-in user |
GET /api/users/get, POST /api/users/create, /update, /delete
|
admin |
GET/POST /api/webhooks, /api/webhooks/deliveries
|
admin |
GET /api/audit |
admin |
GET /api/backup/export, POST /api/backup/import, POST /api/backup/run
|
admin |
GET /api/plugins, POST /api/plugins/toggle
|
admin |
Commerce is off by default. While it is off, every commerce path answers the
same 404 an unknown path does. When it is on:
| Method and path | Access |
|---|---|
GET /api/products, GET /api/products/{ref}
|
public |
GET /api/product-categories, GET /api/brands
|
public |
GET /api/shipping-methods, GET /api/payments, GET /api/commerce/currencies
|
public |
POST /api/orders/quote, POST /api/orders, POST /api/payments/start
|
public |
POST /api/products/{ref}/notify-me |
public |
GET /api/orders, GET/PUT /api/orders/{id}, POST /api/orders/{id}/ship, …/refund
|
staff |
GET /api/customers, GET/POST /api/coupons
|
staff |
POST/PUT/DELETE /api/products… |
staff |
Prices are always computed on the server from product ids and quantities. See Commerce and Payments.
Every /api response carries RateLimit-Limit, RateLimit-Remaining and
RateLimit-Reset (seconds), describing whichever budget is closest to
refusing you. A 429 adds Retry-After and error.retry_after.
| Caller | Budget per minute | Variable |
|---|---|---|
| Anonymous | 60 per address | RATE_LIMIT_PER_MIN |
| API key | 6000 per key | RATE_LIMIT_API_KEY_PER_MIN |
| Signed-in staff | 1800 per person | STAFF_RATE_LIMIT_PER_MIN |
Some routes have an extra per-address budget on top: checkout (10), quote (30), payment start (10), search (30) and payment-provider webhooks (600, instead of the anonymous 60). The variables are listed in INTEGRATION.md. Public form submissions and form uploads have their own tighter ceilings.
Behind a reverse proxy, set TRUST_PROXY=1 so the real client address is
used. IPv6 callers are counted per /64. In-process counters are per process;
with several processes, set RATE_LIMIT_STORE=libsql to share them. See
Security.
POST /api/orders accepts an Idempotency-Key header (1 to 255 printable
ASCII characters). Generate one per checkout attempt and reuse it on every
retry of that attempt. Keys are kept for 24 hours per caller.
| Answer | Meaning |
|---|---|
201 with Idempotent-Replayed: true
|
Already placed; the body is the original order. |
409, reason: IDEMPOTENCY_IN_PROGRESS
|
The first request is still running. Wait Retry-After, retry with the same key. |
422, reason: IDEMPOTENCY_KEY_REUSED
|
Same key, different body. Make a new key. |
400, reason: IDEMPOTENCY_KEY_INVALID
|
Malformed key. |
The typed client sends a key on orders.place() automatically.
Both are public and need no key.
-
/openapi.jsonis a hand-maintained OpenAPI 3.1 document with abearerApiKeysecurity scheme. Generate a client from it or point a tool at it. -
/llms.txtis a plain-text brief for AI coding agents: base URL, auth, and endpoints. It describes this install, so the commerce section only appears when commerce is switched on.
Stored media paths are relative to the CMS (/uploads/2026/08/ab12cd.webp).
On a headless site the frontend lives on another host, so a relative path
resolved against the frontend's origin breaks. Responses that can contain
media paths include meta.media_base, the origin to join them to:
-
GET /api/posts(forfeatured_imageand<img>tags in content) -
GET /api/products,GET /api/products/{ref},GET /api/brands -
GET /api/media/get, which also addsurl_absoluteto each record
media_base is resolved in this order:
- The
public_site_urlsetting, the CMS's own public address. Set this in production. - The origin the request reached, when it is publicly reachable (with
X-Forwarded-Proto: httpshonoured underTRUST_PROXY). - The
site_urlsetting, then theSITE_URLenvironment variable. - A private or loopback request origin, as a last resort for local development.
It can be null when nothing usable is known. Leave already-absolute URLs
(such as imported https://old-shop.example/… images) untouched:
function mediaUrl(path: string | null | undefined, base: string | null) {
if (!path) return null;
if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith('//')) return path;
return base ? `${base}${path.startsWith('/') ? '' : '/'}${path}` : null;
}Set public_site_url before putting a CDN in front of the API, and check
GET /api/health/deep, which reports public_site_url as a problem while it
is unset.
Anonymous reads of products, brands, product categories, search, the sitemap
and the RSS feed send
Cache-Control: public, max-age=0, s-maxage=30, stale-while-revalidate=300
with an ETag. Requests with a session or an API key get
private, no-store. Configure your CDN to bypass its cache for requests
carrying astrobaas_session or Authorization. Tune with
PUBLIC_API_CACHE_SECONDS and PUBLIC_API_CACHE_SWR_SECONDS.
This example is a statically built frontend on https://www.example.com. It
reads posts at build time with an API key and, in the visitor's browser, posts
an enquiry form to a content type called enquiry that an operator has
declared with writable: 'public' and a file field called attachment.
# on the AstroBaaS server
CORS_ORIGINS="https://www.example.com"Set Settings → Address of this CMS (public_site_url) to the CMS's public
address, for example https://cms.example.com. Mint a read-only key for the
build:
curl -X POST https://cms.example.com/api/keys \
-H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_KEY" \
-d '{"name":"static-build","role":"viewer","scopes":["posts:read"]}'Keep that key in your build environment only. It is never shipped to the browser.
// lib/cms.ts, runs during the static build (Node 22)
const CMS = process.env.CMS_URL!; // https://cms.example.com
const KEY = process.env.CMS_KEY; // abk_… (optional for published posts)
export async function allPublishedPosts() {
const out: any[] = [];
let mediaBase: string | null = null;
for (let offset = 0; ; offset += 100) {
const res = await fetch(`${CMS}/api/posts?status=published&limit=100&offset=${offset}`, {
headers: KEY ? { Authorization: `Bearer ${KEY}` } : {},
});
const body = await res.json();
if (!body.success) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
mediaBase = body.meta.media_base;
out.push(...body.data);
if (!body.meta.hasMore) break;
}
return out.map((p) => ({
slug: p.slug,
title: p.title,
html: p.content_rendered, // sanitised; image paths are relative to the CMS
image: p.featured_image ? mediaUrl(p.featured_image, mediaBase) : null,
}));
}content_rendered contains <img src="/uploads/…"> tags. Either rewrite
them with the same media_base, or serve /uploads from the CMS origin
through your CDN.
The browser uses no key. It fetches the form schema, uploads the file (if any), then submits the fields.
<form id="enquiry">
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<input name="attachment" type="file">
<!-- honeypot: must stay empty, hide it from people -->
<input name="hp_url" tabindex="-1" autocomplete="off" hidden>
<button>Send</button>
</form>
<script type="module">
const CMS = 'https://cms.example.com';
// Only needed if an operator enabled the "forms" anti-spam surface.
async function powToken() {
const { data } = await (await fetch(`${CMS}/api/captcha/challenge?surface=forms`)).json();
if (!data?.enabled) return undefined;
const enc = new TextEncoder();
for (let n = 0; ; n++) {
const d = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(`${data.token}.${n}`)));
let bits = data.bits, ok = true;
for (let i = 0; bits > 0; i++, bits -= 8) {
if (d[i] >>> (8 - Math.min(8, bits)) !== 0) { ok = false; break; }
}
if (ok) return `${data.token}::${n}`;
}
}
document.getElementById('enquiry').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.currentTarget;
const body = { name: f.name.value, email: f.email.value, message: f.message.value, hp_url: f.hp_url.value };
const file = f.attachment.files[0];
if (file) {
const fd = new FormData();
fd.append('file', file);
fd.append('hp_url', f.hp_url.value);
const t = await powToken(); if (t) fd.append('pow_token', t);
const up = await (await fetch(`${CMS}/api/forms/enquiry/upload`, { method: 'POST', body: fd })).json();
if (!up.success) return alert(up.error.message);
body.attachment = up.data.id; // a "pf_…" reference
}
const t = await powToken(); if (t) body.pow_token = t; // tokens are single-use
const res = await fetch(`${CMS}/api/content/enquiry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const out = await res.json();
alert(out.success ? 'Thanks, we received it.' : out.error.message);
});
</script>Things to know:
- No
credentials: 'include'and no CSRF header: the request is cookie-less, and the origin is onCORS_ORIGINS. -
GET /api/forms/enquiryreturns the fields, steps, conditions and the names of the two meta-fields (hp_urlandpow_token), so you can build the form from data instead of hard-coding it. - The submission answer never echoes the stored record back. Uploaded files land in private storage and are only downloadable by staff.
- Files are limited to 5 MB each, and each form has a storage quota.
- If your host serves the page from
http://localhost:4321during development, add that origin toCORS_ORIGINStoo.
To rebuild the static site when content changes, see the recipe in Webhooks.
- SDK, CLI and MCP: the typed client that wraps these endpoints
- Webhooks: push notifications and the change feed
- Custom content types: defining public forms and collections
- Security: the security model behind CSRF, CORS and keys
- Commerce and Payments
- INTEGRATION.md: the full headless guide
- STABILITY.md: what the API stability promise covers
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project