Sink 点击 Webhook 实战指南:HMAC 签名、事件负载与尽力投递机制
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.ts 的 runtimeConfig 中注册为 webhookUrl / webhookSecret,并在 worker-configuration.d.ts 中纳入 Cloudflare Worker 环境变量类型。运行时可直接通过 useRuntimeConfig(event) 读取(见 server/utils/webhook.ts)。
一个关键边界:从访问分析中排除的机器人点击,这里同样不会发送。也就是说,被判定为爬虫/机器人并跳过日志的访问,也不会触发 Webhook,接收端收到的都是相对“干净”的真实用户点击事件。
事件触发链路:跳转中间件里如何产生 Webhook
从源码看,点击事件的采集与投递发生在短链跳转中间件 server/middleware/1.redirect.ts 中。流程大致如下:
- 解析 slug 并命中链接后,调用
collectAccessLog(event)(实现在 server/utils/access-log.ts)采集访问信息; - 采集时基于 Cloudflare 的
botManagement.verifiedBot、UA 解析器分类(crawler/fetcher/spider/bot)判定是否为机器人,若命中且开启disableBotAccessLog则直接返回undefined——这就是“机器人点击被排除”的底层逻辑(access-log.ts); - 若采集成功,先写入访问日志,再调用
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。实现见 signWebhook(server/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 直接 rejects 且 fetch 根本不会被调用(tests/webhook.spec.ts)。因此接收端可以放心地“验签失败即拒绝”,不必担心与合法但未签名的请求混淆——未签名请求只会在你没有配置密钥时出现。
事件负载:link.clicked 的完整字段与隐私边界
事件类型为 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 |
设备类型或型号(如 mobile、iPhone) |
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.link无password字段) - 目标 URL(
data.link只含id与slug,不含url)
Schema 使用 .strict() 模式,且测试验证了往负载里塞入 ip、url 等额外敏感字段会直接导致 parse 抛错(tests/webhook.spec.ts)——从数据模型层面杜绝了敏感信息外泄的可能。负载构造实现在 createLinkClickedWebhook(server/utils/webhook.ts)。
投递限制:尽力而为、绝不阻塞跳转
文档将投递模型总结为“尽力投递”(Best-effort delivery),结合源码可以拆解为四条硬性约束:
- 异步且不阻塞跳转:投递 Promise 通过 Cloudflare Workers 的
ExecutionContext.waitUntil挂到后台执行(scheduleWebhookDelivery,server/utils/webhook.ts),用户跳转响应不受任何影响;调度过程即使失败也只会记录webhook.scheduling.failed日志(1.redirect.ts)。 - 失败不重试:
handleWebhookDelivery只负责把错误以webhook.delivery.failed事件写入console.error,不会做任何指数退避或重发(server/utils/webhook.ts)。 - 10 秒超时:常量
WEBHOOK_TIMEOUT_MS = 10_000,用AbortController在 10 秒后中断请求(server/utils/webhook.ts)。你的服务器必须在 10 秒内返回 2xx,否则投递失败且不重试。 - 仅接受 2xx:非 2xx 响应(测试中以 302 为例)会抛
unexpected_status错误(tests/webhook.spec.ts);同时请求以redirect: 'manual'发起,跟随重定向不会计入成功。
此外,URL 校验很严格:只允许 http: / https: 协议,且禁止在 URL 中携带用户名或密码(user:password@ 形式),否则抛 invalid_url(server/utils/webhook.ts,测试见 tests/webhook.spec.ts)。因此请优先使用 HTTPS 端点,且不要在 URL 里内嵌凭据。
接收端参考实现:验签 → 校验时间戳 → 处理事件
综合以上约定,一个最小可用的接收端逻辑应包含四步:
- 取
webhook-id、webhook-timestamp、webhook-signature(若配置了密钥则必须存在); - 用
whsec_密钥对<webhook-id>.<webhook-timestamp>.<raw-body>计算 HMAC-SHA256 并与v1,<base64>比对,验签失败直接 4xx; - 校验时间戳与当前时间的偏差(如 ±5 分钟),防御重放;
- 再解析 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(点击属性采集)。