跳到正文

Webhooks 与事件订阅

查看 Markdown

本文介绍开发者平台的 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 校验原理

签名计算规则如下:

  1. 从 X-Kwork-Signature 头中分别提取 Unix 时间戳 t 与签名字符串 v1。
  2. 构造待签名字符串:"<t>.<原始请求正文>"。必须使用未经 JSON 解析的原始请求正文字节流。
  3. 使用你的 Webhook 密钥作为 Key,计算待签名字符串的 HMAC-SHA256 哈希值,输出为小写十六进制字符串。
  4. 使用恒定时间比较函数核对计算值与 v1 是否一致。
  5. 核对时间戳 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),不单独授予,也收不到这个事件。

本页目录