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) )
必须遵守的规则:
- 对原始请求字节验签。 绝不要先解析 JSON 再重新序列化 —— key 顺序 和空白字符的差异会让签名失效。按字节读取 body,对这些字节做哈希。
- 校验重放窗口。
|now - t| > 300 秒时拒绝。 - 用
Clotho-Delivery-Id去重。 重试会以同一个 delivery id 重发 完全相同的 body 字节(t/v1是新的)。每个 delivery id 最多处理 一次。 - HMAC 比较要用常量时间比较函数。
- 尽快返回
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.confirmed、payment.reverted、order.paid、
order.partially_paid、order.expired。订阅列表为空的端点会收到
全部事件。
payment.reverted 在一笔已确认的入金被链重组回滚时触发 ——
应视为对同一 payment_id 的 payment.confirmed 的撤回。如果该交易
之后重新上链,会再发一条新的 payment.confirmed。