Webhooks 与事件订阅
本文介绍开发者平台的 Webhooks 事件信封格式、签名校验实现、重试策略及应用生命周期事件清单。
理解 Webhook 事件信封
所有向外投递的事件均采用统一的事件信封结构:
{
"id": "evt_01JBCDEF0123456789ABCDEF01",
"type": "app.installed",
"createdAt": "2026-10-28T12:00:00Z",
"apiVersion": "2026-10-28",
"tenant": "acme_corp",
"environment": "live",
"app": "shop-sync",
"data": {
"version": "1.0.0",
"scopes": ["tables.base:read", "platform:ping"]
}
}字段说明:
id:事件的唯一标识。type:事件类型。createdAt:事件发生的 UTC 时间(RFC 3339 格式)。apiVersion:生成该事件的接口版本。tenant:触发事件的租户标识。当商店应用收到订阅事件时,平台会在信封中附带安装该应用的真实租户 ID。environment:事件所属环境,为test或live。app:事件接收方的应用 ID。data:与具体事件类型相关的业务载荷数据。
校验 Webhook 签名
每个 Webhook 请求均包含以下 HTTP 头部:
X-Kwork-Signature:格式为t=<时间戳>,v1=<十六进制签名>。X-Kwork-Event:事件类型,与信封中type一致。X-Kwork-Delivery:这次投递的唯一标识,同一次投递的重试不变。
HMAC 校验原理
签名计算规则如下:
- 从
X-Kwork-Signature头中分别提取 Unix 时间戳t与签名字符串v1。 - 构造待签名字符串:
"<t>.<原始请求正文>"。必须使用未经 JSON 解析的原始请求正文字节流。 - 使用你的 Webhook 密钥作为 Key,计算待签名字符串的 HMAC-SHA256 哈希值,输出为小写十六进制字符串。
- 使用恒定时间比较函数核对计算值与
v1是否一致。 - 核对时间戳
t与当前时间的差值(如不超过 300 秒),防止重放攻击。
使用 Go SDK 验签
Go SDK 提供了内置的验签辅助函数:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/21stware/raise-go"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "无法读取正文", http.StatusBadRequest)
return
}
sigHeader := r.Header.Get("X-Kwork-Signature")
secret := "${WEBHOOK_SECRET}"
// 使用 SDK 验签
event, err := raise.VerifyWebhook(body, sigHeader, secret, 300*time.Second)
if err != nil {
http.Error(w, "验签失败", http.StatusUnauthorized)
return
}
_ = event // event.Type、event.Data:按 event.ID 去重
w.WriteHeader(http.StatusOK)
}
// 原生 HMAC 验签参考实现
func verifyPlainHMAC(body []byte, sigHeader, secret string, tolerance time.Duration) bool {
var timestampStr, signature string
for _, part := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) == 2 {
switch kv[0] {
case "t":
timestampStr = kv[1]
case "v1":
signature = kv[1]
}
}
}
ts, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil || time.Since(time.Unix(ts, 0)).Abs() > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestampStr + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}使用 TypeScript SDK 验签
TypeScript SDK 提供了零依赖的 webhooks.verify 方法:
import http from 'node:http';
import { webhooks } from '@21stware/raise-sdk';
const secret = process.env.WEBHOOK_SECRET || '${WEBHOOK_SECRET}';
const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/webhooks') {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
const rawBody = Buffer.concat(chunks).toString('utf8');
const signature = req.headers['x-kwork-signature'] as string;
try {
const event = await webhooks.verify(rawBody, signature, secret, 300);
console.log('接收到事件:', event.type, event.id);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true }));
} catch (err) {
console.error('签名校验失败:', err);
res.writeHead(401, { 'Content-Type': 'text/plain' });
res.end('Invalid signature');
}
} else {
res.writeHead(404).end();
}
});
server.listen(3000);处理事件推送重试
你的 Webhook 服务接收到事件后,必须返回 HTTP 2xx 状态码以确认接收。
如果你的服务返回非 2xx 状态码、连不上,或者 10 秒内没有答完,平台会重试:依次等 30 秒、2 分钟、10 分钟、1 小时、6 小时再发。一次投递最多发 6 次(第一次加 5 次重试),从第一次到最后一次大约 7 小时 12 分钟;第 6 次仍然失败,这次投递标为失败,不再发送。响应正文平台不保存(最多读 64 KiB 后丢弃),所以收到后尽快回答 2xx,慢的工作放在回答之后。
重试时请求正文不变,X-Kwork-Delivery 与信封里的 id 也不变;X-Kwork-Signature 里的时间戳和签名每次重新计算。按 id 做成幂等:第二次收到同一个 id 仍回答 2xx,不要再执行一遍副作用。
不保证顺序:几条投递会并行发出,重试的那条可能晚于之后发生的事件到达。
在开发者平台应用的「事件订阅」里,每条订阅的「投递记录」列出最近 50 条投递:事件类型、时间、已发次数、最后一次的响应状态码或错误、状态(等待、已送达、失败)。没有投递耗时,也没有响应正文。已经结束(已送达或失败)的记录在 30 天后删除;删除订阅时,它的投递记录一起删除。
目前没有重新投递:失败的投递不能在控制台或通过接口再发一次。「发送测试」会向这条订阅发一个 open.ping 测试事件,用来检查地址和验签,它不是重发。漏掉的事件要靠你自己调用接口取最新状态来补。
监听应用生命周期事件
应用生命周期事件由平台自动发给应用专属的开发者订阅,无需单独申请权限范围:
事件类型 (type) | 数据载荷 (data) | 触发场景 |
|---|---|---|
app.installed | {"version": "1.0.0", "scopes": [...]} | 租户管理员在组织内安装了该应用,附带初始生效的权限范围。 |
app.uninstalled | {} | 租户管理员从组织内卸载了该应用。 |
app.upgraded | {"from": "1.0.0", "to": "1.1.0", "scopes": [...]} | 租户内的应用升级到了新版本。 |
app.scopes_changed | {"scopes": [...], "added": [...], "removed": [...]} | 这个租户里应用生效的权限范围变了:管理员收回或重新授予了已安装版本所请求的某些范围,或者升级后生效的范围和升级前不同。只发给没有页面的后端应用(integration);管理员不能授予版本没有请求的范围。有页面的应用的范围来自清单的 uses(见 App API),不单独授予,也收不到这个事件。 |