Clotho Webhooks — 签名验证

每次 webhook POST 都带两个请求头:

X-Clotho-Signature: t=<unix_seconds>,v1=<hex_hmac>,id=<delivery_uuid>
Clotho-Delivery-Id: <delivery_uuid>

签名方案(v1):

signed_payload = "<t>.<delivery_id>.<sha256_hex(raw_body)>"
v1             = hex( HMAC-SHA256(endpoint_secret, signed_payload) )

必须遵守的规则:

  1. 对原始请求字节验签。 绝不要先解析 JSON 再重新序列化 —— key 顺序 和空白字符的差异会让签名失效。按字节读取 body,对这些字节做哈希。
  2. 校验重放窗口。 |now - t| > 300 秒 时拒绝。
  3. Clotho-Delivery-Id 去重。 重试会以同一个 delivery id 重发 完全相同的 body 字节(t/v1 是新的)。每个 delivery id 最多处理 一次。
  4. HMAC 比较要用常量时间比较函数。
  5. 尽快返回 2xx(重活异步做)。其他响应或超时都会触发重试: 16 次,跨度约 3 天,之后进入死信,可在控制台手动重放。

端点密钥(whsec_...)创建时展示一次,之后可在控制台重新查看 (Webhooks → 端点行 → 查看)。

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "strconv"
    "strings"
    "time"
)

func handler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))

    var ts, v1, id string
    for _, p := range strings.Split(r.Header.Get("X-Clotho-Signature"), ",") {
        k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
        switch k {
        case "t":  ts = v
        case "v1": v1 = v
        case "id": id = v
        }
    }
    tsUnix, err := strconv.ParseInt(ts, 10, 64)
    if err != nil || time.Since(time.Unix(tsUnix, 0)).Abs() > 5*time.Minute {
        http.Error(w, "stale", http.StatusUnauthorized)
        return
    }
    bodyHash := sha256.Sum256(body)
    payload := fmt.Sprintf("%s.%s.%s", ts, id, hex.EncodeToString(bodyHash[:]))
    mac := hmac.New(sha256.New, []byte(secret)) // 你的 whsec_... 密钥
    mac.Write([]byte(payload))
    if subtle.ConstantTimeCompare([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1)) != 1 {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    // 先用 r.Header.Get("Clotho-Delivery-Id") 去重,再处理 body
    w.WriteHeader(http.StatusOK)
}

Node(Express)

const crypto = require('node:crypto')

// 重要:必须拿到原始 body 字节 — app.use(express.raw({type: '*/*'}))
app.post('/hooks', express.raw({ type: '*/*' }), (req, res) => {
  const parts = Object.fromEntries(
    (req.get('x-clotho-signature') ?? '').split(',').map((p) => p.trim().split('=')),
  )
  const { t, v1, id } = parts
  if (!t || !v1 || !id) return res.status(401).end()
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(401).end()

  const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex')
  const expected = crypto
    .createHmac('sha256', process.env.CLOTHO_WEBHOOK_SECRET)
    .update(`${t}.${id}.${bodyHash}`)
    .digest('hex')
  const a = Buffer.from(expected); const b = Buffer.from(v1)
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).end()

  // 先用 req.get('clotho-delivery-id') 去重,再 JSON.parse(req.body)
  res.status(200).end()
})

Python(Flask)

import hashlib, hmac, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = b"whsec_..."  # 来自控制台

@app.post("/hooks")
def hooks():
    raw = request.get_data()  # 原始字节,先于任何 JSON 解析
    parts = dict(p.strip().split("=", 1)
                 for p in request.headers.get("X-Clotho-Signature", "").split(","))
    t, v1, did = parts.get("t"), parts.get("v1"), parts.get("id")
    if not (t and v1 and did):
        abort(401)
    if abs(time.time() - int(t)) > 300:
        abort(401)
    body_hash = hashlib.sha256(raw).hexdigest()
    expected = hmac.new(SECRET, f"{t}.{did}.{body_hash}".encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        abort(401)
    # 先用 request.headers["Clotho-Delivery-Id"] 去重,再解析 raw
    return "", 200

事件信封

{
  "id": "<outbox 事件 uuid>",
  "type": "order.paid",
  "version": 1,
  "created_at": "2026-06-10T08:00:00Z",
  "data": { "...事件字段,含订单 metadata 原样回传..." }
}

事件类型:payment.confirmedpayment.revertedorder.paidorder.partially_paidorder.expired。订阅列表为空的端点会收到 全部事件。

payment.reverted 在一笔已确认的入金被链重组回滚时触发 —— 应视为对同一 payment_idpayment.confirmed 的撤回。如果该交易 之后重新上链,会再发一条新的 payment.confirmed