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"
}changesholds the keys that actually changed, with before and after — never the whole resource. A registry property shows up asproperties.<key>.- the actor is frozen, label included. Revoke a key and the log still says which one did it.
causeiseditorimport.?cause=editis what an activity feed wants — without it, one import of three thousand cards buries six months of work.batchIdis 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=ascThe 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}/replayAn 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.