Webhook 通知
付款到帳、支付單狀態變化時,kokiwa 向你登記的 https 地址發送 POST 請求。請以 Webhook 作為入帳依據。
請求格式
請求體是 JSON,請求頭包含:
Kokiwa-Signature | 簽名,格式 t=時間戳,v1=簽名,校驗方法見下文。 |
Kokiwa-Event-Id | 事件 ID,與請求體的 id 相同,用於去重。 |
Kokiwa-Delivery-Attempt | 第幾次投遞,從 1 開始。 |
{
"id": "evt_3Fk9Qw2Lm7Rt5Xb1Nc8Vz4",
"type": "payment_intent.paid",
"created": 1793612345,
"data": {
"payment_intent": {
"id": "pi_7Gm4Rk2Wp9Ls1Qv8Bd3Zt6",
"object": "payment_intent",
"status": "paid",
"customer": "user_42",
"metadata": { "order_id": "10086" },
"expires_at": "2026-11-02T09:00:00Z",
"paid_at": "2026-11-02T08:41:07Z",
"paid_option": "pio_5Hq2Tz8rLw0aVb3Kc9XyN1",
"options": [
{ "id": "pio_5Hq2Tz8rLw0aVb3Kc9XyN1", "chain": "bsc", "token": "USDT",
"amount": "25000000000000000000", "received": "25000000000000000000" }
],
"deposits": [ { "id": "dep_9Kx2Pa6Wn3Ty8Lq5Rb1Ms4", "...": "與下面的 deposit 相同" } ]
},
"deposit": {
"id": "dep_9Kx2Pa6Wn3Ty8Lq5Rb1Ms4",
"object": "deposit",
"status": "settled",
"chain": "bsc",
"channel": "router",
"customer": "user_42",
"payment_intent": "pi_7Gm4Rk2Wp9Ls1Qv8Bd3Zt6",
"token": { "symbol": "USDT", "address": "0x55d3…7955", "decimals": 18 },
"amount": "25000000000000000000",
"fee": "325000000000000000",
"net": "24675000000000000000",
"usd_value": "25.00",
"tx_hash": "0x4e7a…91c3",
"confirmed_at": "2026-11-02T08:41:07Z",
"transaction": {
"hash": "0x4e7a…91c3",
"log_index": 212,
"block_number": 44718235,
"block_time": "2026-11-02T08:40:58Z",
"from": "0x1f9c…a2b0",
"to": "0x7d3e…09f1"
},
"settlement": {
"tx_hash": "0x4e7a…91c3",
"by": "payment",
"settled_at": "2026-11-02T08:40:58Z"
}
}
}
}amount 是用戶支付的金額,fee 是手續費,net 是到你錢包的金額,都以代幣最小單位表示。
鏈上交易
每筆入帳都附帶原始的鏈上交易,可以拿到區塊瀏覽器(如 BscScan)自行核對,不必只相信 kokiwa 的記錄。
| 字段 | 說明 |
|---|---|
transaction.hash | 用戶付款的交易哈希(與 tx_hash 相同)。 |
transaction.log_index | 這筆轉帳在交易裡的日誌序號。同一筆交易裡有多筆轉帳時,用 hash + log_index 唯一確定一筆。 |
transaction.block_number、block_time | 交易所在的區塊和出塊時間。kokiwa 只計入已最終確認的區塊。 |
transaction.from | 付款方地址。從交易所提幣時,這是交易所的出金地址,不一定是用戶本人的錢包。 |
transaction.to | 收款方地址:錢包付款時是你的收款錢包,轉帳付款時是充值地址。 |
settlement | 款項到你收款錢包的那筆交易,未分帳時為 null。by 為 payment(錢包付款,付款交易本身就完成了分帳,tx_hash 與付款交易相同)、kokiwa(kokiwa 歸集)或 owner(你用自己的錢包取回,不收手續費)。多筆小額入帳一起歸集時,它們的 settlement.tx_hash 相同。 |
事件類型
| type | 什麼時候發送 | data |
|---|---|---|
payment_intent.paid | 支付單已全額收到 | payment_intent、deposit |
payment_intent.partially_paid | 支付單收到了一部分 | payment_intent、deposit |
payment_intent.expired | 支付單過期未付 | payment_intent |
payment_intent.paid_late | 過期後才收到款項 | payment_intent、deposit |
deposit.confirmed | 任何一筆入帳在鏈上最終確認(包括支付單和充值地址) | deposit |
deposit.settled | 入帳已完成分帳,錢已到你的收款錢包 | deposit |
webhook.test | 你在後台或通過 API 發送的測試事件 | — |
- 錢包直接付款(
channel為router)在付款的同一筆交易裡完成分帳,會同時收到deposit.confirmed和deposit.settled。 - 轉帳到充值地址(
channel為deposit)先收到deposit.confirmed,歸集到你的錢包後再收到deposit.settled。收到 confirmed 就可以給用戶入帳,款項已在鏈上確認,只是還在歸集途中。 - 用
deposit.customer找到你的用戶,用deposit.payment_intent找到對應的支付單(充值地址的入帳為null)。
校驗簽名
每個 Webhook 地址有自己的簽名密鑰(whsec_…),只在建立時顯示一次。校驗步驟:
- 從
Kokiwa-Signature取出t和v1。 - 用密鑰對字符串
{t}.{原始請求體}計算 HMAC-SHA256,轉成小寫十六進制,與v1做常數時間比較。 - 拒絕
t與當前時間相差超過 5 分鐘的請求,防止重放。
一定要用原始請求體計算,不要先解析成 JSON 再序列化,否則字節不同,簽名不會匹配。
Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.KOKIWA_WEBHOOK_SECRET;
app.post("/kokiwa/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Kokiwa-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const match = parts.v1?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!fresh || !match) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// 按 event.id 去重後處理,然後盡快返回 2xx
res.sendStatus(200);
});Go
func verify(secret, body []byte, header string) bool {
var t, v1 string
for _, p := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
switch k {
case "t":
t = v
case "v1":
v1 = v
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(t + "."))
mac.Write(body)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}Python
import hashlib, hmac, time
def verify(secret: bytes, body: bytes, header: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)響應與重試
- 返回任何 2xx 表示收到。請在 10 秒內返回,耗時的處理放到後台隊列裡做。
- 非 2xx、超時或連接失敗都會重試,間隔逐步拉長:30 秒、2 分鐘、10 分鐘、30 分鐘、1 小時、2 小時、4 小時、8 小時、12 小時、24 小時、20 小時,約 3 天後停止。
- 同一事件可能送達不止一次,事件之間也不保證順序。請按事件
id去重;需要最新狀態時,以GET /v1/payment_intents/{id}的結果為準。 - 在後台「回調通知」頁可以看到每次投遞的狀態碼和響應內容,失敗的投遞可以手動「重新投遞」。
Telegram 通知
除了 Webhook,你還可以在後台「設置」頁綁定 Telegram 機器人 @KoKiWaPayBot,每筆入帳確認時收到一條消息。它只用於提醒,不能代替 Webhook 入帳。