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 等價條款釋出