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 不再處理 |