Webhook

三种语言的签名验证、投递保证,以及处理程序约定。

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 签名并拒绝过旧的时间戳,就把这个窗口限制在了几分钟之内。

请使用五分钟的容差,并以常量时间进行比较。

Node

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' })

Python

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

这不是一段供你复制的代码片段——它正是网关自己导入、用来给你的投递签名的那个包,公开发布出来,让你可以运行同一份代码来验证签名。

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 '{}'

处理程序约定

本地开发

正式模式下会拒绝 localhost 网址——分发器不会连接私有地址,因为 Webhook 网址本身就是一个 SSRF 攻击面,指向商家输入的任意地址。在本地部署的测试模式下则允许使用。对于真实主机,请使用隧道。