2026-09-27·by Sijie Wang#standmeet#architecture#design#events

webhooks

Webhook:thin、签名、限定范围

上级:events

状态: 已在 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)。

一次投递

一个端点

  • 租约。 每个端点同时只有一次投递,靠一行租约(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.testP1、P2
75–76access_request.created / .approved / .status_changedP2
77–78code.issued / .revoked / .redeemedP2
79–82conversation.started / .message / .pruned、ghost.acceptedP2
83booking.created / .cancelled / .rescheduledP4
84–85application.committed、jobs.fetchedP4
86–88writing.published / .unpublished、vault.importedP2
89–90microsite.build.settled、page.promoted_live / .rolled_back / .unpublished、microsite.store.doc_insertedP4
91–93api_key.issued / .revoked、supplier.connected / .disconnected / .activated、block.installed / .failedP2
94–97gas.exhausted / .refilled、instance.upgrade_requested、owner.login / .email_changed / .recovery_requested、ip_ban.addedP2

webhook 投递从不合并:同一主体的两条事件是两件事实(relay-claims-rows-not-cursor)。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →