Headless API

The bearer-token REST API at /api/v1/*

Every Cusp deployment exposes a read/write REST API at /api/v1/*. Routes are generated from your collections and their field definitions, so adding a content type in the admin makes its endpoints live without a redeploy. The same Worker serves the admin, the public render path, and this API; there is no separate service to run.

Authentication

All collection endpoints require a bearer token in the Authorization header. (Two endpoints are public and need no token: the search endpoint and the browsable API explorer page — both covered below.) Tokens are minted in the admin and stored only as hashes; the plaintext is shown once at creation time and never again. A token has the shape cusp_pk_ followed by a 26-character identifier, an underscore, and a 32-character secret.

curl https://your-cusp-site.example/api/v1/recipes \
  -H 'Authorization: Bearer cusp_pk_01H...XYZ_YOURSECRET'

A missing, malformed, expired, revoked, or unknown token returns 401 with a generic body. The response never reveals which step of validation failed, so it cannot be used to probe which tokens exist.

{ "error": { "code": "UNAUTHENTICATED", "message": "Authentication required" } }

Scopes

Each token carries a list of scopes shaped as collection-slug colon action. Every endpoint validates the scope it requires. The grammar is a closed set matched as exact strings with three wildcard cases.

  • recipes:read — read published documents in the recipes collection.
  • recipes:write — create, update, and delete documents in recipes.
  • recipes:read-drafts — also see drafts and scheduled documents. This is a separate scope from read, not a higher privilege; a token that needs both lists both.
  • *:read — read every collection's published documents. The wildcard names the collection, not the action, so *:write and *:read-drafts are valid too: a token can hold any single action across all collections.
  • * — full access to every collection and action. Admin sessions carry it, and an administrator can also mint it onto a service token deliberately; there is no rule that withholds it from minted tokens. (The synonym *:* is rejected — use the bare *.)

There is no recipes:* form. A token that needs both reading and writing lists recipes:read and recipes:write explicitly, which keeps the scope set readable in the admin.

Dynamic routes

For a collection with slug recipes, these routes exist as soon as the collection does:

  • GET /api/v1/recipes — list documents, paginated, sortable, filterable.
  • GET /api/v1/recipes/{id} — a single document by its identifier.
  • GET /api/v1/recipes/by-slug/{slug} — a single document by its slug.
  • POST /api/v1/recipes — create a document (requires recipes:write).
  • PATCH /api/v1/recipes/{id} — update a document (requires recipes:write).
  • DELETE /api/v1/recipes/{id} — soft-delete a document, moving it to trashed (requires recipes:write). Already-trashed documents return 204.

Listing: sort, filter, paginate

List endpoints accept a consistent set of query parameters:

  • ?page=2&perPage=20 — pagination. perPage defaults to 20 and is capped at 100; a value above the cap is silently clamped.
  • ?sort=-publishedAt,title — multi-field sort. A leading minus sorts descending. Only framing columns (createdAt, updatedAt, publishedAt, title, slug, status) and sortable fields are accepted; an unknown sort field returns 400 SORT_FIELD_INVALID.
  • ?status=published — filter by status (one of draft, published, scheduled, archived). Drafts and scheduled documents are only returned to a token that holds the read-drafts scope.
  • ?field.county=Lincolnshire — filter by any indexed field value. Per-field filters use the field. prefix so they never collide with framing controls. Block, repeater, and group fields cannot be filtered.

A filter the token is not allowed to satisfy is not an error. A token without read-drafts that asks for ?status=draft gets a 200 with an empty data array — it is allowed to ask, and the answer is that nothing matches. Asking for a specific draft by id returns 404, the same shape as a document that does not exist, so resource existence never leaks across a permission boundary.

Response envelopes

List responses wrap the array in a data key alongside a meta block carrying the page, the page size, and the total count.

{
  "data": [
    {
      "id": "01H...",
      "slug": "cuthberts-spelt-pancake",
      "title": "Cuthbert's spelt pancake",
      "status": "published",
      "serves": 4
    }
  ],
  "meta": { "page": 1, "perPage": 20, "total": 4 }
}

Single-document responses return the resource directly, with no data envelope. Field values sit flat on the object next to the framing columns; there is no nested data object on the wire. Rich-text bodies are returned as Portable Text JSON, the same shape Cusp stores and renders internally.

{
  "id": "01H...",
  "slug": "cuthberts-spelt-pancake",
  "title": "Cuthbert's spelt pancake",
  "status": "published",
  "createdAt": 1730000000,
  "updatedAt": 1730000500,
  "publishedAt": 1730000600,
  "cookMinutes": 8,
  "ingredients": [
    { "quantity": "200 g", "item": "stone-ground spelt flour" }
  ]
}

Writing documents

POST creates a document, PATCH updates one. The write body mirrors the read shape: writable framing keys (title, slug, status) and per-field values flat at the top level. Read-only framing keys (id, createdAt, updatedAt, publishedAt) and the block tree are silently ignored, so a read-modify-write round trip of the GET body is safe. Unknown keys return 422.

  • On create, title is required; slug is derived from the title if you do not supply one. status defaults to draft and may be set to draft, published, or scheduled — archived and trashed are admin-only states.
  • Required-field validation only runs when status is published, so drafts and scheduled documents may be incomplete.
  • A slug that collides with an existing document in the collection returns 409 SLUG_CONFLICT.
  • PATCH cannot change a document's status in v1. A PATCH that supplies a status different from the live one returns 422 STATUS_TRANSITION_NOT_SUPPORTED — use the admin publish workflow to transition status. Supplying the same status is a no-op.

GET /api/v1/search is one of the two public endpoints (the other is the explorer page below). It runs a full-text query over published documents only and never requires a token, so it is safe to call directly from a public site's search box.

curl 'https://your-cusp-site.example/api/v1/search?q=pancake&collection=recipes&perPage=10'
  • ?q= — the query string, 2 to 100 characters.
  • ?collection=recipes — repeat to scope to several collections (up to 20); omit to search all.
  • ?page= and ?perPage= — pagination; perPage defaults to 20 and is capped at 50.

Results come back in the standard data/meta envelope — each result carries documentId, collectionSlug, title, snippet, and rank, and meta adds took_ms alongside the page counters. Because it returns only published content, search responses are cacheable at the edge (public, max-age=60). The endpoint is rate-limited to 60 requests per minute per client IP.

Errors

Errors share one shape: an error object with a stable code, a human-readable message, and optional details enumerating each failed field.

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Invalid filter value",
    "details": [
      { "field": "field.cook_minutes", "code": "INVALID_FILTER_VALUE", "message": "Expected a decimal number" }
    ]
  }
}

Status codes are used semantically:

  • 200 — read or update succeeded. 201 with a Location header on create. 204 with no body on a soft-delete.
  • 400 — a malformed request: bad pagination, an invalid sort field, or an unfilterable field. The body names the offending input.
  • 401 UNAUTHENTICATED — authentication failed. Generic body; no detail about which token is wrong.
  • 403 FORBIDDEN — the token lacks the required scope for a listing or a write. Single-document reads return 404 rather than 403 so they do not reveal that a document exists.
  • 404 — the collection or document does not exist, or you cannot see it; the two are indistinguishable on purpose. An unknown collection slug returns COLLECTION_NOT_FOUND, an unknown or invisible document NOT_FOUND.
  • 409 SLUG_CONFLICT — the supplied slug is already taken in the collection.
  • 422 VALIDATION_FAILED — the body is structurally valid JSON but fails field validation, or carries an unknown field. STATUS_TRANSITION_NOT_SUPPORTED is the 422 returned when a PATCH tries to change status.
  • 429 RATE_LIMITED — over the per-token (or, for search, per-IP) quota. The response carries a Retry-After header in seconds.

Rate limits

Each token has a per-minute quota, defaulting to 60 requests per minute and adjustable per token in the admin. The limit is enforced by the RateLimit Durable Object using a strict sliding window. Authentication runs before the rate-limit check, so a flood of bad-secret requests cannot exhaust a legitimate token's budget. Unauthenticated traffic — both probes against the bearer-gated routes and the public search endpoint — is separately capped at 60 requests per minute per client IP.

{ "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded" } }

CORS

By default a token is same-origin only, which suits backend-to-backend use where there is no Origin header at all. To call the API from a browser on another origin, set the token's allowed origins in the admin as an explicit list of origins. There is no wildcard: each origin you want to permit is listed in full, and a request whose Origin is not on the list is not allowed.

Preflight (OPTIONS) requests are answered permissively because they cannot carry the token. On the actual request, if the Origin is not on the token's allowed list, Cusp simply omits the Access-Control-Allow-Origin header and the browser blocks the response — there is no special status code for a disallowed origin. A non-browser caller, which sends no Origin header, is never affected.

The OpenAPI document

GET /api/v1/_meta returns an OpenAPI 3.1 document describing every collection, its fields, and the routes generated from them. It is built from your live schema at request time, so adding a content type updates the document on the next call. Any valid token can read it.

curl https://your-cusp-site.example/api/v1/_meta \
  -H 'Authorization: Bearer cusp_pk_01H...XYZ_YOURSECRET' > openapi.json

Most OpenAPI consumers — Redoc, swagger-ui, Stoplight, and generated client SDKs — ingest this document directly. Field types map onto JSON Schema: text becomes a string; rich text becomes an array of Portable Text blocks (referencing a shared PortableTextBlock component schema, opaque to the OpenAPI consumer); number fields carry their minimum and maximum bounds; media and reference fields are ULID strings; and repeater and group fields are described as opaque arrays and objects that pass through untyped.

The API explorer

Every deployment serves a built-in, read-only explorer at /api/v1/_explorer — a single page that lists your collections from _meta and runs GET requests against them. It carries your token in the Authorization header from the browser (never in the URL), and only ever reads, so it is safe to point at a live site. Paste a token to begin.

On a deployment whose content is already public — the Cusp docs and demo sites, for example — you can pre-wire a read-only token so the explorer loads live data with no paste step. Mint a token scoped to *:read, then set it as the EXPLORER_PUBLIC_TOKEN secret (wrangler secret put EXPLORER_PUBLIC_TOKEN). Cusp bakes it into the page only after confirming it is strictly read-only; write, full-admin, and read-drafts tokens are refused, so the explorer can never expose unpublished content.

Version history

Cusp versions every entity, and the admin exposes per-document history and site-wide time travel. The headless equivalents — listing a document's revisions, fetching a specific revision, and reconstructing site-wide state at a timestamp — are a planned future surface on this API; until they land, version history lives under the admin API.


New to Cusp? Start with the getting-started guide to deploy a site and mint your first token.

Next: Cloudflare bindings