Skip to content

Headless and REST API

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

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.

The short version

  • Every endpoint lives under /api on the same process that serves the admin.
  • Anonymous GET requests 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 }.

Authentication

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

Session cookie and CSRF token (browsers)

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".

API keys (servers, scripts, agents)

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.

Scopes

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.

CORS and cross-origin public writes

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.

The response envelope

// success
{ "success": true, "data": <T>, "message": "…", "meta": { … } }

// failure
{ "success": false,
  "error": { "message": "…", "code": "VALIDATION_ERROR",
             "reason": "checkout.coupon_invalid", "params": { … }, "details": { … } } }
  • error.code follows the HTTP status: BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, VALIDATION_ERROR (422), RATE_LIMITED, INTERNAL_SERVER_ERROR, plus middleware codes such as CSRF_FAILED, INSUFFICIENT_SCOPE, LENGTH_REQUIRED and PAYLOAD_TOO_LARGE.
  • error.reason is the specific, stable cause for a client to translate, with its values in error.params.
  • error.details carries per-field validation messages on a 422.

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.

Paging

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).

Resource groups

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.

Posts and pages

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.

Categories, taxonomies, search, locales, settings

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.

Media

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.

Content types, collections and forms

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.

Users, auth and administration

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 (when switched on)

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.

Rate limits

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.

Idempotency keys

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.

Machine-readable contract: /openapi.json and /llms.txt

Both are public and need no key.

  • /openapi.json is a hand-maintained OpenAPI 3.1 document with a bearerApiKey security scheme. Generate a client from it or point a tool at it.
  • /llms.txt is 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.

Media URLs in headless responses

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 (for featured_image and <img> tags in content)
  • GET /api/products, GET /api/products/{ref}, GET /api/brands
  • GET /api/media/get, which also adds url_absolute to each record

media_base is resolved in this order:

  1. The public_site_url setting, the CMS's own public address. Set this in production.
  2. The origin the request reached, when it is publicly reachable (with X-Forwarded-Proto: https honoured under TRUST_PROXY).
  3. The site_url setting, then the SITE_URL environment variable.
  4. 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.

Caching

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.

Worked example: a static site with a public form

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.

1. Configure the backend

# 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.

2. Read at build time

// 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.

3. Post the form from the browser

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 on CORS_ORIGINS.
  • GET /api/forms/enquiry returns the fields, steps, conditions and the names of the two meta-fields (hp_url and pow_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:4321 during development, add that origin to CORS_ORIGINS too.

To rebuild the static site when content changes, see the recipe in Webhooks.

See also

Clone this wiki locally