Spunto CMS
The API

Events, webhooks and apps

How other software follows what happens here — a log you can replay from, deliveries you can verify, and scoped installations.

Everything that changes in a workspace is written to an event log, and an integration can follow it two ways: pull it by cursor, or have it pushed to a webhook.

Both exist, and the order matters: the log is the mechanism, webhooks are the convenience. If a webhook is missed, the log is still there.

The log

Events are written inside the transaction that changes the data, never from an HTTP handler.

That is the same rule as everywhere else in this product — the barrier goes at the write, in one place — and it has a consequence worth stating as a guarantee:

An event exists if and only if the change committed.

Emitting from HTTP would have missed the seed, imports, field deletion and bulk edits. It also means the log is a data-model concern rather than a logging concern: events sit under row-level security, and UPDATE is revoked on the table. An event is not rewritten.

What an event carries

{
  "id": "0192…",
  "occurredAt": "2026-09-13T09:12:44.000Z",
  "type": "item.updated",
  "resource": { "kind": "item", "id": "…" },
  "actor": { "kind": "api_key", "label": "Site build", "apiKeyId": "…" },
  "changes": { "statusId": { "before": "…", "after": "…" } },
  "batchId": null,
  "cause": "edit"
}
  • changes holds the keys that actually changed, with before and after — never the whole resource. A registry property shows up as properties.<key>.
  • the actor is frozen, label included. Revoke a key and the log still says which one did it.
  • cause is edit or import. ?cause=edit is what an activity feed wants — without it, one import of three thousand cards buries six months of work.
  • batchId is non-null when this event is one of many from a single action.

Event types

item.created, item.updated, item.moved, item.deleted, item.attached, item.detached, collection.created, collection.updated, collection.deleted, collection_group.created, collection_group.updated, collection_group.deleted, field.created, field.updated, field.deleted, status.created, status.updated, status.deleted, view.created, view.updated, view.deleted, workspace.updated.

It is a closed list, published in the specification, because a webhook subscribes by pattern and a consumer should be able to know what it can subscribe to without reading our code.

workspace.updated has no created sibling: the log lives inside the workspace, so the workspace does not exist yet when it is created.

Reading it

GET /workspaces/{workspaceId}/events?since=<eventId>&type=item.updated&order=asc

The cursor is the event id, and it is readable on purpose — the one exception to "cursors are opaque".

The rule against readable cursors was aimed at offsets; a keyset on a time-ordered UUID is not one. And a consumer that just received a webhook has an event in its hand and must be able to say "carry on from that one". The recovery mechanism has to be the simplest thing in the system, because it is the one you reach for when something has just broken.

since is exclusive. order=asc is the log's own order, oldest first — what an integration wants; desc is for an activity feed. Filter by type (repeat the parameter to add more), by batchId, or by cause.

Webhooks

POST   /workspaces/{workspaceId}/webhooks
GET    /workspaces/{workspaceId}/webhooks/{endpointId}/deliveries
POST   /workspaces/{workspaceId}/webhooks/{endpointId}/resume
POST   /workspaces/{workspaceId}/webhooks/{endpointId}/deliveries/{deliveryId}/replay

An endpoint has a URL and a list of eventTypes. Exactly three forms are accepted — * for everything, item.* for a family, or an exact type — and that poverty is deliberate: a pattern language you cannot explain in one sentence is one where nobody knows what they subscribed to. A pattern matching no known type is refused at creation rather than silently never firing.

Filtering happens when a delivery is queued, not when it is sent.

Verifying a delivery

The signing secret is returned once, when the endpoint is created.

X-Spunto-Signature: t=<unix seconds>,v1=<hmac-sha256 of "t.body">

The timestamp is inside the signature. Without it, a captured delivery replays forever.

Compute the HMAC over the literal string `${t}.${rawBody}` with your secret, compare in constant time, and reject a timestamp that is too old for your taste.

Retries, and what happens when your endpoint is down

A failed delivery is retried five times, backing off 10s, 30s, 2min, 10min, 1h — six attempts in total. Each request times out after 10 seconds, so one slow endpoint does not hold up the others.

After 10 consecutive failures, the endpoint is paused.

That pause is safe precisely because cursor reading exists: nothing is lost while it is paused, everything remains readable from the log. Without it, a dead endpoint would grow the queue forever and eat the delivery budget of the live ones.

POST …/resume un-pauses it and brings its pending deliveries due immediately — otherwise the endpoint would be live while its queue still waited out a backoff computed during the incident.

GET …/deliveries shows attempts, status codes and last errors. Any one of them can be replayed.

Batches

A bulk edit or an import does not send one webhook per card. It sends one delivery for the batch:

{
  "kind": "batch",
  "type": "item.updated",
  "batch": {
    "batchId": "…",
    "count": 3000,
    "read": { "batchId": "…", "path": "/workspaces/…/events?batchId=…&type=item.updated" }
  }
}

Under 50 events the individual events are inlined as events; above that you follow read.path. The type is in that query because one batch can carry several — an import creates and updates in the same breath, and an endpoint subscribed only to item.created should not receive the updates.

Apps and scopes

Third-party software installs into a workspace through OAuth and gets a token (spa_…) that carries a role and a set of scopes. It can never exceed either.

Scopes are per resource family × read/write: items:read, items:write, schema:read, schema:write, members:read, files:read, files:write, events:read, webhooks:read, webhooks:write. GET /apps/scopes lists them with labels.

Two decisions worth knowing about:

The route-to-scope table is fail-closed. A route it does not name is refused to every app. A "GET means read" heuristic would have broken on the first POST …/query — which reads — and every read route reached by POST is therefore named individually.

Some routes are human-only. Managing API keys, invitations, members or app installations requires a session. An app that could install itself would not need installing.

An app token does not expire. The lifetime is the installation — revocable on its own, and surviving the departure of whoever installed it.

On this page