# 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.

```sh
curl -H "Authorization: Bearer $SIDESPACE_TOKEN" \
  "https://side.example.com/v1/spaces/$SPACE/journal?after=1040&limit=200"
```

```json
{
  "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.

```ts tab=TypeScript
for await (const change of side.changes("Film A", { after: lastSeq })) {
  console.log(change.seq, change.op, change.node.name);
}
```

```python tab=Python
for change in side.changes("Film A", after=last_seq):
    print(change["seq"], change["op"], change["node"]["name"])
```

```sh tab=CLI
sidespace events tail "Film A" --json            # one JSON object per line, until Ctrl-C
sidespace events tail "Film A" --after 1040 --no-follow
```

## Realtime 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:

```json
{ "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`).

```sh
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:

```json
{
  "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 `2xx` within 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. `enable` or `replay` clears 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.

```ts tab=TypeScript
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);
});
```

```python tab=Python
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 "", 204
```

```python tab="Python, no SDK"
import 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"])
```
