Sink 点击 Webhook 实战指南:HMAC 签名、事件负载与尽力投递机制

原创2026-09-16 16:59:501,226 阅读
文章标签:后端前端数据分析云原生

Sink 是一款完全运行在 Cloudflare 上的 Serverless 短链接服务。本文以官方文档 docs/zh-CN/configuration/webhooks.md 为主线,深入讲解其「点击 Webhook」可选功能:如何配置 NUXT_WEBHOOK_URL / NUXT_WEBHOOK_SECRET 环境变量,如何用 HMAC 签名验证投递真实性,事件负载包含哪些字段、刻意排除哪些隐私数据,以及「尽力投递、不重试、10 秒超时」的投递模型。读完你将能独立搭建一个接收端并正确校验每一条 link.clicked 事件,同时理解 Sink 在 server/utils/webhook.ts 中的完整实现链路。

功能概述:短链被点击时,把事件推送到你的服务器

点击 Webhook 是 Sink 的一个可选能力:当有人点击短链接时,Sink 会向你自己指定的 URL POST 一条小体积 JSON 事件。它适合做实时通知、自有数据管道、CRM 线索记录等场景,与 Sink 仪表盘内置的访问分析(Analytics)相互独立、互不依赖。

启用方式只需两个环境变量(见 docs/zh-CN/configuration/index.md 配置参考):

变量 说明
NUXT_WEBHOOK_URL 接收 Webhook 的 HTTP(S) 地址
NUXT_WEBHOOK_SECRET 可选签名密钥,必须以 whsec_ 开头

两者在 .env.example 中均有声明,且已在 nuxt.config.tsruntimeConfig 中注册为 webhookUrl / webhookSecret,并在 worker-configuration.d.ts 中纳入 Cloudflare Worker 环境变量类型。运行时可直接通过 useRuntimeConfig(event) 读取(见 server/utils/webhook.ts)。

一个关键边界:从访问分析中排除的机器人点击,这里同样不会发送。也就是说,被判定为爬虫/机器人并跳过日志的访问,也不会触发 Webhook,接收端收到的都是相对“干净”的真实用户点击事件。

事件触发链路:跳转中间件里如何产生 Webhook

从源码看,点击事件的采集与投递发生在短链跳转中间件 server/middleware/1.redirect.ts 中。流程大致如下:

  1. 解析 slug 并命中链接后,调用 collectAccessLog(event)(实现在 server/utils/access-log.ts)采集访问信息;
  2. 采集时基于 Cloudflare 的 botManagement.verifiedBot、UA 解析器分类(crawler / fetcher / spider / bot)判定是否为机器人,若命中且开启 disableBotAccessLog 则直接返回 undefined——这就是“机器人点击被排除”的底层逻辑(access-log.ts);
  3. 若采集成功,先写入访问日志,再调用 queueLinkClickedWebhook(event, accessLogResult.click, link) 排队投递(1.redirect.ts),并且整个投递过程包裹在 try/catch 中,即使调度失败也只记录 webhook.scheduling.failed 日志,绝不影响跳转响应

也就是说,Webhook 是跳转流程的“旁路”:用户 3xx 跳转照常返回,事件在后台异步发出。

签名验证:建议开启,基于 HMAC-SHA256

生成密钥

如果设置密钥,它必须以 whsec_ 开头。在安装了 OpenSSL 的机器上生成:

printf 'whsec_%s\n' "$(openssl rand -base64 32)"

源码对密钥有更严格的校验(server/utils/webhook.ts):去除 whsec_ 前缀后 Base64 解码,解码结果长度必须在 24~64 字节之间,否则抛 invalid_secret 错误。测试用例也验证了短密钥、非 Base64 密钥均会被拒绝(tests/webhook.spec.ts)。

请求头与签名算法

每个请求都携带以下请求头:

请求头 说明
webhook-id 事件 ID(形如 evt_<uuid>
webhook-timestamp 投递时的 Unix 秒级时间戳
webhook-signature 仅设置密钥时存在,格式为 v1,<base64>

签名的原文为三段用点号拼接的内容:

<webhook-id>.<webhook-timestamp>.<raw-body>

其中 raw-body未经过任何解析的原始请求体字符串JSON.stringify(payload) 的结果),签名算法为 HMAC-SHA256,密钥即解码后的 whsec_ 密钥,最终输出 v1, + Base64。实现见 signWebhookserver/utils/webhook.ts)。

务必在解析 JSON 之前校验原始请求体。 因为签名覆盖的是原始字节,任何“先 JSON.parse、再重新 stringify”的做法都会改变字节内容导致验签失败。

测试提供了一个固定签名向量,便于你自建接收端时做对照验证(tests/webhook.spec.ts):

await signWebhook('evt_test', 1_700_000_000, '{"hello":"world"}', secret)
// → 'v1,zbepGFaw3CoyW6kZTr419oJi4XIEPboIqPX1vXGLWlI='

验签失败时的行为:不会回退为未签名

这是文档强调的重要安全边界:只要配置了非空密钥,密钥错误(如前缀不对、长度非法)会导致投递失败,而不会降级为不带签名的投递。对应的测试 does not downgrade an invalid non-empty secret to unsigned delivery 验证了这一点:createWebhookDelivery 直接 rejectsfetch 根本不会被调用(tests/webhook.spec.ts)。因此接收端可以放心地“验签失败即拒绝”,不必担心与合法但未签名的请求混淆——未签名请求只会在你没有配置密钥时出现。

事件类型为 link.clicked,其结构由 Zod Schema 定义并严格校验(shared/schemas/webhook.ts):

