开发
变更与 Webhook
每个空间都有一份按顺序的变更日志。可以直接读,可以通过 WebSocket 收到推送信号,也可以让它按顺序、带签名地 POST 到你的地址。
变更日志
空间里的每一次变化都按序号追加到它的变更日志(journal)里:没有空缺,不会乱序。同步客户端、流水线、Webhook 和 SDK 的 changes() 都是它的读者。
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
}记下处理过的最后一个 seq,下次用 after= 接着读。令牌需要 files:read 或 events:read。
op | 什么时候 |
|---|---|
node.created、node.renamed、node.moved、node.deleted、node.restored | 目录结构变了 |
version.created | 有人保存了;version_id 是新版本 |
version.labeled | 某个版本起了名或去掉了名字 |
sidecar.updated | 某个版本的元数据变了 |
rendition.updated | 缩略图或代理生成好了 |
incoming.started、incoming.stopped | 有人开始或停止上传这个文件(应用里显示“正在传入”) |
comment.created、comment.updated | 审阅评论 |
actor_kind 是 human、agent 或 plugin,可以用来跳过你自己工具的改动,或者显示是谁做的。
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 # 每行一个 JSON,直到 Ctrl-C
sidespace events tail "Film A" --after 1040 --no-follow实时信号
不想轮询的话,打开 GET /v1/ws(WebSocket;令牌放在 Bearer 请求头里,不能设请求头时用 ?access_token=),然后订阅:
{ "type": "subscribe", "spaces": ["spc_…"] }空间的变更日志有新内容时,服务器发 {"type": "journal", "space_id": "spc_…", "seq": 1042};有人打开文件时发 {"type": "presence", …};给你的通知是 {"type": "notification", …}。它从不直接发变更内容:收到信号后,从你的游标往后读变更日志。每 25 秒 ping 一次,60 秒没动静的连接会被关闭。信号可能重复或乱序,用 seq 和你的游标比较即可。
Webhook
空间管理员可以把变更日志推送到一个 URL(令牌需要 webhooks:manage)。
sidespace webhook create "Film A" https://hooks.example.com/side \
--event version.created --path "/dailies/**" --mime video/
# whsec_… ← 签名密钥,只显示这一次
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_…用 API:POST /v1/spaces/{sid}/webhooks,带 url、events、path_glob 和 mime_prefix。
收到什么
每个事件一个 POST,按变更日志的顺序,只带引用,不带文件内容:
{
"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" }
}过滤条件:事件类型(--event,可重复)、路径 glob(/dailies/**、**/*.mov)、MIME 前缀(video/)。comment.* 事件还会带上评论内容。
投递规则
- 10 秒内返回
2xx。其他情况都算失败,这个订阅后面的事件会等它成功,以保证顺序。 - 重试从 10 秒开始翻倍,最长 1 小时。连续失败 24 次(约 16 小时)后订阅自动停用,并写明原因。
enable或replay会清掉失败计数。 - 创建它的成员不再是空间管理员时,订阅停用。
- 生产环境只允许 https,且不能指向内网、本机或链路本地地址。
验证签名
每次投递带 Side-Signature: t=<unix 秒>,v1=<HMAC-SHA256(密钥, "<t>.<原始 body>") 的十六进制> 和 Side-Event-Id(等于 body 里的 id,重试和重放时不变)。用原始 body 验签,拒绝时间戳偏差超过 5 分钟的请求,并按事件 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); // 按 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"])