Webhooks

支付完成、退款成功后,35pay 会向你配置的地址发送 POST 请求。建议作为业务状态变更的 权威信号源 —— 不要轮询 getSession

配置

进入 控制台 → Webhooks 添加一个 endpoint,填入你的回调 URL (必须是 HTTPS、能从公网访问),选择要订阅的事件。创建后会得到一个Signing Secret,用来验证请求来源。

本地开发可用 ngrok http 3000cloudflared 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.statuspaid 翻成 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 验证(推荐):

app/api/webhooks/35pay/route.ts
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.sessionIddata.metadata.orderId 作唯一键去重
  • 给业务表加 UNIQUE 约束、捕获冲突而不是先 SELECT 再 INSERT
  • 状态机里只允许 pending → paid,已 paid 的请求直接 200 返回不再处理

最佳实践

  • 先 200,再处理 —— handler 立即返回 2xx,业务逻辑入队异步处理。慢响应会拖慢整个事件流
  • 记录原始 payload —— 验签后在数据库存一份原始 JSON,方便日后排查
  • 不信任 amount —— 始终以 webhook 携带的 amount 为准,而不是从订单系统反查
  • 用 metadata 关联订单 —— 创建 session 时 metadata.orderId 是对账的关键