三种语言的签名验证、投递保证,以及处理程序约定。
Webhook 是你得知任何事情发生的途径。轮询也能用,在开发调试时完全没问题;但正式的付款流程不应该靠它来运行。
curl -X POST $PAYGATE/v1/webhook_endpoints \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your.app/hooks/paygate",
"events": ["payment.paid", "payment.reversed", "payment.underpaid"]
}'
响应中会返回签名密钥,而且只返回这一次。请在关闭终端之前保存好它;之后无法再次读取。
不属于本网关所发出事件的事件名称,会在注册时被拒绝。这是刻意为之:否则,订阅 payment.suceeded 会让你得到一个已配置、已启用、却永远收不到任何东西的端点,而且任何地方都没有错误来解释原因。
每次投递都带有:
X-Paygate-Signature: t=1756300000,v1=8f3a…
签名是 HMAC-SHA256(secret, "<timestamp>.<raw body>"),采用十六进制编码。
时间戳是被签名的,而不只是随请求发送。只对请求体计算的 HMAC 在密钥的整个有效期内都可以被重放——任何人只要从代理日志或工单截图中截获过一次有效投递,就能原样重发,而且每项校验都会通过,因为请求体和签名确实匹配。对 timestamp.body 签名并拒绝过旧的时间戳,就把这个窗口限制在了几分钟之内。
请使用五分钟的容差,并以常量时间进行比较。
import crypto from 'node:crypto';
export function verify(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=', 2)),
);
const ts = Number(parts.t);
const sig = parts.v1;
if (!ts || !sig) throw new Error('malformed signature header');
if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) throw new Error('signature expired');
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest();
const got = Buffer.from(sig, 'hex');
if (got.length !== expected.length) throw new Error('signature mismatch');
if (!crypto.timingSafeEqual(got, expected)) throw new Error('signature mismatch');
}
rawBody 是收到时的原始字节。如果你的框架已经解析并重新序列化了 JSON,签名就不会匹配——键的顺序和空白字符都是签名内容的一部分。在 Express 中,请在这个路由上使用 express.raw({ type: 'application/json' })。
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> None:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
ts, sig = parts.get("t"), parts.get("v1")
if not ts or not sig:
raise ValueError("malformed signature header")
if abs(time.time() - int(ts)) > tolerance:
raise ValueError("signature expired")
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, sig):
raise ValueError("signature mismatch")
这不是一段供你复制的代码片段——它正是网关自己导入、用来给你的投递签名的那个包,公开发布出来,让你可以运行同一份代码来验证签名。
go get github.com/paygatehq/paygate-go
import "github.com/paygatehq/paygate-go/webhook"
// body 必须是原始字节,并在任何 JSON 解码之前读取。
err := webhook.Verify(secret, r.Header.Get(webhook.SignatureHeader), body,
time.Now(), 5*time.Minute)
webhook.KnownEventTypes 是我们可能发送的全部事件类型的封闭列表,所以基于它编写的 switch 不会悄悄漏掉某一类事件。
每个事件都有一个稳定的 id。请据此去重。
不幂等的处理程序,会在你的 200 第一次在回传途中丢失时给账户重复入账——这不是假设,只要你的进程在响应到一半时重启,就会发生这种情况。
最小可行的处理程序:
BEGIN
INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING
IF inserted = 0: COMMIT and return 200 -- 已处理过
...do the work...
COMMIT
return 200
没有得到 2xx 响应的投递会以逐步拉长的间隔重试大约两天,之后被标记为失败。查看具体发生了什么:
curl "$PAYGATE/v1/webhook_endpoints/{id}/deliveries" -H "Authorization: Bearer $KEY"
修复问题之后,任何事件都可以手动重放:
curl -X POST $PAYGATE/v1/events/{id}/replay \
-H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" -d '{}'
200。异步处理实际工作。一个要花八秒的处理程序,会把一场重试风暴变成一次由你自己造成的服务中断。200。新的事件类型会陆续加入;对未知事件返回 400,会让你的端点陷入永久重试。webhook.test 不是付款事件。它是控制台测试按钮发送的事件,并被刻意放在 payment.* 命名空间之外,这样谁的生产环境 switch 语句都不会据此执行操作。正式模式下会拒绝 localhost 网址——分发器不会连接私有地址,因为 Webhook 网址本身就是一个 SSRF 攻击面,指向商家输入的任意地址。在本地部署的测试模式下则允许使用。对于真实主机,请使用隧道。