Skip to content

Webhook 规范

Coinepay 在异步事件完成时(开卡成功、充值成功、销卡完成等),主动向你配置的 URL 推送 HTTP POST。客户必须验签 + 在超时窗口内返回 2xx

客户收到的 HTTP 请求

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>

验签算法

text
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.parsere-stringify —— 可能丢空格 / 改键序,导致签名失败。务必用框架提供的"原始 body"(Express 的 bodyParser.raw、Go 的 io.ReadAll(r.Body) 等)。

webhook_secret 与 API secret 不同

  • API SECRET —— 用于客户端 → 服务器签名
  • webhook_secret —— 用于服务器 → 客户端 webhook 签名

调用 set_webhook 设置 URL 时返回的 webhook_secret 是另外一份。

时间戳防重放

text
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

json
{
  "message": "This is a test event from Coinepay OpenAPI",
  "sent_at": "2026-04-29T11:00:00Z",
  "acknowledge_to_complete_verification": true
}

card.opened

json
{
  "card_id": "card_12345",
  "card_type": "virtual_v",
  "status": "opened",
  "opened_at": "2026-04-29T11:00:12Z",
  "last_four": "4242"
}

card.open_failed

json
{
  "card_id": "card_12345",
  "card_type": "virtual_v",
  "status": "open_failed",
  "fail_reason": "kyc rejected",
  "failed_at": "2026-04-29T11:00:12Z"
}

card.recharged

json
{
  "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

json
{
  "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

json
{
  "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 这些专门事件覆盖的终态变更走此事件。

json
{
  "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 次失败下次投递间隔
11 分钟
25 分钟
315 分钟
41 小时
56 小时
624 小时
7dead_letter —— 不再重试

总投递次数

每个事件共 7 次投递机会(1 次首投 + 6 次重试)。第 7 次失败后进入 dead_letter,不再重试。

死信状态可通过 Webhook 事件历史接口 查询。

fail_reason 服务端脱敏

card.open_failed / card.recharge_failed payload 里的 fail_reason经过脱敏的简短可读描述。服务端会剥除:

  • Provider URL / IPv4 地址 / 邮箱 / 域名
  • Go 传输层模板(如 Postdial tcp ...
  • HTML 与特殊字符
  • 截断到 120 字符
失败模式fail_reason 内容
同步失败(API 调用立即拒绝)""(空字符串)—— 客户端走通用文案
异步失败(已入队但 provider 返回错误)清洗后的简短描述,如 "Insufficient funds""Card blocked" —— 不会含 provider 域名 / IP / 内部 trace id / 完整堆栈

想看原始 provider 错误?

/api/v1/openapi/webhook_events/listlast_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。

验签代码示例

python
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)
js
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)
  })
go
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))
}
php
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 不再处理

采用 MIT 等价条款发布