Webhook:thin、签名、限定范围
状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。
Webhook 就是“出实例的订阅方”。投递是 owner 域里的两种任务(internal/owner/subscriber/webhooks.go):
webhook.fanout订阅所有Webhook可见的类型。它读出 owner 启用的端点,匹配类型通配,做范围判定,在一个事务里给每个端点入队一个webhook.deliver {endpoint_id, event_id}。入队按参数唯一,所以扇出跑两遍也只排一次投递。webhook.deliver先拿端点的租约,读事件和密钥,用 Standard Webhooks 的头给 thin payload 签名并 POST,最后结算端点状态。重试归 River 管(retry-has-one-owner)。
端点
webhook_endpoints (id, owner_id, url, description, event_types text[] -- glob list,
secret_enc, embed_id NULL -- scope source, enabled,
disabled_reason, failing_since, busy_until, created_at, updated_at)
ops 由 owner 域声明一次,MCP 和后台(/api/admin/webhooks*)都是它的投影。
| op | 做什么 |
|---|---|
webhooks.list / .create / .update / .delete |
端点增删改查;update 可以关掉端点,重新打开会清掉失败状态 |
webhooks.rotate_secret |
新密钥只显示这一次 |
webhooks.send_test |
只给这个端点发一条 webhook.test 事件 |
webhooks.deliveries |
读该端点的 webhook.deliver 任务行:尝试次数、状态、最后错误、任务链接 |
webhooks.redeliver |
对方修好后,把该端点所有 discarded 的投递重投一遍 |
webhooks.event_types |
列出 Exposure 为 Webhook 的类型 |
后台在 Integrations 下有 Webhooks 页:端点列表、填 URL 并勾选事件类型的表单、一次性密钥、投递日志(每行链到 /admin/tasks?job=<id>)、发送测试。端点是实例配置,和 suppliers 同类。
线格式:Standard Webhooks + thin event
POST <endpoint url>
webhook-id: <event id>
webhook-timestamp: 1790412000
webhook-signature: v1,<base64 HMAC-SHA256(key, id.timestamp.body)>
{ "id": "…", "type": "corpus.note.changed",
"subject": "wiki://software/project/standmeet/architecture", "occurred_at": "…",
"data": { "op": "updated", "note_id": "…", "published": true, "was_published": true } }
- 只带类型、主体和 id,没有正文;接收方回它本来就用的公开或授权 API 取数据。删除事件带最后已知的主体。
- 每次重试的
webhook-id都等于事件 id,消费方按它去重(message-loss-guarantees)。 - 每个端点一把密钥:
whsec_+ 32 字节随机数的 base64;HMAC 的 key 是解码后的部分。用cryptobox封存落盘,只在cmd/server/unseal.go里解封。 - 经
httpx.NewClient{BlockInternalEgress: true, NoRetry: true}发出,HTTP 超时 10 秒,在 15 秒任务超时之内;内网、回环、重定向到内网的地址被拦截并直接 discarded。 - 有一条 UT 拿 Standard Webhooks 的官方测试向量校验签名。
范围:一个判定,raw 永不出
只有 corpus.note.changed 需要判范围。其它类型发给所有订阅了它的端点(webhook.test 只发给它点名的那个端点)。
raw://永不出实例。- 单独创建的端点看已发布切片:笔记已发布,或刚刚还是已发布(
published或was_published)时才发出,所以取消发布也能被听到。 - 挂在 embed 上的端点用该 embed 访问码的范围,由 access 域回答(
EmbedAdmits),用唯一的 ACL 判定entity.AllowsCorpusEntry:角色 glob 减去码的 deny。只有码的角色读已发布切片时published才起作用。码被吊销或过期、embed 被删,一律不放行。每条事件都重新问一次,因为码随时可能被吊销或改范围(embed-update-hook)。 - embed 范围是组合根交进来的一个函数,所以 owner 域的订阅方不 import access 域的内部,
infra/events也不 import 任何域。 Exposure默认Internal,忘了归类的事件出不了实例(event-model)。
一次投递
stateDiagram-v2 [*] --> pending : webhook.fanout (scope passed, 5 min out while the endpoint is failing) pending --> running : worker claims (SKIP LOCKED) running --> pending : endpoint busy → snooze 2 s (no attempt spent) running --> completed : 2xx running --> pending : 429 / 503 with Retry-After → snooze (no attempt spent) running --> retryable : 5xx / 408 / 429 / timeout / connection failure running --> discarded : other 4xx · SSRF-blocked · endpoint disabled or gone · event pruned retryable --> pending : backoff 5s·5m·30m·2h·5h·10h… retryable --> discarded : 18 attempts (about 5.3 days) completed --> [*] discarded --> [*]
一个端点
stateDiagram-v2 [*] --> enabled : webhooks.create (secret shown once) enabled --> failing : any non-success (failing_since set) failing --> enabled : any delivery succeeds (failing_since cleared) failing --> disabled : 5 days of continuous failure (reason recorded) disabled --> enabled : owner turns it on (clears the failure) enabled --> enabled : rotate_secret / send_test enabled --> [*] : webhooks.delete disabled --> [*] : webhooks.delete
- 租约。 每个端点同时只有一次投递,靠一行租约(
busy_until,用UPDATE … RETURNING拿)。没用 advisory 事务锁:那样 POST 期间要一直占着事务(concurrency-control)。 - 冷却。
failing_since有值期间,新投递排到 5 分钟后,挂掉的接收方不会每来一条新事件就被撞一次。 - 任何非成功都会设
failing_since,410 和 429 也算;一次成功就清掉。停用的端点不再有新投递。
开放的事件类型
声明的 39 个类型全部 Webhook 可见、全部 thin,默认都不订阅。它们都是 owner 自己实例的事实;安全类事件恰好适合告警。完整清单在 event-model,扫描条目在 consolidation-inventory。有一条 e2e(webhook-event-types)对每个类型各做一次真实动作,断言 * 端点收到。
| # | 事件 | 期 |
|---|---|---|
| — | corpus.note.changed、webhook.test |
P1、P2 |
| 75–76 | access_request.created / .approved / .status_changed |
P2 |
| 77–78 | code.issued / .revoked / .redeemed |
P2 |
| 79–82 | conversation.started / .message / .pruned、ghost.accepted |
P2 |
| 83 | booking.created / .cancelled / .rescheduled |
P4 |
| 84–85 | application.committed、jobs.fetched |
P4 |
| 86–88 | writing.published / .unpublished、vault.imported |
P2 |
| 89–90 | microsite.build.settled、page.promoted_live / .rolled_back / .unpublished、microsite.store.doc_inserted |
P4 |
| 91–93 | api_key.issued / .revoked、supplier.connected / .disconnected / .activated、block.installed / .failed |
P2 |
| 94–97 | gas.exhausted / .refilled、instance.upgrade_requested、owner.login / .email_changed / .recovery_requested、ip_ban.added |
P2 |
webhook 投递从不合并:同一主体的两条事件是两件事实(relay-claims-rows-not-cursor)。