Skip to content
LIQFYdocs
PTENENEN
Go to dashboard

Webhooks

正在从旧版事件迁移?请参阅迁移指南。

Webhook 是 Liqfy 向您的后端实时推送状态更新的方式。请使用 Webhook 进行履约 —— 切勿仅依赖轮询。如果错过了某次投递,请使用拉取/对账 API 补齐。

#0. 从这里开始(最常见问题)

#一个 URL,还是每个事件一个 URL?

一个 URL。 在控制台(Integrations → Webhooks → New endpoint)中,您注册一个 HTTPS 端点并勾选多个事件。Liqfy 会针对每次发生单独发送一个 POST,且始终发送到同一个 URL。

其他一些 PSPLiqfy
/webhook/cashin、/webhook/cashout、/webhook/refund(每种类型一个路径)https://your-shop.com/hooks/liqfy + 注册时的事件列表
每个事件一个 HTTP 路由一个路由、多个事件;通过 type 字段(或旧版的 event)区分

推荐的通用 URL:

text
https://your-domain.com/hooks/liqfy

除非您的路由器要求,否则请避免使用 /api/charge/created 这样的路径 —— 路径并不决定事件;决定事件的是控制台中的复选框(或 API 中的 events 数组)。

#“所有事件发送到同一个 URL”是什么样子?

您不会收到一个包含所有事件类型的载荷。每次状态变更您会收到一个 POST。请按类型分支处理:

js
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 事件 跟踪争议。

退款对象携带已退款金额及其来源:

json
{
  "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)

  1. Integrations → Webhooks → New endpoint
  2. 通用 HTTPS URL(例如 …/hooks/liqfy)
  3. 至少勾选:charge.created、charge.paid、charge.failed、charge.expired(如需退款,再加 charge.refunded)
  4. 保存密钥(secret)(仅显示一次)
  5. 点击 Test,确认您的服务器返回了 2xx

#1. 规范事件信封

新集成应订阅规范事件(charge.*、payout.*、med_case.*)。每次投递都是发往您注册 URL 的一个 HTTPS POST,结构如下:

json
{
  "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 密钥(只有服务商入站路由是开放的):

bash
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.submittedKYC 已提交审核。
kyc.approvedKYC 已批准。
kyc.rejectedKYC 被拒绝。
tenant.created已创建新账户。
tenant.updated账户已更新。
med.resolvedMED(付款人申请的退还)已解决 —— 告知款项去向:退回商户、未退还即结案,或作为损失结案。
med_case.created针对您某笔收款的 MED 案件已开启 —— 包含争议金额和提出抗辩的截止期限。
med_case.updatedMED 案件发生变化 —— 已抗辩、已接受、已结案、需提供证据。

KYC 和账户事件使用旧版信封 —— 请参阅账户/KYC 事件。

#终态出款字段

以下示例展示经过补充的规范回调。队列中的旧事件、测试投递或详情查询失败时,可能缺少新字段;请继续接受精简对象。旧版 {event,data} 格式不变。

payout.paid 示例(settlement 出现时保持原有结构):

json
{
  "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 示例(失败且未退款的分段):

json
{
  "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事件
completedpaidpayout.paid
failed, rejected, cancelledfailedpayout.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

请求头

text
Authorization: Bearer lq_live_...
Content-Type: application/json

规范的认证请求头是 Authorization: Bearer <key>。旧版 apikey: <key> 形式出于兼容性仍然可用,但新集成应使用 Authorization: Bearer。

请求体

json
{
  "url": "https://merchant.example.com/hooks/liqfy",
  "events": ["charge.paid", "charge.failed", "charge.expired"]
}

响应 201 Created

json
{
  "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。现有订阅遵循平台兼容性策略;更新时可以保留已订阅的名称。

json
{
  "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 对象:

json
{
  "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= 签名:

json
{
  "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-Signaturet=<unix-seconds>,v1=<hex> —— 被签名的载荷为 "<t>.<rawBody>"规范事件(charge.*、payout.*、med_case.*)
X-Liqfy-Signaturesha256=<hex> —— 被签名的载荷仅为原始请求体旧版事件(kyc.*、med.resolved 以及上文的账户生命周期事件)
X-Liqfy-Delivery-Id不透明 id,在同一投递的每次重试中保持不变两者
X-Liqfy-Event-Type与所投递的事件名称一致两者
X-Liqfy-Testtrue,仅出现在测试投递 中;真实投递中均不存在两者
User-AgentLiqfy-Webhooks/1.0(名称中的空格变为连字符)两者

规范时间戳在发送时生成,因此 t(以及签名)在每次重试时都会变化,而请求体保持逐字节相同。在品牌迁移期间,旧的请求头名称也可能同时发送,携带相同的值。

规范方案在被签名内容中嵌入时间戳,正是为了让您能够拒绝重放超出容差窗口(建议:5 分钟)的请求 —— 旧版方案没有时间戳,无法做到这一点。

#Node.js (Express) —— 规范方案

js
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。

js
// 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
# 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
// 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]);
csharp
// 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 和账户事件

php
$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) —— 旧版方案

python
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_…,在投递转移到其他端点时保持不变)进行去重,使您的处理程序具备幂等性。

#幂等处理程序模式

js
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

如果错过了推送(您的接收端宕机数小时),请拉取错过的事件,而不是任其丢失。

bash
curl "https://api.liqfy.com.br/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
  -H "Authorization: Bearer $LIQFY_API_KEY"
json
{
  "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 会同步发送一次示例投递 —— 不产生投递记录,也不重试 —— 以便您检查状态、延迟和签名处理。它需要与其他写操作相同的权限,并计入每小时测试限制(请参阅限制与权限)。

bash
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,任何生产投递都不会输出它们 —— 请勿据此编写代码。

响应会告诉您发生了什么以及发送了什么:

json
{
  "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. 运维操作

#列出端点

bash
curl "https://api.liqfy.com.br/v1/webhooks/endpoints" \
  -H "Authorization: Bearer $LIQFY_API_KEY"
json
{
  "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 小时一次。健康状况仅用于观察:它绝不会停止、延迟或更改任何投递。

#列出最近的投递

bash
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。

#查看单个投递

bash
curl "https://api.liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>" \
  -H "Authorization: Bearer $LIQFY_API_KEY"

返回完整的投递记录 —— 包括 lastResponseBody、paymentMethod 和 test —— 以及尝试日志和其所属的端点:

json
{
  "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。

#重放失败或已取消的投递

bash
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 拒绝这两者。

#轮换密钥

bash
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 都使用新密钥。服务端不存在重叠窗口。

为了在轮换时不丢失事件,您的验证器必须在部署期间暂时同时接受新旧两个密钥:

js
// 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();

操作顺序:

  1. 调用 rotate-secret → 将新密钥与旧密钥一同保存。
  2. 部署同时启用两个密钥的验证器。
  3. 观察至少一分钟(让所有进行中的重试完成)。
  4. 在下一次部署时移除旧密钥。

#更新或删除端点

bash
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 } 并永久停用该端点:它会从您的列表中移除且不再接收任何内容,但其投递历史会被保留。仍为其排队的投递会转移到您订阅了同一事件的其他活跃端点 —— 请参阅投递保证。

#统计

bash
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 保证和保留期限,请参阅结算标识符与查询。