Spunto CMSworkspace · headless API

A workspace you write in.
An API that serves it.

Most teams end up running two things: a place that is pleasant to write and organise in, and a content API clean enough to feed a site. Then they spend a year syncing them. Spunto CMS is one thing — bases, typed properties, block pages and saved views on one side; the same content, over REST, with a generated TypeScript client, on the other. Not an export. The same rows.

REST, never GraphQLOpenAPI + typed clientPostgres RLSevents & webhooks
01 — What it is

The part that feels like Notion.

A workspace holds lists of cards. A card has a readable key, a status, a place in a hierarchy — and then whatever properties your team declared. There is no built-in assignee, no built-in due date: a registry shared across the workspace, rather than a task tracker's vocabulary imposed on a content base.

Open a card and you get a document, not a text area: headings, checklists, callouts, code, images. Save a view and you save a filter tree, a sort and a group-by — a piece of data, not a query someone typed. That distinction is why the same view can drive the screen, the API and a bulk edit without three implementations drifting apart.

what a workspace holds

collections
Lists of cards with a readable key — SPR-12— numbered by the API from the list’s prefix. A card can be attached to several lists at once without leaving the one it was born in.
properties
Ten types, defined per workspace and reused across lists: text, number, select, multi_select, date, checkbox, url, person, multi_person, file. They are validated when written, not when read — a badly typed value is refused at the door rather than breaking every view that casts it later.
page bodies
A card’s body is a block document: headings, lists, to-dos, quotes, callouts, code, dividers and images. Marks are named (bold, italic, code, strike, link), so the format cannot carry HTML and there is nothing in it to sanitise.
hierarchy
Cards nest. Subtasks, a whole subtree or a card’s ancestors are each one request, and moving a card rewrites its subtree rather than orphaning it.
views
A saved view is data, never a SQL fragment: a filter tree, sorts, a group-by. The same definition drives the screen, the API and bulk edit — compiled to SQL in exactly one file.
files
Attachments and image blocks carry a file id, never a URL. The API serves the bytes itself from a scoped route: no public bucket, no signed link to forward.
02 — The headless half

Your site reads the same rows.

There is no publish step that copies content somewhere else, and no second store to keep in sync. The cards a writer edits are the cards a request returns.

fetch-published-posts.ts
import { createSpuntoCmsClient } from "@spunto-cms/client"

// SPUNTO_CMS_TOKEN holds an API key: spk_…, one workspace
const api = createSpuntoCmsClient()

// STAGE and DUE_DATE are ids from the workspace registry. Nothing but the
// title, the body and the status is built in — the rest your team declares.

// A view is data — a filter tree, sorts, an optional group-by.
const definition = {
  filter: {
    and: [
      { op: "eq", field: { fieldId: STAGE }, value: "shipped" },
      { op: "is_not_empty", field: { fieldId: DUE_DATE } },
    ],
  },
  sort: [
    { field: { fieldId: "sys:created_at" }, direction: "desc" },
  ],
}

const { data } = await api.POST(
  "/workspaces/{workspaceId}/collections/{collectionId}/query",
  { params: { path: { workspaceId, collectionId } },
    body: { definition, limit: 50 } }
)

data.rows        // "SPR-12", its title, its typed properties
data.nextCursor  // keyset — no page number to ask for

The client is generated from the committed OpenAPI file, so the route names, the filter operators and the shape of a row are all checked by tsc — a renamed property is a build error, not a blank page in production.

the surface

protocol
REST. Not « REST-ish », and never GraphQL — that one is settled, not pending.
spec
OpenAPI, generated from the annotated routes and committed to the repo. Served live at /openapi.json, with the interactive reference at /api/docs.
docs
/docs is the written half — the model, the view definitions, the event log, and a page that says what this does not do. The reference is generated; that one is not.
client
@spunto-cms/client is generated from that spec — full types for every route, and the only supported way in. A missing route gets added to the spec and regenerated; it never becomes a hand-rolled fetch.
paging
Keyset, always. limit tops out at 200 and the response hands back an opaque nextCursor; there is no OFFSET anywhere in the product, so page 900 costs what page 1 costs.
reading a view
POST …/collections/{id}/query takes a view definition and returns its rows. …/group-counts returns the counts per group for a board, and …/index-hints tells you which index that view would like.
auth
One Authorization: Bearer header. Anonymous calls get 401, never a quietly empty list.
03 — Written by API too

An agent is a first-class author.

Plenty of headless CMSes will read you anything and expect a human to have typed it. Here the write path is the same path: a script, a cron job or an LLM agent creates cards, sets typed properties, writes a structured body and edits in bulk with the same client and the same key a person’s session would use.

That only works if writing is auditable, so the event log is not a feature bolted on top of HTTP: it is written inside the transaction that changes the row. An import, a seed, a deleted field and a 3 000-card bulk update all leave the same trail as a click, because none of them can commit without it.

