Build
Changes and webhooks
Every space keeps an ordered journal of what changed. Read it, get a push signal over a WebSocket, or have it POSTed to your URL, signed and in order.
The journal
Everything that changes a space is appended to its journal with a sequence number: no gaps, no reordering. Sync clients, pipelines, webhooks and changes() in the SDKs are all readers of it.
curl -H "Authorization: Bearer $SIDESPACE_TOKEN" \
"https://side.example.com/v1/spaces/$SPACE/journal?after=1040&limit=200"{
"entries": [
{
"seq": 1041,
"op": "version.created",
"node_id": "nod_…",
"version_id": "ver_…",
"actor_id": "usr_…",
"actor_kind": "agent",
"actor_via": "tok_…",
"ts": "2026-11-14T09:30:00Z",
"node": { "id": "nod_…", "name": "A001_C003.mov", "parent_id": "nod_…", "head_version_id": "ver_…" }
}
],
"last_seq": 1041,
"has_more": false
}Keep the last seq you processed and ask for after= it next time. Tokens need files:read or events:read.
op | When |
|---|---|
node.created, node.renamed, node.moved, node.deleted, node.restored | The tree changed |
version.created | Someone saved; version_id is the new version |
version.labeled | A version was named or its name cleared |
sidecar.updated | Metadata on a version changed |
rendition.updated | A thumbnail or proxy is ready |
incoming.started, incoming.stopped | Someone began or stopped uploading this file (the apps show "arriving") |
comment.created, comment.updated | Review comments |
actor_kind is human, agent or plugin, so you can ignore your own tool's changes or show who did what.
for await (const change of side.changes("Film A", { after: lastSeq })) {
console.log(change.seq, change.op, change.node.name);
}for change in side.changes("Film A", after=last_seq):
print(change["seq"], change["op"], change["node"]["name"])sidespace events tail "Film A" --json # one JSON object per line, until Ctrl-C
sidespace events tail "Film A" --after 1040 --no-followRealtime signals
Rather than polling, open GET /v1/ws (a WebSocket; send the token as a bearer header, or ?access_token= where headers can't be set) and subscribe:
{ "type": "subscribe", "spaces": ["spc_…"] }The server sends {"type": "journal", "space_id": "spc_…", "seq": 1042} when a space's journal grows, {"type": "presence", …} when someone opens a file, and {"type": "notification", …} for you. It never sends the changes themselves: on a signal, read the journal after your cursor. Ping every 25 seconds; silent connections close after 60. Signals may repeat or arrive out of order, so compare seq with your cursor.
Webhooks
A space manager can have the journal POSTed to a URL (token scope webhooks:manage).
sidespace webhook create "Film A" https://hooks.example.com/side \
--event version.created --path "/dailies/**" --mime video/
# whsec_… ← the signing secret, shown once
sidespace webhook list "Film A"
sidespace webhook ping "Film A" whk_…
sidespace webhook deliveries "Film A" whk_…
sidespace webhook replay "Film A" whk_… --from 120
sidespace webhook enable "Film A" whk_… [--off]
sidespace webhook rm "Film A" whk_…Over the API: POST /v1/spaces/{sid}/webhooks with url, events, path_glob and mime_prefix.
What arrives
One POST per event, in journal order, with references only, never file content:
{
"id": "spc_….1042",
"type": "version.created",
"space_id": "spc_…",
"seq": 1042,
"time": "2026-11-14T09:30:00Z",
"node_id": "nod_…",
"version_id": "ver_…",
"path": "/dailies/A001_C003.mov",
"node": { "name": "A001_C003.mov", "mime": "video/quicktime", "size": 734003200 },
"actor": { "id": "usr_…", "kind": "human" }
}Filters: event types (--event, repeatable), a path glob (/dailies/**, **/*.mov) and a MIME prefix (video/). comment.* events also carry the comment.
Delivery
- Answer
2xxwithin 10 seconds. Anything else is a failure, and later events for that subscription wait until it succeeds, so order is kept. - Retries start after 10 seconds and double up to an hour. After 24 failures in a row (about 16 hours) the subscription turns itself off and says why.
enableorreplayclears the count. - If the member who made it stops being a space manager, it turns off.
- In production, URLs must be https and can't point at private, loopback or link-local addresses.
Check the signature
Each delivery has Side-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")> and Side-Event-Id (the body's id, the same on every retry and replay). Check the signature on the raw body, reject timestamps more than five minutes off, and dedupe on the event id.
import { verifyWebhookSignature } from "@sidespace/sdk/webhooks";
app.post("/hooks/side", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWebhookSignature(process.env.SIDE_WEBHOOK_SECRET!, req.get("Side-Signature"), req.body)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString());
queue.add(event.id, event); // dedupe on id
res.sendStatus(204);
});from sidespace import verify_webhook_signature
@app.post("/hooks/side")
def hook():
if not verify_webhook_signature(SECRET, request.headers.get("Side-Signature"), request.get_data()):
abort(401)
event = request.get_json()
return "", 204import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
want = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, parts["v1"])