Webhook 规范
Coinepay 在异步事件完成时(开卡成功、充值成功、销卡完成等),主动向你配置的 URL 推送 HTTP POST。客户必须验签 + 在超时窗口内返回 2xx。
客户收到的 HTTP 请求
POST https://customer-server.example/your-webhook-path HTTP/1.1
Content-Type: application/json
Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
Webhook-Timestamp: 1714377612
Webhook-Signature: v1,9b8e7f6d5c4b3a2918273645d4e3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b
Webhook-Type: card.opened
User-Agent: Coinepay-Webhook/1.0
<JSON body>验签算法
signInput = WebhookTimestamp + "." + raw_body_bytes
expected = "v1," + lowercase_hex( HMAC_SHA256(webhook_secret_bytes, signInput) )
verify_ok = constant_time_compare(Webhook-Signature, expected)必须用 raw body 字节
不能 JSON.parse 后 re-stringify —— 可能丢空格 / 改键序,导致签名失败。务必用框架提供的"原始 body"(Express 的 bodyParser.raw、Go 的 io.ReadAll(r.Body) 等)。
webhook_secret 与 API secret 不同
- API
SECRET—— 用于客户端 → 服务器签名 webhook_secret—— 用于服务器 → 客户端 webhook 签名
调用 set_webhook 设置 URL 时返回的 webhook_secret 是另外一份。
时间戳防重放
abs(server_now_unix - WebhookTimestamp) <= 300 # ±5 分钟超出窗口的请求请视为可疑并拒绝。
客户端必须做到
| 项 | 要求 |
|---|---|
| 响应状态 | 必须 2xx,否则触发重试 |
| 响应头超时 | 5 秒 —— TLS 握手完成后 5 秒内必须把响应状态行 + 头部发给服务端 |
| 整请求总超时 | 10 秒 —— 含响应体在内整请求 10 秒内必须完成;响应体慢吞吐也算失败 |
| 幂等 | 同一 Webhook-Id 可能被多次投递(重试导致),客户端需根据 Webhook-Id 去重 |
实操建议
把重活丢到后台队列,立即返回 200。webhook 入口端到端目标 < 200 ms,给网络抖动留缓冲。
幂等性
即使你 200 OK 了,因网络丢包,Coinepay 可能没收到 ack 而重试。务必基于 Webhook-Id 去重,避免重复处理。
事件类型
| event_type | 触发时机 | payload 概要 | 卡类型覆盖 |
|---|---|---|---|
webhook.test | 控制台点测试 / set_webhook 后异步触发一次 | {message, sent_at, acknowledge_to_complete_verification} | 全部 |
card.opened | 虚拟卡开卡成功 | {card_id, card_type, status: "opened", opened_at, last_four?} | virtual_l/p/v/r/g |
card.open_failed | 开卡失败 | {card_id, card_type, status: "open_failed", fail_reason, failed_at} | virtual_l/p/v/r/g |
card.recharged | 充值成功 | {transaction_id, card_id, status: 2, amount, currency, completed_at} | 全部 |
card.recharge_failed | 充值失败 | {transaction_id, card_id, status: 3, amount, currency, fail_reason, failed_at} | 全部 |
card.closed | 销卡完成 | {card_id, card_type, status: "closed", closed_at, last_four?} | virtual_l/p/v/r/g |
card.status_changed | 其他状态变更(手动冻结/解冻、风控临时冻结等) | {card_id, card_type, from_status, to_status, changed_at, last_four?} | virtual_l/p/v/r/g |
v1.2 状态码约定
- 开卡 / 销卡的
status是字符串:"opened"/"open_failed"/"closed" - 充值的
status是数字:2= 成功 /3= 失败 card_type全小写:virtual_v而非VIRTUAL_V
完整 payload 示例
webhook.test
{
"message": "This is a test event from Coinepay OpenAPI",
"sent_at": "2026-04-29T11:00:00Z",
"acknowledge_to_complete_verification": true
}card.opened
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "opened",
"opened_at": "2026-04-29T11:00:12Z",
"last_four": "4242"
}card.open_failed
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "open_failed",
"fail_reason": "kyc rejected",
"failed_at": "2026-04-29T11:00:12Z"
}card.recharged
{
"transaction_id": "txn_RO20260429120000xyz",
"card_id": "card_12345",
"status": 2,
"amount": "100.00",
"currency": "USD",
"completed_at": "2026-04-29T12:00:00Z"
}card.recharge_failed
{
"transaction_id": "txn_RO20260429120000xyz",
"card_id": "card_12345",
"status": 3,
"amount": "100.00",
"currency": "USD",
"fail_reason": "provider declined",
"failed_at": "2026-04-29T12:00:01Z"
}card.closed
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "closed",
"closed_at": "2026-04-29T13:00:00Z",
"last_four": "4242"
}card.status_changed
卡片在非终态之间的状态切换 —— 通常是用户手动冻结/解冻或风控临时冻结。card.opened / card.closed / card.open_failed 这些专门事件覆盖的终态变更不走此事件。
{
"card_id": "card_12345",
"card_type": "virtual_v",
"from_status": "active",
"to_status": "frozen",
"changed_at": "2026-04-29T14:30:00Z",
"last_four": "4242"
}from_status / to_status 是小写字符串(active / frozen / pending / closing)。对于销卡 (closed) / 开卡失败 (failed) 等终态切换,优先使用更具体的 card.closed / card.open_failed 事件。
重试退避
| 第 N 次失败 | 下次投递间隔 |
|---|---|
| 1 | 1 分钟 |
| 2 | 5 分钟 |
| 3 | 15 分钟 |
| 4 | 1 小时 |
| 5 | 6 小时 |
| 6 | 24 小时 |
| 7 | dead_letter —— 不再重试 |
总投递次数
每个事件共 7 次投递机会(1 次首投 + 6 次重试)。第 7 次失败后进入 dead_letter,不再重试。
死信状态可通过 Webhook 事件历史接口 查询。
fail_reason 服务端脱敏
card.open_failed / card.recharge_failed payload 里的 fail_reason 是经过脱敏的简短可读描述。服务端会剥除:
- Provider URL / IPv4 地址 / 邮箱 / 域名
- Go 传输层模板(如
Post、dial tcp ...) - HTML 与特殊字符
- 截断到 120 字符
| 失败模式 | fail_reason 内容 |
|---|---|
| 同步失败(API 调用立即拒绝) | ""(空字符串)—— 客户端走通用文案 |
| 异步失败(已入队但 provider 返回错误) | 清洗后的简短描述,如 "Insufficient funds"、"Card blocked" —— 不会含 provider 域名 / IP / 内部 trace id / 完整堆栈 |
想看原始 provider 错误?
调 /api/v1/openapi/webhook_events/list 看 last_error(运营级别的诊断信息)。完整 provider 响应在服务端保留;如需深度排查请联系运营。
URL 配置约束
验证状态生命周期
每次调用 set_webhook 都会轮换 webhook_secret 并清空已验证标志。当下一条出站事件(通常是自动派发的 webhook.test)成功收到 2xx 响应后,标志被重新置上,表示"新地址可达"。可通过 get_webhook 读取该标志,用于发现"已配置但从未收到投递"的异常情况。
调用 set_webhook 时:
| 约束 | 说明 |
|---|---|
| 协议 | 生产环境强制 HTTPS(dev 环境 AllowHTTP=true 时方可放行 HTTP) |
| 端口 | 生产环境仅允许 443,其他端口在投递阶段被拒 |
| IP | 必须公网 IP,拒绝 127.0.0.0/8 / 10/8 / 192.168/16 / 172.16-31/12 / 169.254/16 等私网与 link-local |
| 域名 | 解析必须落到允许的 IP 范围 |
| URL 长度 | 推荐 < 1024 字符 |
SSRF 防护
拒绝内网 / cloud-metadata(169.254.169.254)/ loopback 是强制约束,不可绕过。如果你的接收器在内网,请通过反向代理暴露公网 HTTPS。
验签代码示例
import hmac, hashlib
def verify_webhook(sig_header, ts_header, raw_body, webhook_secret):
if not sig_header.startswith("v1,"):
return False
sig = sig_header[3:]
expected = hmac.new(
webhook_secret.encode(),
f"{ts_header}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, expected)import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/webhook',
express.raw({ type: 'application/json' }), // 关键:raw body
(req, res) => {
const sigHeader = req.header('Webhook-Signature') || ''
const tsHeader = req.header('Webhook-Timestamp') || ''
if (!sigHeader.startsWith('v1,')) return res.sendStatus(401)
const sig = sigHeader.slice(3)
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${tsHeader}.`).update(req.body)
.digest('hex')
const ok =
Buffer.byteLength(sig) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
if (!ok) return res.sendStatus(401)
// 处理 ...
res.sendStatus(200)
})func verifyWebhook(sigHeader, tsHeader string, rawBody []byte, webhookSecret string) bool {
const prefix = "v1,"
if !strings.HasPrefix(sigHeader, prefix) {
return false
}
sig := sigHeader[len(prefix):]
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(tsHeader))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}function verifyWebhook(string $sigHeader, string $tsHeader, string $rawBody, string $webhookSecret): bool {
if (strpos($sigHeader, 'v1,') !== 0) return false;
$sig = substr($sigHeader, 3);
$expected = hash_hmac('sha256', $tsHeader . '.' . $rawBody, $webhookSecret);
return hash_equals($sig, $expected);
}常见集成陷阱
| 陷阱 | 解决 |
|---|---|
| 用 JSON parse 后的对象计算签名 | 改用 raw body 字节 |
把 webhook_secret 与 API SECRET 混淆 | 它们是两个独立 secret |
用 == 比较签名 | 改用恒定时间比较,防 timing attack |
| 超时未返回 2xx | 把"重活"丢到队列,立即返回 200。整请求 10 s 内必须完成(响应头超时 5 s) |
没有 Webhook-Id 去重 | 加幂等表,重复 Webhook-Id 直接 200 不再处理 |