{
  "id": "evt_9d2e2c9a-...",
  "event": "link.clicked",
  "createdAt": "2026-07-11T12:00:00.000Z",
  "data": {
    "click": {
      "id": "clk_8f1b...",
      "timestamp": "2026-07-11T12:00:00.000Z",
      "country": "US",
      "region": "California",
      "city": "San Francisco",
      "device": "mobile",
      "browser": "Mobile Safari",
      "os": "iOS",
      "referer": "example.com"
    },
    "link": {
      "id": "link_test",
      "slug": "test"
    }
  }
}

字段说明:

层级 字段 含义
顶层 id 事件 ID,格式 evt_<uuid>,同时作为 webhook-id 请求头
顶层 event 固定为 link.clicked
顶层 createdAt 事件创建时间(ISO 8601)
data.click id 点击 ID,格式 clk_<uuid>
data.click timestamp 点击发生时间,与 createdAt 相同
data.click country / region / city 基于 Cloudflare 请求地理位置的国家 / 地区 / 城市
data.click device 设备类型或型号(如 mobileiPhone
data.click browser / os 从 User-Agent 解析出的浏览器与操作系统名称
data.click referer 来源站点的主机名(非完整 URL)
data.link id / slug 被点击链接的 ID 与短链码

明确不包含的数据(隐私边界)

文档明确列出以下数据不会出现在负载中,测试用例也逐一断言(tests/webhook.spec.ts):

  • IP 地址(顶层与 data.click 均无 ip 字段)
  • 经纬度坐标(无 latitude / longitude
  • 完整 User-Agent(无 ua 字段,只有解析后的 browser / os / device
  • 查询参数(无 query 字段)
  • 访问密码data.linkpassword 字段)
  • 目标 URLdata.link 只含 idslug,不含 url

Schema 使用 .strict() 模式,且测试验证了往负载里塞入 ipurl 等额外敏感字段会直接导致 parse 抛错(tests/webhook.spec.ts)——从数据模型层面杜绝了敏感信息外泄的可能。负载构造实现在 createLinkClickedWebhookserver/utils/webhook.ts)。

投递限制:尽力而为、绝不阻塞跳转

文档将投递模型总结为“尽力投递”(Best-effort delivery),结合源码可以拆解为四条硬性约束:

  1. 异步且不阻塞跳转:投递 Promise 通过 Cloudflare Workers 的 ExecutionContext.waitUntil 挂到后台执行(scheduleWebhookDeliveryserver/utils/webhook.ts),用户跳转响应不受任何影响;调度过程即使失败也只会记录 webhook.scheduling.failed 日志(1.redirect.ts)。
  2. 失败不重试handleWebhookDelivery 只负责把错误以 webhook.delivery.failed 事件写入 console.error,不会做任何指数退避或重发(server/utils/webhook.ts)。
  3. 10 秒超时:常量 WEBHOOK_TIMEOUT_MS = 10_000,用 AbortController 在 10 秒后中断请求(server/utils/webhook.ts)。你的服务器必须在 10 秒内返回 2xx,否则投递失败且不重试。
  4. 仅接受 2xx:非 2xx 响应(测试中以 302 为例)会抛 unexpected_status 错误(tests/webhook.spec.ts);同时请求以 redirect: 'manual' 发起,跟随重定向不会计入成功。

此外,URL 校验很严格:只允许 http: / https: 协议,且禁止在 URL 中携带用户名或密码user:password@ 形式),否则抛 invalid_urlserver/utils/webhook.ts,测试见 tests/webhook.spec.ts)。因此请优先使用 HTTPS 端点,且不要在 URL 里内嵌凭据。

接收端参考实现:验签 → 校验时间戳 → 处理事件

综合以上约定,一个最小可用的接收端逻辑应包含四步:

  1. webhook-idwebhook-timestampwebhook-signature(若配置了密钥则必须存在);
  2. whsec_ 密钥对 <webhook-id>.<webhook-timestamp>.<raw-body> 计算 HMAC-SHA256 并与 v1,<base64> 比对,验签失败直接 4xx;
  3. 校验时间戳与当前时间的偏差(如 ±5 分钟),防御重放;
  4. 再解析 JSON 处理 event === 'link.clicked' 的事件,并保证在 10 秒内返回 2xx。

由于 Sink 的签名方案与 Svix 兼容的 whsec_ / v1,<base64> 约定一致,你可以直接复用常见的 Webhook 签名验证库或按本文算法自行实现。

小结

  • 启用:设置 NUXT_WEBHOOK_URL(可选 NUXT_WEBHOOK_SECRET),机器人点击自动排除;
  • 验签webhook-signature: v1,<base64> 基于 id.timestamp.raw-body 的 HMAC-SHA256,必须在解析 JSON 前校验原始体,密钥错误不会回退为未签名;
  • 数据link.clicked 只包含事件 ID/时间、链接 ID/短链码与点击属性,严格排除 IP、坐标、完整 UA、查询参数、密码与目标 URL,且有 Zod .strict() Schema 兜底;
  • 投递:异步 + waitUntil 后台执行,10 秒超时、不重试、仅 2xx 算成功,失败只记日志不影响跳转。

相关源码与测试可继续深入阅读:server/utils/webhook.ts(核心实现)、shared/schemas/webhook.ts(负载 Schema)、tests/webhook.spec.ts(签名向量与投递行为测试)、server/middleware/1.redirect.ts(触发链路)、server/utils/access-log.ts(点击属性采集)。

Sink