kokiwa

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_…),只在建立時顯示一次。校驗步驟:

  1. 從 Kokiwa-Signature 取出 t 和 v1。
  2. 用密鑰對字符串 {t}.{原始請求體} 計算 HMAC-SHA256,轉成小寫十六進制,與 v1 做常數時間比較。
  3. 拒絕 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 入帳。