Webhook 事件
20 条事件。事件体只带 ID 与状态—— 不含金额、不含卡号任何片段、不含风控原因、不含上游名称。要详情请拿 ID 回查。
一条事件长什么样
我方向你配置的端点发 POST,Content-Type: application/json,超时 10 秒。
请求头四个:
Content-Type: application/json
z-signature: t=<unix秒>,v1=<base64>
z-event-id: evt_2f1c8a9b4d7e4c1fa0b3e5d6c7a8b901
z-event-type: deposit.credited
请求体的形状对所有事件都一样,只有 data 里的值不同:
{
"event_id": "evt_2f1c8a9b4d7e4c1fa0b3e5d6c7a8b901",
"event_type": "deposit.credited",
"created_at": "2026-08-12T09:30:00Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "deposit",
"id": "184203",
"external_member_id": "u_88123",
"status": "credited",
"previous_status": "",
"status_version": 0
}
}
红线:事件体只带 ID 与状态。
没有金额、没有资产代码、没有卡号的任何片段、没有风控原因、没有上游名称。
这不是省字节 —— webhook 端点是你的服务,我方没有办法保证它的传输与存储;
而 GET /v1/<资源>/{id} 那条路径上有 API Key、scope、代理会员三层校验。
要详情就拿 data.id 回查,别指望从事件体里读出金额来记账。
验签
v1 = HMAC-SHA256(你的 webhook_secret, "<t>." + 原始请求体字节),base64 输出。
签的是原始字节,不是你反序列化再序列化一遍的 JSON —— 后者会因为键序或
空白差异算出另一个值,而那种失败看起来像「我方签错了」。
校验 t 落在 ±300 秒内,否则一份被截获的旧请求可以被无限重放。
幂等与向前合并
我方保证至少一次,不保证恰好一次。同一次状态变更重复到达是常态
(资金线的 webhook 与巡检共用同一段落地代码,这是刻意的)。
1. 按 event_id 幂等。重投(你在商户后台点的,或我方退避重试的)**沿用同一个
event_id** —— 换新 id 会让「重投」在你这边表现成「又发生了一次」,
对资金事件而言那正是一次错误的重复入账。
2. 按 status_version 向前合并,收到倒序的事件直接丢弃。少了它,一次慢响应
会把「已到账」打回「处理中」,而两边都不会报错。
status_version 不是所有事件都有(下面每条事件里注明了)。恒为 0 的那几条
没有版本判据,只能按 created_at 比 —— 别写一个「version 相等就跳过」的合并器,
那会让这几条事件只有第一条生效。
previous_status 目前恒为空串:生产者一条都没有传它。别拿它做状态机的
前置判断,判断请用你自己库里的当前值。
重试与死信
首投失败后退避重试,等待 1 / 5 / 30 / 120 / 360 分钟,最多投递 6 次
(首投 + 5 次重投)。第 6 次仍失败转 dead 并在商户后台告警。
不会静默丢弃,也不会无限重试。
成功判据是任意 2xx,响应体我方不解析。所以先回 2xx 再异步处理,
别在 handler 里同步做重活 —— 超过 10 秒我方按失败处理并进入退避,
而你那边其实已经处理完了。
一个端点一行
扇出发生在入队那一刻:配了三个端点就落三行投递,各有自己的 attempts、
退避与 dead。所以某一个端点挂了不会连累另外两个,也不会出现「有一个通了
就整条标已送达」而另外两个永远收不到。
端点在事件产生之后被你删掉或停用了,那一行标 no_subscriber 而不是重试到
dead —— 死信面是要人处理的告警,「自己关掉的端点」不该出现在那里。
产生事件时一个订阅方都没有,同样落一行 no_subscriber:这样「事件产生了但
没人订阅」与「事件根本没产生」在商户后台上是两种不同的显示。
沙盒与生产
订阅按 env 隔离:沙盒端点收不到生产事件。业务产生的事件一律 livemode: true
—— 今天沙盒与 live 共用同一套账本,事件对应的是真实分录。
data.id 是内部原始 id,不带 REST 接口那层前缀
这是最容易踩的一个坑:GET /v1/remittances/... 返回的 id 是 rmt_<uuid>,
而事件里的 data.id 是裸的 <uuid>。回查时自己拼前缀(多数端点两种都认,
但别赌)。另有几条事件的 data.id 根本不是那张单的号,见下表最后一列。
回查一律要带 x-on-behalf-of,值就用事件里的 external_member_id。
data.object | data.id 是什么 | 拿它回查 |
|---|---|---|
member | 会员内部 id | GET /v1/members/mem_<id> |
kyc | 会员内部 id,不是 KYC 单号 | GET /v1/kyc |
deposit | 我方入金流水号(纯数字) | 没有单笔端点;GET /v1/deposits 列表里的 dep_<你上报的 reference> 是另一套标识,两者对不上 |
withdrawal | 会员自助链上提现单号 | 开放 API 上没有回查端点(见下) |
remittance | 汇款单号 | GET /v1/remittances/rmt_<id> |
qrpay_order | 扫码付单号 | GET /v1/qrpay/orders/qrp_<id> |
card_application | 会员内部 id,不是申请单号 | GET /v1/cards 列该会员的卡 |
card | 会员内部 id,不是卡号 | 同上 |
card_topup | 充值到卡单号 | 没有单笔端点;GET /v1/cards/crd_<卡 id> 看余额 |
earn_order | 定期结算时是定期订单号;活期派息时是会员内部 id | GET /v1/earn/orders(只覆盖定期那一半) |
exchange_order | 兑换单号 | GET /v1/exchange/orders 列表(那里的 id 是 exc_<id>) |
internal_transfer | 转账单号 | GET /v1/transfers/itr_<id> |
**withdrawal.order.* 与 POST /v1/withdrawals 不是同一门生意**
两者的 id 都长得像 wdr_,但落在两张表上:
withdrawal.order.*事件说的是会员在 App 里自助发起的链上提现,由我方审核、
我方出款;
POST /v1/withdrawals/.../confirm/.../fail是你自己执行链上出款时
的两段式扣账,全程由你驱动,因此不产生任何事件(你已经知道结果了)。
把事件里的 id 拿去 GET /v1/withdrawals/{id} 会得到 not_found。
平台自营会员不发事件,只有归属到某个商户的会员才会触发桥接。
订阅清单里有、但今天不会到达的两条
商户后台的订阅勾选框里还留着 card.application.rejected 与
card.application.submitted。它们当前没有生产者 —— 开卡失败与开卡补件今天
只发邮件,没有落会员通知,而 webhook 的唯一桥接点是会员通知。
所以本页不登记它们:登记了你会写一个 handler 然后一直等。
开卡结果请轮询 GET /v1/cards/applications/cap_<id>,或等
card.application.approved(成功那一侧是有生产者的)。
member.created
会员建档成功
何时发:POST /v1/members 真的新建了一行会员(返回 201)那一刻。
幂等命中(同一个 external_member_id 已存在,返回 200)不发 ——
否则你每次重试注册都会收到一条「新会员」。
{
"event_id": "evt_2f1c8a9b4d7e4c1fa0b3e5d6c7a8b901",
"event_type": "member.created",
"created_at": "2026-08-12T09:30:00Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "member",
"id": "4b7c1e02-9a3d-4f18-8c55-2d61ab0f9e77",
"external_member_id": "u_88123",
"status": "normal",
"previous_status": "",
"status_version": 0
}
}
member.suspended
商户级停用状态变化(停用与恢复共用这一条)
何时发:POST /v1/members/{id}/suspend 真的改动了 merchant_status 之后。
恢复也发这一条,带 status: normal —— 方向靠 status 区分。
给恢复另造一条事件会让「这个人现在能不能用」需要看两条事件流才答得出。
这一位只影响该会员在你这边的可用性,平台级封禁是另一套、你看不到。
{
"event_id": "evt_7c93a1d05e6b4a2f9d81c4e0f2a37b56",
"event_type": "member.suspended",
"created_at": "2026-08-12T10:02:11Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "member",
"id": "4b7c1e02-9a3d-4f18-8c55-2d61ab0f9e77",
"external_member_id": "u_88123",
"status": "suspended",
"previous_status": "",
"status_version": 0
}
}
kyc.result.updated
实名审核有结果,或被要求补充材料
何时发:两件事共用这一条:审核出结论(status: updated)、被要求补件
(status: supplement_required)。
⚠ 通过与驳回是同一个 status: updated —— 事件体里没有任何字段能区分,
这是「只带 ID 与状态」那条红线的直接后果。收到它必须回查
GET /v1/kyc(带 x-on-behalf-of)才知道结论;照 status 直接放行是错的。
⚠ data.id 是会员内部 id,不是 KYC 单号。
L1 与 L2 也不分事件,回查时看返回里的层级。
{
"event_id": "evt_1a55e9c73b0d47f2ab6c8d19e4f05237",
"event_type": "kyc.result.updated",
"created_at": "2026-08-12T11:15:40Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "kyc",
"id": "4b7c1e02-9a3d-4f18-8c55-2d61ab0f9e77",
"external_member_id": "u_88123",
"status": "updated",
"previous_status": "",
"status_version": 0
}
}
deposit.credited
链上入金已确认并记进会员可用余额
何时发:Cobo 确认到账、入账凭证过账成功之后(不是收到链上通知那一刻)。
收到它时余额已经能查到了。
⚠ 只覆盖链上入金。你用 POST /v1/deposits 上报的入金不发这条事件 ——
那条接口是同步的,201 返回时钱已经记上了,再发一条事件只是回声。
⚠ status_version 恒为 0(入金没有状态机,只有「已入账」这一态)。
⚠ data.id 是我方入金流水号(纯数字),与 GET /v1/deposits 列表里的
dep_<你的 reference> 不是同一套标识。
{
"event_id": "evt_9d2b6f4013ae4c78b5910c3e7d84a2f1",
"event_type": "deposit.credited",
"created_at": "2026-08-12T12:44:03Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "deposit",
"id": "184203",
"external_member_id": "u_88123",
"status": "credited",
"previous_status": "",
"status_version": 0
}
}
withdrawal.order.locked
会员自助链上提现已提交,资金已从可用余额扣走
何时发:会员在 App 里提交提现的那一刻 —— 钱这时已经不在可用余额里了
(进了独立的提现在途桶),还没有出款。
把它当成「余额已减少」的通知,不是「钱已经到对方地址」。
终局看 withdrawal.order.completed / .failed。
与 POST /v1/withdrawals 那条两段式扣账无关,见页首说明。
{
"event_id": "evt_5e81c0a7f39b4d26ae4713b8c0d5f9a2",
"event_type": "withdrawal.order.locked",
"created_at": "2026-08-12T13:05:22Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "withdrawal",
"id": "8f2a41d6-0c7b-4e39-9a15-63d0c8be7f24",
"external_member_id": "u_88123",
"status": "locked",
"previous_status": "",
"status_version": 1
}
}
withdrawal.order.completed
会员自助链上提现已出款完成
何时发:上游确认出款完成、结算凭证落账那一刻。这是终态。 中间态(审核通过、已广播上链)刻意不外发 —— 它们是我方内部流程的 可见性,多发只会让你以为有什么要处理。
{
"event_id": "evt_c47d2be9105f43a8b6e0972d15c8340a",
"event_type": "withdrawal.order.completed",
"created_at": "2026-08-12T13:41:57Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "withdrawal",
"id": "8f2a41d6-0c7b-4e39-9a15-63d0c8be7f24",
"external_member_id": "u_88123",
"status": "completed",
"previous_status": "",
"status_version": 4
}
}
withdrawal.order.failed
会员自助链上提现未完成,资金已全额退回可用余额
何时发:两种情形共用这一条:我方审核驳回,或上游出款失败。 两者对会员的下一步完全不同(前者要问客服,后者可以直接重发), 但事件体里区分不了 —— 要知道是哪一种得回查。 手续费在失败时全额退还,不会留在我方。
{
"event_id": "evt_0b6e3f9d82c74a15ae37d0561fb92c48",
"event_type": "withdrawal.order.failed",
"created_at": "2026-08-12T13:52:10Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "withdrawal",
"id": "8f2a41d6-0c7b-4e39-9a15-63d0c8be7f24",
"external_member_id": "u_88123",
"status": "failed",
"previous_status": "",
"status_version": 3
}
}
remittance.order.completed
汇款已到账收款人
何时发:上游回报该笔 payout 完成、我方结算凭证落账那一刻。这是终态。
{
"event_id": "evt_3ad9165e7b0c4f82a91d64c05e73b8f1",
"event_type": "remittance.order.completed",
"created_at": "2026-08-12T14:20:08Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "remittance",
"id": "1c0e8a37-45bd-4f6a-b2e1-90a7d3f5c862",
"external_member_id": "u_88123",
"status": "completed",
"previous_status": "",
"status_version": 6
}
}
remittance.order.failed
汇款未能完成,冻结资金已全额退回
何时发:上游拒付、合规拒绝、或建收款人失败等导致这一单走不下去。 钱已经回到会员可用余额了 —— 你的客服话术要说清这一点, 只说「失败」会立刻换来一通「那我的钱呢」。
{
"event_id": "evt_86f4c1097d2b4e35a8c0136be59d7f20",
"event_type": "remittance.order.failed",
"created_at": "2026-08-12T14:31:44Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "remittance",
"id": "1c0e8a37-45bd-4f6a-b2e1-90a7d3f5c862",
"external_member_id": "u_88123",
"status": "failed",
"previous_status": "",
"status_version": 5
}
}
remittance.order.refunded
汇款到账后被收款行退回,款项已退回会员余额
何时发:与 failed 刻意分开:这一单曾经发过 completed,你的用户看到过
「已到账」。合并成 failed 会让他以为之前那条是误报。
{
"event_id": "evt_e29a704c6b3d418f95720ad3c8f16b54",
"event_type": "remittance.order.refunded",
"created_at": "2026-08-13T09:02:31Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "remittance",
"id": "1c0e8a37-45bd-4f6a-b2e1-90a7d3f5c862",
"external_member_id": "u_88123",
"status": "refunded",
"previous_status": "",
"status_version": 8
}
}
remittance.order.action_required
球在会员脚下:要确认新价格,或要补充材料
何时发:两件事共用这一条:汇率变动超过会员设的上限需要重新确认,
或上游要求补充材料。
两件都有时限:重新确认逾期这一单自动取消并退回冻结资金;
材料不交这一单就停在那里,钱一直冻着。
收到它请立刻把会员引到这一单上,并回查
GET /v1/remittances/rmt_<id>/pending-action 看究竟要他做什么 ——
事件体里区分不了这两件事。
{
"event_id": "evt_74b0d3e18f6a4c29ba51e07d962f4c83",
"event_type": "remittance.order.action_required",
"created_at": "2026-08-12T14:12:19Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "remittance",
"id": "1c0e8a37-45bd-4f6a-b2e1-90a7d3f5c862",
"external_member_id": "u_88123",
"status": "action_required",
"previous_status": "",
"status_version": 3
}
}
qrpay.order.completed
扫码付成功,上游已向收单商户放行
何时发:上游确认该笔付款完成那一刻。这是终态。
{
"event_id": "evt_bd15c8073e4a49f2861b09df7ac35e10",
"event_type": "qrpay.order.completed",
"created_at": "2026-08-12T15:07:52Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "qrpay_order",
"id": "6d3f9b21-8c07-4a5e-91d4-27e0b6a3fc58",
"external_member_id": "u_88123",
"status": "completed",
"previous_status": "",
"status_version": 4
}
}
qrpay.order.failed
扫码付未成功,扣款已全额退回可用余额
何时发:上游拒付、走廊未开通、报价过期都会走到这里 —— 失败不是罕见路径。 没有这条事件时你唯一的知情方式是轮询,而轮询要先知道有单可查: 你等到的会是一条永远不来的 completed。
{
"event_id": "evt_af02e71d94b6435c8071cd23e6b95f4a",
"event_type": "qrpay.order.failed",
"created_at": "2026-08-12T15:09:03Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "qrpay_order",
"id": "6d3f9b21-8c07-4a5e-91d4-27e0b6a3fc58",
"external_member_id": "u_88123",
"status": "failed",
"previous_status": "",
"status_version": 3
}
}
qrpay.order.refunded
扫码付被退款,款项已退回会员可用余额
何时发:收单侧退款到达之后。它直接影响会员余额,所以与 failed 分开一条 —— 两者在你账上的含义不同(一个是这笔从没成过,一个是成了又退)。
{
"event_id": "evt_5c8b0a2f61d7452e93af6b04d18e37c9",
"event_type": "qrpay.order.refunded",
"created_at": "2026-08-13T10:21:35Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "qrpay_order",
"id": "6d3f9b21-8c07-4a5e-91d4-27e0b6a3fc58",
"external_member_id": "u_88123",
"status": "refunded",
"previous_status": "",
"status_version": 6
}
}
card.application.approved
开卡成功,卡片已可用
何时发:发卡机构开卡成功、卡片落库之后。
⚠ data.id 是会员内部 id,不是申请单号也不是卡 id。
要拿到卡请用 GET /v1/cards 带 x-on-behalf-of: <external_member_id>。
⚠ 失败那一侧(card.application.rejected)目前没有生产者,
开卡失败请轮询 GET /v1/cards/applications/cap_<id>。
{
"event_id": "evt_2e6c98a04f1b47d3850ac7e195b3d602",
"event_type": "card.application.approved",
"created_at": "2026-08-12T16:03:27Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "card_application",
"id": "4b7c1e02-9a3d-4f18-8c55-2d61ab0f9e77",
"external_member_id": "u_88123",
"status": "approved",
"previous_status": "",
"status_version": 0
}
}
card.status.updated
卡片状态发生变化(冻结 / 解冻 / 激活 / 销卡 / 制卡与物流推进)
何时发:上游回报的卡片状态与我方记录不一致、我方把它同步过来那一刻。
一条事件覆盖全部状态变化,具体变成了什么事件体里没有 —— 回查
GET /v1/cards/crd_<卡 id> 的 status。
⚠ data.id 是会员内部 id,不是卡 id,所以你得先 GET /v1/cards
列出这个人的卡再逐张比对。
⚠ 冻结与解冻各有一个过渡态(freezing / unfreezing),它们不是终态 ——
过渡态期间往卡里充值会失败。别看到一条事件就认定卡已经可用。
{
"event_id": "evt_ca730f5b8e214d6790ab3c1e57f4d028",
"event_type": "card.status.updated",
"created_at": "2026-08-12T16:40:11Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "card",
"id": "4b7c1e02-9a3d-4f18-8c55-2d61ab0f9e77",
"external_member_id": "u_88123",
"status": "updated",
"previous_status": "",
"status_version": 0
}
}
card.topup.credited
充值到卡的钱已经真的到卡上了
何时发:上游 webhook 确认到账那一刻,不是下单成功那一刻。
POST /v1/cards/{id}/topups 返回 201 只表示「下单收到了」,钱到卡是
第二段异步。限额与可用额度要等这条事件之后再放开 ——
提前放开等于限额已开而钱没到。
{
"event_id": "evt_71fd0c9e46a34b18b52d7f038ea6c145",
"event_type": "card.topup.credited",
"created_at": "2026-08-12T17:12:48Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "card_topup",
"id": "3a90c7e4-1f68-4b02-95dc-7e41b0a2d963",
"external_member_id": "u_88123",
"status": "credited",
"previous_status": "",
"status_version": 2
}
}
earn.order.settled
理财收益已入账(活期派息 / 定期到期结本息)
何时发:两件不同的事共用这一条:
· 活期派息——每期派息过账之后,data.id 是会员内部 id、
status_version 恒为 0(活期是余额包,不是一笔一单);
· 定期到期结算——本息结清之后,data.id 是定期订单号、带 status_version。
区分方法是拿 data.id 去 GET /v1/earn/orders 找:找得到就是定期,
找不到就是活期派息,这时用 GET /v1/earn/positions 看活期本金。
⚠ 定期起息不发事件 —— 申购那一刻发起方是你(或会员自己),没有新信息。
{
"event_id": "evt_9e04b7c2513f486da8021c6e5f7b3d90",
"event_type": "earn.order.settled",
"created_at": "2026-08-13T00:05:00Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "earn_order",
"id": "b52c8e17-3d94-40af-8617-c9e0d21a4f35",
"external_member_id": "u_88123",
"status": "settled",
"previous_status": "",
"status_version": 2
}
}
exchange.order.executed
站内兑换已成交
何时发:兑换是原子的、不可逆的、没有中间态 —— 只有成交这一条事件。
POST /v1/exchange/orders 是同步接口,返回 201 时已经成交,所以这条事件
主要用在「会员在 App 里自己换的」那一侧,你的服务端据它同步余额。
⚠ status_version 恒为 0(没有状态机可言)。
{
"event_id": "evt_4f8d21b60a9e47c5b370ed81c2a6594f",
"event_type": "exchange.order.executed",
"created_at": "2026-08-12T18:02:33Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "exchange_order",
"id": "7e13a5c8-62f0-4d91-ae35-0b8c4d7f2916",
"external_member_id": "u_88123",
"status": "executed",
"previous_status": "",
"status_version": 0
}
}
transfer.completed
会员收到一笔站内转账
何时发:转账成交那一刻(一步到账,没有待审态,成交即对方的可用余额)。
⚠ 只发给收款方。 data.external_member_id 是收款人的外部号,
发起方一条都不发 —— 他是自己按的确认键,流水里已经有了。
⚠ 跨商户转账是禁止的,所以收款人一定也是你的会员。
⚠ status_version 恒为 0。
{
"event_id": "evt_d6710b3fa825469c8e14f0d539b7a2c1",
"event_type": "transfer.completed",
"created_at": "2026-08-12T18:30:07Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "internal_transfer",
"id": "0af62d81-59b3-4c07-9e2a-816d3f0b5ca4",
"external_member_id": "u_88999",
"status": "completed",
"previous_status": "",
"status_version": 0
}
}