Webhook
付款入账或支付单状态变化时,kokiwa 向你的 https 地址发送 POST 请求。在商户后台的“回调通知”页管理接收地址。
想直接复制可用的代码? Node.js、Python、PHP、Go、Java 接入示例
请求格式
{
"id": "evt_3Fk9Qw2Lm7Rt5Xb1Nc8Vz4",
"type": "payment_intent.paid",
"created_at": "2026-11-02T08:41:07Z",
"livemode": true,
"data": {
"payment_intent": { "id": "pi_7Gm4Rk2Wp9Ls1Qv8Bd3Zt6", "object": "payment_intent", "status": "paid", "...": "..." },
"deposit": { "id": "dep_9Kx2Pa6Wn3Ty8Lq5Rb1Ms4", "object": "deposit", "status": "settled", "...": "..." }
}
}| 请求头 | 说明 |
|---|---|
Kokiwa-Signature | 签名,格式 t=时间戳,v1=签名。 |
Kokiwa-Event-Id | 事件 ID,与请求体的 id 相同。 |
Kokiwa-Delivery-Attempt | 第几次投递,从 1 开始。 |
data.deposit 是入账对象。data.payment_intent 与查询支付单返回的对象相同,只是不含 checkout_url 和 livemode;livemode 在事件最外层。
事件类型
| 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 | 你在后台发送的测试事件 | message |
钱包付款在付款交易里完成结算,deposit.confirmed 和 deposit.settled 同时发送。转账到地址先发 deposit.confirmed,归集后才发 deposit.settled;收到 confirmed 就可以给用户入账,不用等 settled。
校验签名
每个接收地址有自己的签名密钥(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);
// deduplicate by event.id, process, then return 2xx quickly
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)响应与重试
- 在 10 秒内返回任意 2xx,耗时的处理放到后台队列。
- 非 2xx、超时或连接失败会重试,共 11 次,间隔为 30 秒、2 分钟、10 分钟、30 分钟、1、2、4、8、12、24、20 小时,约 3 天后停止。
- 同一事件可能送达多次,事件之间不保证顺序。按事件
id去重;需要最新状态时调用GET /v1/payment_intents/{id}。 - 后台“回调通知”页可以查看每次投递的结果,并手动重新投递。
- 重试全部失败后,用
GET /v1/deposits补齐漏掉的入账。
Telegram 通知
在后台“设置”页绑定 @KoKiWaPayBot,每笔入账都会收到消息。它只用于提醒,不能代替 Webhook。