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/apiThe 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:
| Token | Who | Scope |
|---|---|---|
| a session | a person signing in | their workspaces, their role in each, 30 days |
spk_… | an API key | one workspace, one fixed role, until revoked |
spa_… | an installed app | one 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:
| Status | Means |
|---|---|
400 | The request is malformed, or a value failed validation |
401 | No bearer, or a bearer that is no longer valid |
404 | It does not exist — or it is not yours |
409 | The 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.