what you can do to a workspace

cards
Create, patch, move and archive through the same routes the UI uses. Write blocks to set a structured body, or body to hand over plain text and let the API derive the document.
bulk edit
One UPDATE … WHERE over a view definition, previewed first. Past 200 cards you must pass the count you expect; past 5 000 the API refuses outright instead of pretending to be atomic.
event log
Written inside the transaction that changed the data, so an event exists if and only if the change committed. It records the keys that moved with their before and after — not the whole resource.
webhooks
Endpoints with delivery history, a single delivery replayable on demand, and a suspended endpoint you resume once it is fixed.
OAuth apps
An installed app gets its own token and cannot exceed either its role or its scopes. The route-to-scope table is fail-closed: a route it doesn’t name is refused to every app.
publish-release-notes.ts
// A body is blocks, and marks are named — the format
// cannot carry HTML, so there is nothing to sanitise.
const blocks = [
  { type: "heading", level: 2, text: [{ text: "What changed" }] },
  {
    type: "paragraph",
    text: [
      { text: "Bulk edit now emits " },
      { text: "one event per card", bold: true },
    ],
  },
  { type: "code", language: "bash", code: "npm i @spunto-cms/client" },
]

// Same client, same key an agent or a human would use.
const { data: item } = await api.POST(
  "/workspaces/{workspaceId}/collections/{collectionId}/items",
  { params: { path: { workspaceId, collectionId } },
    body: {
      title: "Release notes — 0.4.0",
      properties: { [STAGE]: "shipped", [TAGS]: ["api"] },
      blocks: { blocks },
    } }
)

item.key  // "REL-18" — numbered by the API, not by the caller
follow-the-log.ts
// Everything that changed the data left a row here: the UI,
// the seed, an import, and the script you just ran.
const { data: page } = await api.GET(
  "/workspaces/{workspaceId}/events",
  { params: {
      path: { workspaceId },
      query: { since: lastProcessed, type: ["item.updated"] },
    } }
)

for (const event of page.rows) {
  event.type     // "item.updated"
  event.changes  // only the keys that moved, before / after
  event.actor    // frozen: revoking a key doesn't rewrite history
}

page.nextCursor  // an event id — hand it straight back as `since`
04 — Whose content is whose

Handing out a key stays a small decision.

A content API is only as calm as its blast radius. Every table here is keyed by workspace, every index starts with it, and the database itself refuses to return a row from a tenant the connection is not scoped to — so a mistake in application code is a bug, not a leak.

Which is what makes the rest reasonable: a key for your marketing site, another for the agent that files changelog entries, each pinned to one workspace and one role at issue time. Revoke one and the other keeps working. And because attachments are served by the API rather than from a bucket, a link that escapes is a link that still asks who you are.

how access is decided

membership
The scope comes from a verified membership, never from the URL. Naming a workspace id is not the same as being allowed into it.
404, not 403
A non-member gets 404. A 403 would confirm the workspace exists to whoever is enumerating identifiers, so the product declines to answer at all.
row-level security
Postgres RLS underneath, fail-closed, with the API connected as an unprivileged role — not the table owner, not a superuser, or the policies would protect nothing.
closed by default
The guards sit on the route prefix rather than route by route, so a new route is protected before anyone remembers to protect it. A test sweeps the whole spec and demands 401 everywhere except seven doors, each justified on its own line.
API keys
An spk_… key carries its own workspace and its own role, fixed when it is issued. It does not follow the shifting membership of whoever created it, and revoking it is one click.
05 — Where it stands

Who gets in is configuration.

The code closes by default and the configuration opens: an instance is invite-only unless it says otherwise, and an invitation is a token an admin issues, bound to one email and one role, consumed by the same transaction that creates the membership. This one is open.

It is enforced in the application rather than by a gate in front of it, which is precisely what lets the API sit on the open internet for integrations to reach. The button above says « sign in » because that page carries both halves, whichever way this instance is set.

as of today

sign-up
Per instance. The code defaults to invite-only — an spi_… token bound to one address and one role, consumed by the very transaction that creates the account — and this one is open.
views
Definitions carry a kind — table, board, list, calendar — and the filters, sorts and grouping behind all four are implemented. The web UI renders three; the calendar has no screen yet.
publishing
There is no draft/published split yet. Everything in a workspace is readable by anything holding a key for that workspace; the separation is a design question, not a flag someone forgot to flip.
language
The API and this page are in English. The application’s own UI is currently in French.

read this bit

Nothing on this page is a roadmap entry. Every route named here is in the committed OpenAPI file, and everything described is running behind this domain today. When something is missing — publishing states, body search, the calendar screen — it is in the list on the right rather than phrased as if it shipped. The full version of that list lives at /docs/limits.