Webhooks
支付完成、退款成功后,35pay 会向你配置的地址发送 POST 请求。建议作为业务状态变更的 权威信号源 —— 不要轮询 getSession。
配置
进入 控制台 → Webhooks 添加一个 endpoint,填入你的回调 URL (必须是 HTTPS、能从公网访问),选择要订阅的事件。创建后会得到一个Signing Secret,用来验证请求来源。
本地开发可用
ngrok http 3000 或 cloudflared tunnel 暴露本地地址, 然后在 dashboard 用"测试发送"按钮触发一次 test.ping 验证连通性。事件清单
payment.paid
用户支付成功时触发。
{
"type": "payment.paid",
"ts": "2026-04-28T06:31:12.000Z",
"data": {
"sessionId": "sess_01HXYZ...",
"providerRef": "wx_xxx" ,
"provider": "wechat",
"amount": 9900,
"currency": "CNY",
"paidAt": "2026-04-28T06:31:12.000Z",
"metadata": { "orderId": "order_123" },
"mode": "live"
}
}payment.failed
provider 返回支付失败时触发(用户超时未支付走 expired,不会触发此事件)。
{
"type": "payment.failed",
"ts": "...",
"data": {
"sessionId": "sess_01HXYZ...",
"reason": "user_canceled"
}
}refund.succeeded
退款成功时触发,含全额 / 部分退款。 如果该笔被退完了(refundedAmount >= amount),后端会同步把对应session.status 从 paid 翻成 refunded。
{
"type": "refund.succeeded",
"ts": "...",
"data": {
"recordId": "rec_01HXYZ...",
"amount": 9900
}
}test.ping
在 dashboard 点击"测试发送"按钮时触发,仅用于调试。payload 会带 "test": true, 生产代码可以直接忽略。
签名验证
每个请求带两个头:
X-35pay-Timestamp
string
UNIX 秒级时间戳,例如
1714287072。X-35pay-Signature
string
形如
t=<ts>,v1=<hex>。其中 v1 是 HMAC-SHA256(secret, `${ts}.${rawBody}`) 的 hex。用 SDK 验证(推荐):
import { Pay } from '@35m/sdk'
export async function POST(req: Request) {
const sig = req.headers.get('x-35pay-signature')
const body = await req.text() // ⚠️ 必须拿原始字符串,不能 req.json()
if (!sig || !Pay.verifyWebhookSignature(body, sig, process.env.PAY_WEBHOOK_SECRET!)) {
return new Response('invalid signature', { status: 401 })
}
const event = JSON.parse(body) as {
type: 'payment.paid' | 'payment.failed' | 'refund.succeeded' | 'test.ping'
data: Record<string, unknown>
}
// 业务处理...
return new Response('ok')
}不用 SDK,自己实现(任何语言):
import crypto from 'node:crypto'
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
if (!parts.t || !parts.v1) return false
const ts = parseInt(parts.t, 10)
if (!Number.isFinite(ts)) return false
if (Math.abs(Date.now() / 1000 - ts) > 300) return false // 5min 容差
const expected = crypto.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`).digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}必须用 原始 body 字符串 计算签名。许多框架默认会 parse JSON, 读取后就拿不到原始字节,签名会对不上。Next.js App Router 用
await req.text(), Express 用 express.raw({ type: 'application/json' })。重试策略
endpoint 必须在 10 秒内 返回 2xx 状态码。任何超时、5xx、网络错误都视为失败。
事件入队后立即尝试一次。若失败,按指数退避自动重试 5 次(共 6 次尝试):
- 第 1 次重试:1 分钟后
- 第 2 次:5 分钟后
- 第 3 次:30 分钟后
- 第 4 次:2 小时后
- 第 5 次:12 小时后
重试由后台 worker 自动执行(Next 进程内每分钟扫一次到期任务)。 所有 6 次都失败后,事件保留在 dashboard 投递记录 中, 可以人工触发"立即重试"。
自部署 / 多实例环境也可以禁用 in-process worker,改用外部 cron 调
POST /api/cron/webhook-retry(带 Authorization: Bearer $CRON_SECRET)触发重试。幂等
因为重试和网络抖动,同一事件可能被投递多次。务必让 handler 幂等:
- 用
data.sessionId或data.metadata.orderId作唯一键去重 - 给业务表加
UNIQUE约束、捕获冲突而不是先 SELECT 再 INSERT - 状态机里只允许
pending → paid,已 paid 的请求直接 200 返回不再处理
最佳实践
- 先 200,再处理 —— handler 立即返回 2xx,业务逻辑入队异步处理。慢响应会拖慢整个事件流
- 记录原始 payload —— 验签后在数据库存一份原始 JSON,方便日后排查
- 不信任 amount —— 始终以 webhook 携带的
amount为准,而不是从订单系统反查 - 用 metadata 关联订单 —— 创建 session 时
metadata.orderId是对账的关键