Spunto CMS
The API

Using the API

Base URL, credentials, errors and the generated client — everything that is true of every route before you pick one.

The API is REST over JSON. Every route is described in the reference, generated from the specification at /openapi.json.

This page covers what holds for all of them.

Base URL

On cms.spunto.net, one public address serves the whole product and routes /api/* to the API:

https://cms.spunto.net/api

The prefix is stripped before the API sees it, so a route documented as /workspaces is called at https://cms.spunto.net/api/workspaces.

The specification itself is served at the root, /openapi.json, without the prefix — that is the one exception, and it exists so the reference page can load it.

Running it yourself

The /api prefix belongs to the proxy, not to the API. Run the stack locally and there is no proxy: the API listens unprefixed on its own port, so every example here becomes http://localhost:3001/workspaces/… — drop the /api, change the host.

Two exceptions answer on the front's port in every environment, because a documented address that is dead where you work is a wrong address: /api/docs and /openapi.json. They are relayed, and nothing else under /api is.

Authenticating

Every route except a handful of doors requires a bearer token:

Authorization: Bearer spk_...

Three kinds of token get through, and past the door nothing distinguishes them:

TokenWhoScope
a sessiona person signing intheir workspaces, their role in each, 30 days
spk_…an API keyone workspace, one fixed role, until revoked
spa_…an installed appone workspace, a role and scopes

Use an API key for anything unattended. Mint one in a workspace's settings. The secret is returned once, at creation — only its SHA-256 is stored.

A key's workspace and role are fixed when it is issued. They do not follow the membership of whoever created it, which is the point: a key should not gain or lose power because a person changed jobs.

An API key cannot manage keys, invitations, members or app installations. Those need a human session.

What is public

Nine routes answer without a token, and no more: the health check, the specification, the reference page, the registration policy, the invitation preview, sign-up, sign-in, the Google sign-in exchange, and the OAuth token endpoint.

That list is not maintained by reading the code and hoping. A test sweeps every route in the specification and requires a 401, with the exceptions named one per line and justified. A new route is protected on the day it is written, because the guards are mounted on the path prefix rather than route by route.

Errors

Errors are JSON, with the shape { "error": "..." }, and the status carries the meaning:

StatusMeans
400The request is malformed, or a value failed validation
401No bearer, or a bearer that is no longer valid
404It does not exist — or it is not yours
409The request conflicts with current state

404 is doing double duty, on purpose

There is no 403 for "you are not a member of this workspace". A 403 would confirm the workspace exists to whoever is enumerating identifiers. The API will not tell you which of the two you hit.

Pagination

Lists paginate by keyset cursor. A page response carries nextCursor; send it back as cursor to get the next one. null means you are at the end.

There is no OFFSET and no page number, anywhere, and that is a settled decision rather than an omission — see saved views.

Cursors are opaque. Do not parse them, do not construct them, and do not carry one across a change of sort order.

The event log is the one exception: its cursor is an event id, and it is readable on purpose.

Dates

Every date the API returns is an ISO 8601 string. There are no epoch numbers and no locale-formatted values.

The generated TypeScript client

@spunto-cms/client wraps the API with types generated from the specification:

import { createSpuntoCmsClient } from "@spunto-cms/client"

const api = createSpuntoCmsClient({
  baseUrl: "https://cms.spunto.net/api",
  token: process.env.SPUNTO_CMS_TOKEN,
})

const { data, error } = await api.GET("/workspaces")

It reads SPUNTO_CMS_API_URL (then API_URL) and SPUNTO_CMS_TOKEN from the environment when you don't pass them, which is usually what a script wants.

Nothing in it is written by hand. Types come from openapi-typescript, requests from openapi-fetch, both regenerated from the committed specification — so a route that changes shape becomes a type error rather than a runtime surprise.

There is a Swift client too, generated from the same specification at build time for the iOS app.

Response fields are added as optional

A field added to a response schema is optional, always. The Swift generator decodes strictly: one extra required field does not degrade a client that hasn't been updated — it fails the entire response. Since clients deploy separately from the API, "the app is ahead of the server" is a normal state, not an incident. Write consumers accordingly.

Rate limits

There are none today. That is a statement about the current implementation, not a promise — do not build something that depends on their absence.

On this page