Webhooks
正在从旧版事件迁移?请参阅迁移指南。
Webhook 是 Liqfy 向您的后端实时推送状态更新的方式。请使用 Webhook 进行履约 —— 切勿仅依赖轮询。如果错过了某次投递,请使用拉取/对账 API 补齐。
#0. 从这里开始(最常见问题)
#一个 URL,还是每个事件一个 URL?
一个 URL。 在控制台(Integrations → Webhooks → New endpoint)中,您注册一个 HTTPS 端点并勾选多个事件。Liqfy 会针对每次发生单独发送一个 POST,且始终发送到同一个 URL。
| 其他一些 PSP | Liqfy |
|---|---|
/webhook/cashin、/webhook/cashout、/webhook/refund(每种类型一个路径) | https://your-shop.com/hooks/liqfy + 注册时的事件列表 |
| 每个事件一个 HTTP 路由 | 一个路由、多个事件;通过 type 字段(或旧版的 event)区分 |
推荐的通用 URL:
https://your-domain.com/hooks/liqfy除非您的路由器要求,否则请避免使用 /api/charge/created 这样的路径 —— 路径并不决定事件;决定事件的是控制台中的复选框(或 API 中的 events 数组)。
#“所有事件发送到同一个 URL”是什么样子?
您不会收到一个包含所有事件类型的载荷。每次状态变更您会收到一个 POST。请按类型分支处理:
const type = body.type || body.event; // canonical uses type; legacy uses event
switch (type) {
case 'charge.paid':
// fulfill order
break;
case 'charge.failed':
case 'charge.expired':
// cancel / expire
break;
case 'charge.refunded':
// handle refund
break;
case 'med_case.created':
case 'med_case.updated':
case 'med.resolved':
// Pix dispute (MED) — see "MED events" below
break;
default:
// unknown type: 200 OK and ignore
}
res.sendStatus(200); // respond 2xx quickly#退款 —— 对应哪个事件?
| 需求 | 事件 | 类别 |
|---|---|---|
| 收款已支付 | charge.paid | 规范 |
| 收款失败 / 过期 | charge.failed / charge.expired | 规范 |
| 收款退款 | charge.refunded | 规范 |
| 出款已结算 / 失败 | payout.paid / payout.failed | 规范(非退款) |
| Pix 争议(MED)开启 / 变更 / 解决 | med_case.created / med_case.updated / med.resolved | 规范 / 规范 / 旧版信封(非退款) |
如需退款通知,请在同一个端点上订阅 charge.refunded —— 每一笔退款都会触发它:包括您(或 Liqfy 团队代表您)发起的退款,以及基于付款人 CPF 的自动退还(devolução)。
MED 不是退款:当付款人的银行发起 MED 时,不会创建退款,charge.refunded 也不会触发 —— 无论是 MED 开启时,还是胜诉或败诉时。请通过 MED 事件 跟踪争议。
退款对象携带已退款金额及其来源:
{
"id": "evt_…",
"object": "event",
"type": "charge.refunded",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "refunded",
"payment_method": null,
"amount_refunded": 1000,
"settlement": { "end_to_end_id": "E0000…" },
"refund": {
"amount": 1000,
"currency": "BRL",
"origin": "MERCHANT",
"reason": "customer request",
"end_to_end_id": "E0000…"
}
}
}
}对于由商户/管理员发起的退款,refund.origin 为 MERCHANT;对于已结算付款人的 CPF/CNPJ 与收款预期付款人不匹配时触发的自动 Pix 退还(devolução),其值为 AUTOMATIC_PAYER_RESTRICTION。amount_refunded 是迄今为止的累计退款总额(单笔退款时等于 refund.amount;多次部分退款时会更大)。在此事件中,data.object.amount 是原始收款总额(amount_refunded 是其中已退款的部分),且 payment_method 始终为 null(退款事件不携带支付方式)。例外情况是在原始总额字段出现之前发出的退款事件:它们投递的是 amount: 0,因此如果您需要旧退款的原始总额,请读取该收款。
refund.reason 仅出现在商户发起的退款(MERCHANT)中 —— 自动退款从不携带原因。在该字段出现之前发出的事件中,refund.origin 可能不存在。
#多个账户 / 店铺使用同一个 URL
没问题。每个 Liqfy 账户都有自己的密钥和端点,但您的服务器 URL 可以共享。请使用载荷进行区分(data.object.id = ch_…、创建收款时设置的 metadata 等)。
#控制台操作路径(无需 API)
- Integrations → Webhooks → New endpoint
- 通用 HTTPS URL(例如
…/hooks/liqfy) - 至少勾选:
charge.created、charge.paid、charge.failed、charge.expired(如需退款,再加charge.refunded) - 保存密钥(secret)(仅显示一次)
- 点击 Test,确认您的服务器返回了 2xx
#1. 规范事件信封
新集成应订阅规范事件(charge.*、payout.*、med_case.*)。每次投递都是发往您注册 URL 的一个 HTTPS POST,结构如下:
{
"id": "evt_5f8a3c1e9b2d4a6f8e0c1b3d5f7a9c1e",
"object": "event",
"api_version": "2026-07-23",
"type": "charge.paid",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "paid",
"payment_method": "pix",
"idempotency_key": "order-123",
"metadata": { "order_id": "123" },
"payer": { "name": "Maria da Silva", "document": "***.982.247-**" },
"settlement": {}
}
}
}id(evt_…)是业务事件 id —— 在每次投递尝试和每个端点扇出中都保持不变。请据此去重。data.object是一个公开的、经过白名单过滤的charge/payout/med_case对象(其中不会出现服务商名称、成本、密钥或原始 PSP 载荷),但它并不总是完整的 REST 结构:- 只有
med_case.*真正一致 —— 与GET /v1/med/cases/{id}是同一个对象; charge.created是内容最丰富的charge载荷:它可能携带customer(name、document、email、phone)、pix(br_code、expires_at)、metadata和created_at。这些数据来自尽力而为的查询 —— 如果查询失败,事件将以精简结构发出。不提供二维码图片 URL/base64:请根据br_code渲染二维码,或读取该收款。仅当收款只接受一种支付方式时,才会填充payment_method;charge.paid、charge.failed和charge.expired可能包含idempotency_key和metadata;metadata 与GET /v1/charges/:id使用相同的公开过滤规则。这些状态事件均不添加customer、pix或created_at。请读取data.object.status:paid;在charge.failed中可能为failed、cancelled、expired或disputed;- 终态
payout.paid/payout.failed可能包含idempotency_key、amount、currency、pix_key和outcome;payout.failed还可能包含refunded和legs。不包含fee。公开status仍为paid或failed。payout.paid还可能包含原有的settlement:单段的end_to_end_id,或多段的end_to_end_id: null与legs[]。 charge.paid还可能包含payer,终态payout.paid/payout.failed还可能包含beneficiary。二者均为支付服务商所报告的{ name, document },任一值都可能为null。document始终经过掩码处理(CPF 为***.982.247-**,CNPJ 为**.***.111/0001-**);完整证件号绝不会发送。当服务商两个值都未报告时,整个对象会被省略。它不是创建收款时你提交的customer。
- 只有
- 仅当已知有效的 Pix 端到端(end-to-end)id 时才会填充
settlement.end_to_end_id—— 实际上即在charge.paid和charge.refunded上。charge.created始终包含settlement: {}。 - 务必妥善处理未知的
type值(200 OK+ 忽略),这样新增事件类型时永远不会影响您的集成。
#事件目录
GET /v1/webhooks/event-catalog 返回实时、权威的列表。与 /v1/webhooks 下的所有其他路由一样,它需要您的 API 密钥(只有服务商入站路由是开放的):
curl https://api.liqfy.com.br/v1/webhooks/event-catalog \
-H "Authorization: Bearer $LIQFY_API_KEY"响应是一个由 { "event", "description" } 对象组成的数组(条目还可能携带 legacy: true):
| 事件 | 触发时机 |
|---|---|
charge.created | 收款已创建(已生成 Pix/卡支付,等待付款)。 |
charge.paid | 收款已支付并确认(Pix/卡已结算)。 |
charge.failed | 收款失败或已取消。 |
charge.expired | 收款已过期且未付款。 |
charge.refunded | 收款已退款(全额或部分)—— 商户退款或自动退还(CPF 锁定)。MED 不会触发此事件(请使用 med_case.* 和 med.resolved)。 |
payout.created | 出款已申请。 |
payout.approved | 出款已批准。 |
payout.paid | 出款已成功结算。 |
payout.failed | 出款失败或被拒绝。 |
kyc.submitted | KYC 已提交审核。 |
kyc.approved | KYC 已批准。 |
kyc.rejected | KYC 被拒绝。 |
tenant.created | 已创建新账户。 |
tenant.updated | 账户已更新。 |
med.resolved | MED(付款人申请的退还)已解决 —— 告知款项去向:退回商户、未退还即结案,或作为损失结案。 |
med_case.created | 针对您某笔收款的 MED 案件已开启 —— 包含争议金额和提出抗辩的截止期限。 |
med_case.updated | MED 案件发生变化 —— 已抗辩、已接受、已结案、需提供证据。 |
KYC 和账户事件使用旧版信封 —— 请参阅账户/KYC 事件。
#终态出款字段
以下示例展示经过补充的规范回调。队列中的旧事件、测试投递或详情查询失败时,可能缺少新字段;请继续接受精简对象。旧版 {event,data} 格式不变。
payout.paid 示例(settlement 出现时保持原有结构):
{
"id": "evt_5f8a3c1e9b2d4a6f8e0c1b3d5f7a9c1e",
"object": "event",
"api_version": "2026-07-23",
"type": "payout.paid",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "po_01JABCDEF",
"object": "payout",
"status": "paid",
"idempotency_key": "withdrawal-123",
"amount": 1000,
"currency": "BRL",
"pix_key": "merchant@example.com",
"outcome": "completed",
"beneficiary": { "name": "Maria da Silva", "document": "***.982.247-**" }
}
}
}payout.failed 示例(失败且未退款的分段):
{
"id": "evt_5f8a3c1e9b2d4a6f8e0c1b3d5f7a9c1f",
"object": "event",
"api_version": "2026-07-23",
"type": "payout.failed",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "po_01JABCDEG",
"object": "payout",
"status": "failed",
"idempotency_key": "withdrawal-124",
"amount": 1000,
"currency": "BRL",
"pix_key": "merchant@example.com",
"outcome": "failed",
"refunded": false,
"legs": [
{
"index": 0,
"amount": 1000,
"status": "failed",
"refunded": false
}
]
}
}
}data.object 字段 | 含义 |
|---|---|
idempotency_key | 创建收款或出款时使用的键。 |
metadata(charge.paid、charge.failed、charge.expired) | 经过公开过滤的商户 metadata;提供时可包含 order_id,过滤规则与 GET /v1/charges/:id 相同。 |
amount、currency(payout.*) | 最小货币单位的金额及 ISO 4217 币种。 |
pix_key(payout.*) | Pix 收款方密钥;可为 null。 |
payer(charge.paid) | 服务商报告的付款方 { name, document };document 始终掩码;未报告时省略。 |
beneficiary(payout.*) | 服务商报告的收款方 { name, document };document 始终掩码;未报告时省略。 |
outcome(payout.*) | 小写的内部终态结果,与公开 status 分开。 |
refunded(payout.failed) | 投递时退款是否已入账。 |
legs[](payout.failed) | index、amount、小写 status、refunded;仅已完成且具有有效 Pix E2E 的分段包含 end_to_end_id。派发前失败时可为 []。 |
付款方与收款方为尽力提供。 这些字段取决于支付服务商在每笔操作中报告的内容,因此部分支付中可能缺失(或只有两个值中的一个)。仅将其作为附加信息:不要强制依赖,也不要用它们来对账或审批。
outcome | 公开 status | 事件 |
|---|---|---|
completed | paid | payout.paid |
failed, rejected, cancelled | failed | payout.failed |
refunded 是投递时的状态。如果退款被暂缓,payout.failed 可能为 false;之后释放退款不会再发送 webhook。请查询 GET /v1/withdrawals/{id} 获取最新状态。如果之后确认该出款实际已支付,payout.failed 之后会收到 payout.paid,已入账的退款(如有)会被冲回。
仅最终失败(账户设置)。 为您的账户启用后,只有在失败已成定局且退款已入账时才会发送
payout.failed。退款因待与银行确认而暂缓期间,不会发送任何 webhook;该出款最终以payout.failed(退款已入账)或payout.paid(PIX 已发出)结束。收到payout.failed后即可为您的用户退款。如需启用,请联系支持团队。
#出款失败的原因
payout.failed 以稳定代码的形式携带失败原因:data.object.failure_code(小写,例如 destination_not_allowed、destination_unavailable、invalid_pix_key、provider_rejected),以及 data.object.failure_message,即针对该代码的一句中性描述。仅当失败能映射到公开代码表中的某个代码时,两者才会出现;否则出款中两者均不携带。
仅部分分段结算的多段出款不会发送 webhook。 拆分出款仅部分完成(内部状态
PARTIALLY_COMPLETED)时,不会投递任何payout.*事件。请查询GET /v1/withdrawals/by-idempotency-key/{key}进行对账。
端到端暂缓(账户设置)。 启用后,缺少 E2E 的 Pix
charge.paid/payout.paid会暂缓投递,等待 Liqfy 确认。事件将携带已确认的 E2E 投递;同一收款或出款的后续事件会排队等待。若 30 分钟后仍未确认,系统会发出运维警报,但新建的暂缓投递仍继续等待。未启用此选项时,回调可能不含 E2E。测试投递不会暂缓。
争议(MED)通过 MED 事件跟踪,而不是
charge.*—— 该收款已支付,而 MED 既不是拒付也不是退款,因此不会发送charge.refunded。请改为订阅 MED 事件;案件开启期间重新读取收款会显示status: "disputed"。如果收款的争议状态变更确实到达了收款通知,它会以带有data.object.status: "disputed"的charge.failed形式到达 —— 切勿将其视为拒付,也不要因此取消订单。请参阅 争议与 MED。
迟到的
charge.paid可能出现在charge.expired之后。 如果收单机构在收款已过期后才确认付款(webhook 延迟 / 状态 API 滞后),Liqfy 会核查该付款,并在确认后为同一收款投递charge.paid。后到的charge.paid才是最终状态 —— 即使您之前已收到charge.expired,也请将其视为已支付。切勿认为charge.expired是终态。
一旦支付,后续失败不会投递。 在
charge.paid之后,同一收款后续的failed/expired/cancelled状态会被丢弃;全额退款后,该收款不会再投递任何后续状态。同一源事件的重复投递也会被丢弃。
过期事件也会送达
charge.failed的订阅者。 收款过期时,事件会扇出到两个名称:仅订阅了charge.failed的端点会以带有data.object.status: "expired"的charge.failed形式收到该过期事件。不要将每个charge.failed都视为拒付 —— 请读取data.object.status加以区分。
#2. 注册您的端点
端点 POST /v1/webhooks/endpoints
请求头
Authorization: Bearer lq_live_...
Content-Type: application/json规范的认证请求头是
Authorization: Bearer <key>。旧版apikey: <key>形式出于兼容性仍然可用,但新集成应使用Authorization: Bearer。
请求体
{
"url": "https://merchant.example.com/hooks/liqfy",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}响应 201 Created
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://merchant.example.com/hooks/liqfy",
"events": ["charge.expired", "charge.failed", "charge.paid"],
"secret": "b8f3a9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"status": "ACTIVE"
}请立即保存
secret。 它仅在创建时显示,且验证每个传入的 webhook 都需要它。我们不会再次显示它。
#限制与权限
| 规则 | 值 |
|---|---|
| 每个账户的活跃端点数 | KYC 已批准为 10 个,未批准为 2 个。在创建和重新激活(带 status: ACTIVE 的 PATCH)时检查 |
| 每个账户每小时测试投递次数 | KYC 已批准为 30 次,未批准为 10 次 |
| 账户状态不可用 | 当 Liqfy 无法确认账户状态时,适用未通过 KYC 的限制 |
| URL 格式 | 必须以 http:// 或 https:// 开头 —— 否则返回 400 |
| URL 协议 | 在 SANDBOX/STAGING 之外必须为 https:// —— 否则返回 400 WEBHOOK_HTTPS_REQUIRED。在该规则生效前保存的 http:// 端点仍会继续投递 |
| URL 主机 | 不得为平台自身的域名 —— 返回 400 WEBHOOK_OWN_DOMAIN |
| 发送时的 URL | 必须解析为公网地址且不得指向平台;否则该次尝试失败(SSRF_BLOCKED) |
events | 由已知名称组成的非空数组 —— 否则返回 400 并附带有效事件列表 |
| 账户内 URL 重复 | 409 WEBHOOK_ENDPOINT_DUPLICATE,并附带现有端点的 details.endpointId —— 请改为编辑其事件 |
| 超出端点限制 | 409 WEBHOOK_ENDPOINT_LIMIT(details.limit);超出限制的重新激活会被撤销 |
| 超出测试限制 | 429 WEBHOOK_TEST_RATE_LIMIT(details.limit) |
| 写操作(创建/更新/删除/轮换密钥/测试/重放) | 需要 OWNER 或 ADMIN 的控制台会话;只读模拟登录会被拒绝(403 WEBHOOK_ROLE_REQUIRED / IMPERSONATION_VIEW_ONLY)。角色从会话令牌中读取,因此角色变更会在该令牌过期后生效。API 密钥以账户本身的身份进行写操作。 |
| 修改密码后的控制台会话 | 修改密码前签发的会话会被拒绝 —— 返回 401 Session no longer valid |
| 已暂停或被封禁的账户 | 无法创建、编辑或测试端点 —— 返回 403 TENANT_NOT_ACTIVE |
#规范订阅
收款和出款订阅仅使用规范的公开名称。在创建/更新时添加已停用名称会被拒绝,返回 HTTP 400、代码 LEGACY_WEBHOOK_EVENT,以及一对 event / replacement。现有订阅遵循平台兼容性策略;更新时可以保留已订阅的名称。
{
"code": "LEGACY_WEBHOOK_EVENT",
"event": "<retired name>",
"replacement": "charge.paid",
"message": "Event <retired name> was retired; use charge.paid."
}同时订阅了规范名称及其已停用名称的端点,每次发生只会收到一次投递,且使用规范名称。原始名称 payment.status_changed 和 withdrawal.status_changed 在创建/更新时仍被接受,并分别展开为 charge.paid + charge.failed 和 payout.paid + payout.failed。
payout.approved 在结算之前报告批准。其版本化信封包含 data.object,其中带有 object: "payout"、一个 po_ id 和 status: "processing"。与其他出款事件一样,它使用带时间戳的 HMAC 签名。其事件 id 对每笔出款保持稳定,包括重新发布时。
#账户/KYC 事件(仅旧版信封)
kyc.submitted、kyc.approved、kyc.rejected 以及账户生命周期事件对 tenant.created / tenant.updated 也会触发,且始终使用旧版 { event, data } 信封(它们不属于 charge.*/payout.*,因此永远不会使用规范的 evt_ 包装或 t=,v1= 签名)。
在 kyc.* 上,data 携带 documentId 和 documentType(如已知)、status(在 kyc.submitted 上为 PENDING,在 kyc.approved 上为 APPROVED,在 kyc.rejected 上为 REJECTED)、在 kyc.rejected 上存在时的 reason,以及 occurredAt。
⚠️ 已知命名缺陷: 账户生命周期事件目前会在其载荷中原样输出一个内部字段名 —— 这是一个已在跟踪中的术语表违规问题,并非公共契约的有意组成部分。具体的事件和字段名称请参阅迁移指南中的已知缺陷与后续事项。请勿针对该字段名构建永久性集成。
#MED 事件
有三个事件用于跟踪 Pix 争议(MED)—— 完整流程请参阅 争议与 MED:
| 事件 | 信封 / 签名 | 触发时机 |
|---|---|---|
med_case.created | 规范 evt_ / t=,v1= | 针对您某笔收款的 MED 案件开启。 |
med_case.updated | 规范 evt_ / t=,v1= | 案件状态变更(appealed、accepted、rejected、closed),或平台要求您提供证据。 |
med.resolved | 旧版 {event,data} / sha256= | 案件得出结果;告知款项去向。 |
med_case.created / med_case.updated 在 data.object 中携带与 GET /v1/med/cases/{id} 返回的相同的 med_case 对象:
{
"id": "evt_9c2f4e1a7b3d5c8e0f1a2b3c4d5e6f70",
"object": "event",
"api_version": "2026-07-23",
"type": "med_case.created",
"created_at": "2026-09-23T14:02:12.000Z",
"data": {
"object": {
"id": "med_3f1c2b9e-0d7a-4a55-9a51-6f0e3c1d2b10",
"object": "med_case",
"type": "med",
"status": "open",
"amount": 15000,
"currency": "BRL",
"charge_id": "ch_6b0f4c1e-2a8d-4c1b-9d0e-1f2a3b4c5d6e",
"charge_metadata": { "order_id": "PED-10442" },
"customer_name": "Maria Silva",
"reason": "Payer reports a scam",
"response_deadline": "2026-09-30T23:59:59.000Z",
"evidence_requested_at": null,
"appeal_reason": null,
"appealed_at": null,
"evidence_count": 0,
"money_outcome": null,
"opened_at": "2026-09-23T14:02:11.000Z",
"closed_at": null,
"created_at": "2026-09-23T14:02:12.000Z",
"updated_at": "2026-09-23T14:02:12.000Z"
}
}
}med_case.updated 结构相同,带有 type: "med_case.updated" 以及变更后的案件(例如 status: "closed" 和 money_outcome: "refunded_to_merchant")。
在 med_case 对象中,status 取值为 open、appealed、accepted、rejected、closed 之一;type 为 med、chargeback、dispute 或 fraud;money_outcome 为 null,或 refunded_to_merchant、closed_without_refund、lost 之一。
med.resolved 使用旧版 {event, data} 请求体和 sha256= 签名:
{
"event": "med.resolved",
"data": {
"infractionId": "3f1c2b9e-0d7a-4a55-9a51-6f0e3c1d2b10",
"transactionId": "6b0f4c1e-2a8d-4c1b-9d0e-1f2a3b4c5d6e",
"amount": 15000,
"currency": "BRL",
"outcome": "closed_without_refund",
"occurredAt": "2026-09-11T14:32:07.000Z"
}
}infractionId是不带med_前缀的案件 id,transactionId是不带ch_前缀的收款 id:med_+infractionId即为您在/v1/med/cases/{id}中使用的id。outcome为refunded_to_merchant(被冻结的金额已退回您的余额)、closed_without_refund(已结案,金额未退回)或lost(金额保持扣除状态)。它与案件的money_outcome值相同。- 同一结果绝不会通知两次;但不同的结果(更正)会通知。
- 您自行接受案件不会发送
med.resolved—— 带有status: "accepted"的med_case.updated会通知您。
结果通知(med.resolved,以及针对 accepted / rejected / closed 的 med_case.updated)可能不会送达:发布失败的 MED 事件不会重试(一旦发布,向您端点的投递将遵循常规的重试计划)。请将这些事件视为信号,并通过 GET /v1/med/cases 进行对账 —— API 才是事实来源。
#3. 验证签名
每次投递都使用您端点的 secret,对原始请求体进行 HMAC-SHA256 签名。共有两种方案,根据事件类别自动选择 —— 如果您有任何仍处于活跃状态的旧版订阅,您的验证器应同时支持两者。
| 请求头 | 方案 | 适用于 |
|---|---|---|
X-Liqfy-Signature | t=<unix-seconds>,v1=<hex> —— 被签名的载荷为 "<t>.<rawBody>" | 规范事件(charge.*、payout.*、med_case.*) |
X-Liqfy-Signature | sha256=<hex> —— 被签名的载荷仅为原始请求体 | 旧版事件(kyc.*、med.resolved 以及上文的账户生命周期事件) |
X-Liqfy-Delivery-Id | 不透明 id,在同一投递的每次重试中保持不变 | 两者 |
X-Liqfy-Event-Type | 与所投递的事件名称一致 | 两者 |
X-Liqfy-Test | true,仅出现在测试投递 中;真实投递中均不存在 | 两者 |
User-Agent | Liqfy-Webhooks/1.0(名称中的空格变为连字符) | 两者 |
规范时间戳在发送时生成,因此 t(以及签名)在每次重试时都会变化,而请求体保持逐字节相同。在品牌迁移期间,旧的请求头名称也可能同时发送,携带相同的值。
规范方案在被签名内容中嵌入时间戳,正是为了让您能够拒绝重放超出容差窗口(建议:5 分钟)的请求 —— 旧版方案没有时间戳,无法做到这一点。
#Node.js (Express) —— 规范方案
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.LIQFY_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyCanonical(rawBody, header, secret) {
const [tPart, v1Part] = header.split(',');
const t = Number(tPart?.split('=')[1]);
const v1 = v1Part?.split('=')[1];
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
'/hooks/liqfy',
// capture raw body — express.json() strips it
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-Liqfy-Signature') || '';
if (!verifyCanonical(req.body.toString('utf8'), signature, SECRET)) {
return res.status(401).send('invalid signature');
}
const { id: eventId, type, data } = JSON.parse(req.body.toString('utf8'));
// 200 OK FAST. Do work in a background queue.
res.status(200).end();
queue.enqueue({ eventId, type, data, deliveryId: req.header('X-Liqfy-Delivery-Id') });
},
);不想自己手写?官方 SDK 会为您验证两种方案(规范与旧版)。推荐的入口是传入请求头映射(headers map):SDK 按名称(一个选项)选取签名请求头,或自动检测名称以
-signature结尾的唯一请求头 —— 这正是您品牌的投递所使用的请求头。请参阅packages/sdk-node/README.md。
// node — @liqfy/node
import { LiqfyClient } from '@liqfy/node';
const client = new LiqfyClient({ apiKey: process.env.LIQFY_API_KEY!, baseUrl: 'https://api.liqfy.com.br' });
// headers-map form: auto-detects the one `*-signature` header of this brand
if (!client.webhooks.verify(rawBody, headers, secret)) return res.status(401).end();
// or pin the header name explicitly (also settable once as `webhookSignatureHeader`)
client.webhooks.verify(rawBody, headers, secret, { signatureHeader: 'X-Liqfy-Signature' });
client.webhooks.parse(rawBody, headers, secret);
// 0.8.0 management surface
await client.webhooks.endpoints.test(endpointId); // POST /v1/webhooks/endpoints/:id/test
await client.webhooks.events({ since, limit: 50 }); // GET /v1/webhooks/events (pull)# python — liqfy
ok = client.webhooks.verify(raw_body, headers, secret) # auto-detect
ok = client.webhooks.verify(raw_body, headers, secret,
signature_header='X-Liqfy-Signature') # explicit name
event = client.webhooks.parse(raw_body, headers, secret)
client.webhooks.endpoints.test(endpoint_id)
client.webhooks.events(since=..., limit=50)// php — liqfy/sdk-php
$ok = $client->webhooks->verify($rawBody, $headers, $secret); // array = headers map, auto-detect
$ok = $client->webhooks->verify($rawBody, $headers, $secret, 300, null, 'X-Liqfy-Signature');
$event = $client->webhooks->parse($rawBody, $headers, $secret);
$client->webhooks->endpoints->test($endpointId);
$client->webhooks->events(['since' => $since, 'limit' => 50]);// dotnet — Liqfy
bool ok = client.Webhooks.Verify(rawBody, headers, secret); // dictionary = headers map
bool ok2 = client.Webhooks.Verify(rawBody, headers, secret, 300, "X-Liqfy-Signature");
var evt = client.Webhooks.Parse(rawBody, headers, secret);
await client.Webhooks.Endpoints.TestAsync(endpointId);
await client.Webhooks.EventsAsync(); // optional ListWebhookEventsQuery四种 SDK 的解析方式完全相同:显式指定的 signatureHeader(按名称匹配,不区分大小写)优先;未指定时,SDK 使用小写名称以 -signature 结尾的唯一请求头,若此类请求头为零个或两个则失败 —— 它从不猜测。直接传入签名字符串仍然可用,且无需请求头名称。
#PHP —— 无版本的 KYC 和账户事件
$secret = getenv('LIQFY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_LIQFY_SIGNATURE'] ?? '';
$expect = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expect, $sig)) {
http_response_code(401);
exit('invalid signature');
}
$body = json_decode($raw, true);
http_response_code(200);
// queue $body for processing#Python (Flask) —— 旧版方案
import hmac, hashlib, os
from flask import request, abort
SECRET = os.environ['LIQFY_WEBHOOK_SECRET'].encode()
@app.post('/hooks/liqfy')
def liqfy_hook():
raw = request.get_data() # bytes, untouched
sig = request.headers.get('X-Liqfy-Signature', '')
expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
payload = request.get_json()
# respond 200 fast, process async
return '', 200#4. 投递保证、重试与 DLQ
- 成功 任何
2xx响应都会确认投递并停止重试。 - 超时 30 秒(测试投递为 10 秒)。响应更慢则视为失败。
- 重试计划 指数退避(
1s × 2^attempt),两次尝试之间的间隔上限为 6 小时(WEBHOOK_BACKOFF_CAP_MS,默认21_600_000)。到期的投递每 10 秒拾取一次。 - 最大尝试次数 15 次(
WEBHOOK_MAX_ATTEMPTS)。最后一次尝试失败后,投递将转为CANCELLED(进入死信)—— 它不会被删除,可以手动重放。 - 更换端点 失败中的投递会跟随您当前注册的端点。添加一个端点(或重新激活一个端点)后,如果它订阅了相同的事件名称,您其他端点上失败中的投递会转移到该端点。停用或删除端点,或停止订阅某个事件后,仍为其排队的投递会转移到您订阅了同一事件的其他活跃端点 —— 如果没有这样的端点,则会被取消。修改端点的
url会立即重试其失败中的投递,并重置尝试次数配额。只有最近 7 天内创建的投递会被转移或重新开始。被转移的投递会获得新的X-Liqfy-Delivery-Id;规范事件的id(evt_…)保持不变。 - 429 处理 如果您的端点返回
429,并带有Retry-After响应头(秒)或 JSON 响应体{ "retry_after": <seconds> },则会遵循该值(两者取较大者,上限 5 分钟),而不是盲目的指数退避。 - 投递 id 稳定性 在同一投递的每次尝试中,
X-Liqfy-Delivery-Id都完全相同 —— 请将其用作主要去重键。
⚠️ 同一投递可能到达不止一次。 请始终基于
X-Liqfy-Delivery-Id(或规范事件的id/evt_…,在投递转移到其他端点时保持不变)进行去重,使您的处理程序具备幂等性。
#幂等处理程序模式
async function handle({ deliveryId, eventId, type, data }) {
// Atomic insert — fails if we've seen this delivery before
const inserted = await db.processedWebhooks.insertIgnore({
id: deliveryId ?? eventId,
receivedAt: new Date(),
});
if (!inserted) return; // already handled
if (type === 'charge.paid') {
await orders.markPaid(data.object.id, data.object);
}
}#5. 拉取 / 对账 API
如果错过了推送(您的接收端宕机数小时),请拉取错过的事件,而不是任其丢失。
curl "https://api.liqfy.com.br/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
-H "Authorization: Bearer $LIQFY_API_KEY"{
"data": [
{ "eventType": "charge.paid", "payload": { "...": "..." }, "status": "DELIVERED", "lastStatusCode": 200, "deliveredAt": "2026-07-23T14:31:05.000Z", "createdAt": "2026-07-23T14:31:00.000Z" }
],
"nextCursor": "MjAyNi0wNy0yM1QxNDozMTowMC4wMDBafGRlbF8xMjM="
}- 基于
(createdAt desc, id desc)的游标分页 —— 将nextCursor作为cursor回传以获取下一页;请将其视为不透明令牌。在最后一页上nextCursor为null。 limit默认为 50,最大 100。- 过滤条件:
since、until(ISO 8601)、status、eventType。 - 每一项都是一次投递:发送到您两个端点的事件会出现两次。对于规范事件,请基于载荷的
id(evt_…)去重。 - 严格限定于您自己的端点 —— 永远不会返回平台内部的投递。
#6. 测试端点
POST /v1/webhooks/endpoints/:id/test 会同步发送一次示例投递 —— 不产生投递记录,也不重试 —— 以便您检查状态、延迟和签名处理。它需要与其他写操作相同的权限,并计入每小时测试限制(请参阅限制与权限)。
curl -X POST "https://api.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "Authorization: Bearer $LIQFY_API_KEY"示例内容取决于端点的订阅:
charge.paid(默认情况,以及端点订阅了charge.paid时)—— 版本化的evt_信封和带时间戳的t=,v1=签名。对象是一个charge,其 id 为ch_test_…,并带有amount: 10000、status: "paid"、payment_method: "pix"和metadata.test: true。- 旧名称 —— 仅当端点订阅了
charge.paid的旧名称 而未订阅charge.paid时:{event,data}请求体和sha256=签名,带有data.test: true以及示意性的providerFee和netAmount,任何生产投递都不会输出它们 —— 请勿据此编写代码。
响应会告诉您发生了什么以及发送了什么:
{
"ok": true,
"statusCode": 200,
"durationMs": 182,
"errorKind": null,
"error": null,
"responseSnippet": "ok",
"eventType": "charge.paid",
"url": "https://merchant.example.com/hooks/liqfy",
"sentHeaders": { "...": "..." },
"sentPayload": { "...": "..." }
}errorKind:timeout(10 秒内无应答)、connection(无法访问、被拒绝或 URL 被阻止)、http(非 2xx 应答)或null。responseSnippet:您响应体的前 500 个字符,仅在 2xx 时提供;出错时您只会得到状态和消息。
#测试投递
有些投递是测试,而非业务事件:
- 上文的 Test 按钮;
- 测试收款的支付(使用
lq_test_密钥或在控制台的**测试环境(Test environment)**中创建); - **测试环境(Test environment)**中的模拟出款,它会发送一笔从未真实存在的已完成出款的 webhook(带有新的
po_…id 的payout.paid)。它有自己的每小时限制,额度与测试限制相同,按投递计数(每个订阅的端点计为一次)。
测试投递携带 X-Liqfy-Test: true。Test 按钮还会在收款对象中标记 metadata.test: true(旧版示例中为 data.test: true);已支付的测试收款和模拟出款投递的请求体与相应的业务事件相同。只有当平台能够读取收款详情时,测试收款的 charge.created 才会被标记 —— 如果该查询失败,则发出时不带该请求头。切勿根据测试投递履行订单或为客户入账。
#7. 生产环境检查清单
- 端点使用 HTTPS,且具有有效的 TLS 证书。
- 在 JSON 解析之前,基于原始请求体验证签名。
- 带有
X-Liqfy-Test: true的投递永远不会触发订单履约。 - 比较使用恒定时间算法(例如
timingSafeEqual/hash_equals/hmac.compare_digest)。 - 如果仍有端点订阅了旧版事件,您的验证器同时支持两种签名方案。
- 处理程序在 5 秒内响应
2xx。繁重的工作放入队列处理。 - 基于
X-Liqfy-Delivery-Id(规范事件则基于evt_…)实现幂等。 - 未知的
type/event名称会被妥善忽略(200 OK,不报错)。 - 密钥从密钥管理器加载 —— 绝不提交到代码库。
- 如果在预期时间窗口内未收到 webhook,会触发告警;使用拉取 API 作为兜底。
#8. 运维操作
#列出端点
curl "https://api.liqfy.com.br/v1/webhooks/endpoints" \
-H "Authorization: Bearer $LIQFY_API_KEY"{
"data": [
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://merchant.example.com/hooks/liqfy",
"events": ["charge.paid", "charge.failed"],
"status": "ACTIVE",
"createdAt": "2026-07-23T14:31:00.000Z",
"updatedAt": "2026-07-23T14:31:00.000Z",
"consecutiveFailures": 0,
"lastSuccessAt": "2026-07-23T14:31:05.000Z",
"lastStatusCode": 200,
"failingSince": null,
"failing": false
}
],
"access": { "canManage": true },
"httpsOnly": true
}各行永远不会包含 secret。access.canManage 表示当前调用方是否可以执行写操作(创建、更新、删除、轮换、测试、重放);httpsOnly 表示该环境是否对端点 URL 强制要求 https://。
#端点健康状况
每个端点行还携带其投递健康状况:
| 字段 | 含义 |
|---|---|
consecutiveFailures | 连续失败的投递尝试次数,期间无任何成功 |
lastSuccessAt | 最后一次尝试以 2xx 应答 |
lastStatusCode | 最后一次尝试的 HTTP 状态(传输错误时为 null) |
failingSince | 端点首次被判定为失败的时间;健康时为 null |
failing | 设置了 failingSince 时为 true |
当端点连续失败尝试达到 20 次(默认值),或在过去一小时内收到了尝试但无一成功时,该端点即变为失败状态。它会一直保持失败状态 —— 即使流量停止 —— 直到某次尝试成功。处于失败状态期间,系统会通过电子邮件和控制台通知该账户,最多每 6 小时一次。健康状况仅用于观察:它绝不会停止、延迟或更改任何投递。
#列出最近的投递
curl "https://api.liqfy.com.br/v1/webhooks/deliveries?limit=25" \
-H "Authorization: Bearer $LIQFY_API_KEY"响应为 { data, total, limit, offset }。每个条目包含尝试次数(attempts / maxAttempts)、最后的状态码、所投递的 payload、lastError 文本、nextRetryAt、deliveredAt,以及(对于转移到其他端点的投递)cancelReason / supersededById。此处不包含最后的响应体 —— 它仅出现在投递详情 中。可选过滤条件:status、eventType、endpointId、limit(默认 25,最大 100)、offset。
#查看单个投递
curl "https://api.liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>" \
-H "Authorization: Bearer $LIQFY_API_KEY"返回完整的投递记录 —— 包括 lastResponseBody、paymentMethod 和 test —— 以及尝试日志和其所属的端点:
{
"id": "…",
"eventType": "charge.paid",
"status": "FAILED",
"attempts": 3,
"lastStatusCode": 500,
"lastError": "HTTP 500",
"lastResponseBody": "{\"error\":\"…\"}",
"logs": [
{
"id": "…",
"deliveryId": "…",
"attempt": 1,
"statusCode": 500,
"error": "HTTP 500",
"responseBody": "{\"error\":\"…\"}",
"duration": 120,
"url": "https://merchant.example.com/hooks/liqfy",
"createdAt": "2026-07-23T14:31:00.000Z"
}
],
"endpoint": { "url": "https://merchant.example.com/hooks/liqfy", "status": "ACTIVE" }
}logs 按顺序列出每次尝试,每次都附带当时使用的 URL。转移到其他端点的投递带有 cancelReason(ENDPOINT_REPLACED、ENDPOINT_INACTIVE 或 ENDPOINT_DELETED),被取代时还带有 supersededById。未知 id 返回 404。
#重放失败或已取消的投递
curl -X POST "https://api.liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "Authorization: Bearer $LIQFY_API_KEY"重置尝试计数器并立即重新入队;返回 { "id", "status": "PENDING", "attempts": 0, "nextRetryAt" }。批量重放可通过 POST /v1/webhooks/deliveries/replay-bulk 使用,支持可选的 { status, endpointId, limit } 过滤条件(limit 默认 50,最大 500;其 status 默认为 FAILED + CANCELLED),返回 { "requeued": <count> }。
读取与批量重放的过滤语义不同。 GET /deliveries 和 GET /events 会静默忽略无效的 status、无效日期或格式错误的 cursor(您会得到未过滤的页面)。而 POST /deliveries/replay-bulk 在遇到未知的 status 时返回 400。已知问题(按现状记录):批量重放还会接受 status: DELIVERED 或 PROCESSING 并将这些投递重新入队,而上文的单个重放会以 400 拒绝这两者。
#轮换密钥
curl -X POST "https://api.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "Authorization: Bearer $LIQFY_API_KEY"新密钥仅返回一次,字段为 { "id": "…", "secret": "…" }。
轮换是服务端的即时切换 —— 在 rotate-secret 调用成功之后,Liqfy 签名的每个 webhook 都使用新密钥。服务端不存在重叠窗口。
为了在轮换时不丢失事件,您的验证器必须在部署期间暂时同时接受新旧两个密钥:
// Try new first, fall back to old. Drop OLD_SECRET after the deploy soaks.
const ok = verify(req, NEW_SECRET) || verify(req, OLD_SECRET);
if (!ok) return res.status(401).end();操作顺序:
- 调用 rotate-secret → 将新密钥与旧密钥一同保存。
- 部署同时启用两个密钥的验证器。
- 观察至少一分钟(让所有进行中的重试完成)。
- 在下一次部署时移除旧密钥。
#更新或删除端点
curl -X PATCH "https://api.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "Authorization: Bearer $LIQFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://merchant.example.com/hooks/liqfy-v2", "status": "ACTIVE"}'
curl -X DELETE "https://api.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "Authorization: Bearer $LIQFY_API_KEY"PATCH 仅更新您发送的字段(url、events、status:ACTIVE/INACTIVE),返回 { id, url, events, status },且绝不返回密钥。对于未知端点返回 404;未发送任何字段或 status 不是 ACTIVE/INACTIVE 时返回 400;重新激活超出限制时返回 409 WEBHOOK_ENDPOINT_LIMIT(该编辑会被撤销)。DELETE 返回 { "deleted": true } 并永久停用该端点:它会从您的列表中移除且不再接收任何内容,但其投递历史会被保留。仍为其排队的投递会转移到您订阅了同一事件的其他活跃端点 —— 请参阅投递保证。
#统计
curl "https://api.liqfy.com.br/v1/webhooks/stats" \
-H "Authorization: Bearer $LIQFY_API_KEY"按状态返回投递数量(PENDING、PROCESSING、DELIVERED、FAILED、CANCELLED),以及 total。
#常见问题
问:我可以有多个端点吗? 答:可以,最多可达您账户的活跃端点上限(KYC 已批准为 10 个,未批准为 2 个 —— 请参阅限制与权限)—— 这对于区分预发布、生产和可观测性接收端很有用。每个 URL 在每个账户中只能注册一次。
问:一个端点可以混合使用规范事件和旧版事件吗?
答:可以。单个端点上的 events 可以同时包含两种类别(例如 ["charge.paid", "kyc.approved"]);每个投递的事件都使用与其自身名称相匹配的信封/签名方案。
问:如果我的端点宕机数小时会怎样?
答:投递会持续重试,最多 15 次,退避间隔最长 6 小时。之后它会转为 CANCELLED,但不会被删除 —— 您可以手动重放,或通过拉取 API 拉取所有内容。
已停用的 webhook 订阅 已停用的收款和出款名称无法添加到订阅中。现有订阅保留其消息格式和签名。控制台会显示已保存的已停用名称,并附带移除提示及其替代名称;商户可以移除它们并选择规范名称。规范名称使用版本化信封和带时间戳的签名。
有关 E2E 保证和保留期限,请参阅结算标识符与查询。