Webhooks
Migrando de um evento legado? Veja o guia de migração.
Webhooks are how Liqfy pushes status updates to your backend in real time. Use them for fulfillment — never rely on polling alone. If a delivery is missed, use the pull/reconciliation API to catch up.
#0. Start here (most common questions)
#One URL or one URL per event?
One URL. In the dashboard (Integrations → Webhooks → New endpoint) you register one HTTPS endpoint and check multiple events. Liqfy sends a separate POST for each occurrence, always to the same URL.
| Some other PSPs | Liqfy |
|---|---|
/webhook/cashin, /webhook/cashout, /webhook/refund (path per type) | https://your-shop.com/hooks/liqfy + event list at registration |
| One HTTP route per event | One route, many events; the type field (or legacy event) distinguishes them |
Recommended generic URL:
https://your-domain.com/hooks/liqfyAvoid paths like /api/charge/created unless your router requires it — the path does not select the event; the dashboard checkboxes (or the events array in the API) do.
#What does “all events on one URL” look like?
You do not receive one payload with every event type. You receive one POST per status change. Branch on the type:
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#Refunds — which event?
| Need | Event | Family |
|---|---|---|
| Charge paid | charge.paid | canonical |
| Charge failed / expired | charge.failed / charge.expired | canonical |
| Charge refund | charge.refunded | canonical |
| Payout settled / failed | payout.paid / payout.failed | canonical (not a refund) |
| Pix dispute (MED) opened / changed / resolved | med_case.created / med_case.updated / med.resolved | canonical / canonical / legacy envelope (not a refund) |
Subscribe to charge.refunded on the same endpoint if you need refunds — it fires for every refund: the ones you (or the Liqfy team, on your behalf) issue, and the automatic payer-CPF devolução.
A MED is not a refund: when the payer's bank opens a MED, no refund is created and charge.refunded does not fire — neither when the MED opens nor when it is won or lost. Follow disputes with the MED events.
The refund object carries the refunded amount and its origin:
{
"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 is MERCHANT for a merchant/admin-initiated refund or AUTOMATIC_PAYER_RESTRICTION for the automatic Pix devolução triggered when a settled payer's CPF/CNPJ did not match the charge's expected payer. amount_refunded is the total refunded so far (equal to refund.amount for a single refund; larger across partial refunds). On this event data.object.amount is the original charge total (amount_refunded is the refunded part of it), and payment_method is always null (the refund event carries no payment method). Refund events emitted before the original total existed are the exception: they deliver amount: 0, so read the charge if you need the original total of an old refund.
refund.reason is present only on merchant-origin refunds (MERCHANT) — an automatic refund never carries a reason. refund.origin may be absent on events emitted before the field existed.
#Several accounts / shops on the same URL
Fine. Each Liqfy account has its own keys and endpoints, but your server URL can be shared. Segment using the payload (data.object.id = ch_…, metadata you set when creating the charge, etc.).
#Dashboard path (no API)
- Integrations → Webhooks → New endpoint
- Generic HTTPS URL (e.g.
…/hooks/liqfy) - Check at least:
charge.created,charge.paid,charge.failed,charge.expired(+charge.refundedif you need refunds) - Save the secret (shown once)
- Click Test and confirm your server returned 2xx
#1. The canonical event envelope
New integrations subscribe to the canonical events (charge.*, payout.*, med_case.*). Every delivery is an HTTPS POST to your registered URL with this shape:
{
"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_…) is the business-event id — stable across every delivery attempt and every endpoint fan-out. Deduplicate on it.data.objectis a public, allowlistedcharge/payout/med_caseobject (no provider name, cost, secret, or raw PSP payload can appear here), but it is not always the full REST shape:med_case.*is the only true parity — the same object asGET /v1/med/cases/{id};charge.createdis the richestchargepayload: it may carrycustomer(name,document,email,phone),pix(br_code,expires_at),metadataandcreated_at. These come from a best-effort lookup — if it fails, the event goes out in the reduced shape. There is no QR image URL/base64: render the QR frombr_codeor read the charge.payment_methodis filled only when the charge accepts a single method;charge.paid,charge.failedandcharge.expiredcan includeidempotency_keyandmetadata; the metadata has the same public filtering asGET /v1/charges/:id. None of these status events addscustomer,pixorcreated_at. Readdata.object.statusto know what happened:paid; oncharge.failed,failed,cancelled,expired(an expiry, see below) ordisputed;- Terminal
payout.paid/payout.failedcan includeidempotency_key,amount,currency,pix_keyandoutcome;payout.failedcan also includerefundedandlegs.feeremains absent.statusis one ofpending,processing,paid,failed.payout.paidmay also carry the existingsettlement:end_to_end_idfor a single-leg payout, orend_to_end_id: nullpluslegs[](index,amount,status: "paid",end_to_end_id) for a multi-leg payout. charge.paidcan also includepayerand terminalpayout.paid/payout.failedcan includebeneficiary. Each is{ name, document }as reported by the payment provider, and either value can benull.documentis always masked (***.982.247-**for a CPF,**.***.111/0001-**for a CNPJ); the full document is never sent. The whole object is omitted when the provider reported neither value. These are not thecustomeryou sent when creating the charge.
settlement.end_to_end_idis filled only when a valid Pix end-to-end id is known — in practice oncharge.paidandcharge.refunded.charge.createdalways hassettlement: {}.- Always handle unknown
typevalues gracefully (200 OK+ ignore) so adding new event types never breaks you.
#Event catalog
GET /v1/webhooks/event-catalog returns the live, authoritative list. It requires your
API key like every other route under /v1/webhooks (only the provider-inbound routes are
open):
curl https://api.liqfy.com.br/v1/webhooks/event-catalog \
-H "Authorization: Bearer $LIQFY_API_KEY"The response is an array of { "event", "description" } objects (an entry may also carry legacy: true):
| Event | When it fires |
|---|---|
charge.created | Cobrança criada (Pix/cartão gerado, aguardando pagamento). |
charge.paid | Cobrança paga e confirmada (Pix/cartão liquidado). |
charge.failed | Cobrança falhou ou foi cancelada. |
charge.expired | Cobrança expirou sem pagamento. |
charge.refunded | Cobrança reembolsada (total ou parcial) — reembolso do lojista ou devolução automática (trava de CPF). Não dispara para MED (use med_case.* e med.resolved). |
payout.created | Saque solicitado. |
payout.approved | Saque aprovado. |
payout.paid | Saque liquidado com sucesso. |
payout.failed | Saque falhou ou foi rejeitado. |
kyc.submitted | KYC enviado para análise. |
kyc.approved | KYC aprovado. |
kyc.rejected | KYC rejeitado. |
tenant.created | New account created. |
tenant.updated | Account updated. |
med.resolved | MED (devolução pedida pelo pagador) resolvida — informa o destino do valor: devolvido ao lojista, encerrado sem devolução ou encerrado como perda. |
med_case.created | Caso MED aberto contra uma cobrança sua — traz o valor contestado e o prazo para contestar. |
med_case.updated | Caso MED mudou — contestado, aceito, encerrado, evidências pedidas. |
The KYC and account events use the legacy envelope — see Account/KYC events.
#Terminal payout fields
These examples show enriched canonical callbacks. Older queued events, test deliveries and an unavailable detail lookup can omit the new fields; keep handling the reduced object. The legacy {event,data} callbacks do not gain these fields.
payout.paid example (settlement retains its existing shape when present):
{
"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 example (a failed leg with no refund):
{
"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
}
]
}
}
}Field in data.object | Meaning |
|---|---|
idempotency_key | Key supplied when creating the charge or payout. |
metadata (charge.paid, charge.failed, charge.expired) | Public merchant metadata, including order_id when supplied; same filtering as GET /v1/charges/:id. |
amount, currency (payout.*) | Payout amount in the smallest currency unit and ISO 4217 currency. |
pix_key (payout.*) | Pix destination key; may be null. |
payer (charge.paid) | Payer { name, document } as reported by the provider; document always masked; omitted when not reported. |
beneficiary (payout.*) | Beneficiary { name, document } as reported by the provider; document always masked; omitted when not reported. |
outcome (payout.*) | Lowercase internal terminal result; separate from public status. |
refunded (payout.failed) | Whether the payout refund has been booked at delivery time. |
legs[] (payout.failed) | Leg index, amount, lowercase status, refunded, and end_to_end_id only for a completed leg with a valid Pix E2E. Can be [] when failure happened before dispatch. |
Payer and beneficiary are best effort. These fields depend on what the payment provider reports for each operation, so they can be absent (or have only one of the two values) for some payments. Use them as extra context only: do not require them, and do not use them to reconcile or approve anything.
outcome | Public status | Event |
|---|---|---|
completed | paid | payout.paid |
failed, rejected, cancelled | failed | payout.failed |
refunded is a snapshot at delivery time. If a refund is held, payout.failed can say false; releasing that refund later does not send another webhook. Read GET /v1/withdrawals/{id} for the latest state. If the payout turns out to have been paid after all, payout.paid follows the payout.failed, and any refund already booked is reversed.
Final failures only (account setting). When enabled for your account,
payout.failedis sent only once the failure is final and the refund is booked. While a refund is held for confirmation with the bank, no webhook is sent; the payout then ends withpayout.failed(refund booked) orpayout.paid(the Pix went out). You can refund your end user as soon aspayout.failedarrives. Ask support to enable it.
#Why a payout failed
payout.failed carries the reason as a stable code: data.object.failure_code
(lowercase, e.g. destination_not_allowed, destination_unavailable, invalid_pix_key,
provider_rejected) plus data.object.failure_message, a neutral sentence for
that code. Both appear only when the failure maps to a code of the public table; otherwise
the payout carries neither.
A multi-leg payout that settles only some legs sends no webhook. When a split payout finishes only partially (internal status
PARTIALLY_COMPLETED), nopayout.*event is delivered at all. ReadGET /v1/withdrawals/by-idempotency-key/{key}to reconcile it.
End-to-end hold (account setting). When enabled for your account, a Pix
charge.paid/payout.paidwithout its E2E is held while Liqfy confirms it. The event is delivered with confirmed E2Es; later events for the same charge or payout wait behind it. If confirmation has not arrived after 30 minutes, the operations team receives an alert, but new holds keep waiting. Without this option, the callback may arrive without an E2E. Test deliveries are never held.
Disputes (MED) are followed with the MED events, not
charge.*— the charge was paid, and a MED is neither a refusal nor a refund, socharge.refundedis not sent. Subscribe to the MED events instead; re-reading the charge showsstatus: "disputed"while the case is open. If a dispute status change of the charge does reach the charge notifications, it arrives ascharge.failedwithdata.object.status: "disputed"— never treat that as a refusal or cancel the order because of it. See Disputes and MED.
A late
charge.paidcan follow acharge.expired. If the acquirer confirms a payment only after the charge already expired (delayed webhook / lagging status API), Liqfy checks the payment and, once confirmed, deliverscharge.paidfor that same charge. The latercharge.paidis the final state — treat it as paid, even though you had receivedcharge.expiredbefore. Never rely oncharge.expiredbeing terminal.
Once paid, later failures are not delivered. After
charge.paid, a laterfailed/expired/cancelledstatus for the same charge is dropped; after a full refund, no further status of that charge is delivered. A redelivery of the same source event is dropped too.
An expiry also reaches
charge.failedsubscribers. When a charge expires, the event fans out to both names: an endpoint subscribed only tocharge.failedreceives the expiry as acharge.failedwithdata.object.status: "expired". Do not treat everycharge.failedas a refusal — readdata.object.statusto tell them apart.
#2. Register your endpoint
Endpoint POST /v1/webhooks/endpoints
Headers
Authorization: Bearer lq_live_...
Content-Type: application/jsonThe canonical auth header is
Authorization: Bearer <key>. The legacyapikey: <key>form still works as compatibility, but new integrations should useAuthorization: Bearer.
Body
{
"url": "https://merchant.example.com/hooks/liqfy",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Response 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"
}Save the
secretimmediately. It is shown only at creation time and is required to verify every incoming webhook. We never display it again.
#Limits and permissions
| Rule | Value |
|---|---|
| Active endpoints per account | 10 with approved KYC, 2 without. Checked on create and on reactivation (PATCH with status: ACTIVE) |
| Test deliveries per hour per account | 30 with approved KYC, 10 without |
| Account status unavailable | when Liqfy cannot confirm the account status, the no-KYC limits apply |
| URL format | must start with http:// or https:// — 400 otherwise |
| URL scheme | https:// outside SANDBOX/STAGING — 400 WEBHOOK_HTTPS_REQUIRED otherwise. An http:// endpoint saved before the rule keeps delivering |
| URL host | may not be the platform's own domain — 400 WEBHOOK_OWN_DOMAIN |
| URL at send time | must resolve to a public address and not to the platform; otherwise the attempt fails (SSRF_BLOCKED) |
events | non-empty array of known names — 400 with the list of valid events otherwise |
| Duplicate URL in the account | 409 WEBHOOK_ENDPOINT_DUPLICATE, with details.endpointId of the existing endpoint — edit its events instead |
| Over the endpoint limit | 409 WEBHOOK_ENDPOINT_LIMIT (details.limit); a reactivation over the limit is undone |
| Over the test limit | 429 WEBHOOK_TEST_RATE_LIMIT (details.limit) |
| Writes (create/update/delete/rotate-secret/test/replay) | need an OWNER or ADMIN dashboard session; view-only impersonation is refused (403 WEBHOOK_ROLE_REQUIRED / IMPERSONATION_VIEW_ONLY). The role is read from the session token, so a role change takes effect when that token expires. An API key writes as the account itself. |
| Dashboard session after a password change | a session issued before the password change is refused — 401 Session no longer valid |
| Suspended or blocked account | cannot create, edit or test endpoints — 403 TENANT_NOT_ACTIVE |
#Canonical subscriptions
Charge and payout subscriptions use only the canonical public names. Adding retired names is rejected on create/update with HTTP 400, code LEGACY_WEBHOOK_EVENT, and an event / replacement pair. Existing subscriptions follow the platform compatibility policy; updates may retain names already subscribed.
{
"code": "LEGACY_WEBHOOK_EVENT",
"event": "<retired name>",
"replacement": "charge.paid",
"message": "Event <retired name> was retired; use charge.paid."
}An endpoint subscribed both to a canonical name and to its retired name receives one delivery per occurrence, under the canonical name. The raw names payment.status_changed and withdrawal.status_changed are still accepted on create/update and are expanded to charge.paid + charge.failed and payout.paid + payout.failed.
payout.approved reports approval, before settlement. Its versioned envelope contains data.object with object: "payout", a po_ id and status: "processing". It uses the timestamped HMAC signature, like other payout events. Its event id is stable per payout, including re-publishes.
#Account/KYC events (legacy envelope only)
kyc.submitted, kyc.approved, kyc.rejected, and the account-lifecycle pair tenant.created / tenant.updated also fire and always use the legacy { event, data } envelope (they are not charge.*/payout.*, so they never get the canonical evt_ wrapper or the t=,v1= signature).
On kyc.*, data carries documentId and documentType when known, status (PENDING on kyc.submitted, APPROVED on kyc.approved, REJECTED on kyc.rejected), reason on kyc.rejected when there is one, and occurredAt.
⚠️ Known naming gap: the account-lifecycle events currently emit an internal field name verbatim in their payload — a tracked glossary violation, not an intentional part of the public contract. See the migration guide's known gap and followups for the exact event and field names. Do not build a permanent integration against that field name.
#MED events
Three events follow a Pix dispute (MED) — the full flow is in Disputes and MED:
| Event | Envelope / signature | When it fires |
|---|---|---|
med_case.created | canonical evt_ / t=,v1= | A MED case opens against one of your charges. |
med_case.updated | canonical evt_ / t=,v1= | The case changes status (appealed, accepted, rejected, closed) or the platform asks you for evidence. |
med.resolved | legacy {event,data} / sha256= | The case reaches its outcome; tells where the money went. |
med_case.created / med_case.updated carry the same med_case object that
GET /v1/med/cases/{id} returns, in data.object:
{
"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"
}
}
}A med_case.updated has the same shape, with type: "med_case.updated" and the case as it
is after the change (for example status: "closed" and money_outcome: "refunded_to_merchant").
In the med_case object, status is one of open, appealed, accepted, rejected,
closed; type is med, chargeback, dispute or fraud; money_outcome is null or
one of refunded_to_merchant, closed_without_refund, lost.
med.resolved uses the legacy {event, data} body and the sha256= signature:
{
"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"
}
}infractionIdis the case id without themed_prefix andtransactionIdis the charge id without thech_prefix:med_+infractionIdis theidyou use in/v1/med/cases/{id}.outcomeisrefunded_to_merchant(the held amount came back to your balance),closed_without_refund(closed, the amount did not come back) orlost(the amount stays debited). It is the same value as the case'smoney_outcome.- The same outcome is never announced twice; a different outcome (a correction) is.
- Accepting a case yourself does not send
med.resolved— themed_case.updatedwithstatus: "accepted"tells you.
Outcome notifications (med.resolved, and med_case.updated for accepted / rejected /
closed) may not arrive: a MED event that fails to be published is not retried (delivery to your endpoint, once published, follows the
normal retry schedule). Treat these events as a
signal and reconcile with GET /v1/med/cases — the API is the source of truth.
#3. Verify the signature
Every delivery is signed with HMAC-SHA256 over the raw request body using your endpoint's secret. There are two schemes, chosen automatically by event family — your verifier should support both if you have any legacy subscription active.
| Header | Scheme | Applies to |
|---|---|---|
X-Liqfy-Signature | t=<unix-seconds>,v1=<hex> — signed payload is "<t>.<rawBody>" | Canonical events (charge.*, payout.*, med_case.*) |
X-Liqfy-Signature | sha256=<hex> — signed payload is the raw body alone | Legacy events (kyc.*, med.resolved, and the account-lifecycle events above) |
X-Liqfy-Delivery-Id | Opaque id, stable across every retry of the same delivery | Both |
X-Liqfy-Event-Type | Mirrors the delivered event name | Both |
X-Liqfy-Test | true, only on a test delivery; absent on every real one | Both |
User-Agent | Liqfy-Webhooks/1.0 (spaces in the name become hyphens) | Both |
The canonical timestamp is generated at send time, so t (and the signature) changes on every retry while the body stays byte-identical. During a brand migration, older header names may also be sent in parallel, carrying the same values.
The canonical scheme embeds a timestamp in the signed material specifically so you can reject replays outside a tolerance window (recommended: 5 minutes) — the legacy scheme has no timestamp and cannot do this.
#Node.js (Express) — canonical scheme
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') });
},
);Prefer not to hand-roll this? The official SDKs verify both schemes (canonical and legacy) for you. The recommended entry point passes the headers map: the SDK picks the signature header by name (an option), or auto-detects the single header whose name ends in
-signature— which is exactly the header your brand's deliveries use. Seepackages/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 ListWebhookEventsQueryResolution is identical in the four: an explicit signatureHeader (name match,
case-insensitive) wins; without one, the SDK uses the single header whose lowercased
name ends with -signature, and fails when there are zero or two such headers — it never
guesses. A bare signature string still works and needs no header name.
#PHP — unversioned KYC and account events
$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) — legacy scheme
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. Delivery guarantees, retries and DLQ
- Success Any
2xxresponse confirms the delivery and stops retries. - Timeout 30 seconds (10 for a test delivery). A slower response counts as a failure.
- Retry schedule Exponential backoff (
1s × 2^attempt), capped at 6 hours between attempts (WEBHOOK_BACKOFF_CAP_MS, default21_600_000). Due deliveries are picked up every 10 seconds. - Maximum attempts 15 (
WEBHOOK_MAX_ATTEMPTS). After the last failed attempt the delivery moves toCANCELLED(dead-lettered) — it is not deleted, and can be replayed manually. - Paused or stopped endpoint If repeated deliveries to your server fail, delivery can be paused. The dashboard shows the affected endpoint, its last error, and how many events are waiting. While paused, new events are kept for resend. If repeated attempts to restore delivery fail, the endpoint can be stopped; it will not resume on its own.
- Restore delivery Fix your server, then select Resend in the webhooks page or the alert on your dashboard. This reactivates the endpoint and resends its waiting events. You can also call
POST /v1/webhooks/endpoints/{id}/resume. A failed resend may pause delivery again. Check the endpoint status and reconcile payments through the API while delivery is interrupted. If you replace the endpoint, subscribe the new active endpoint to the same events so waiting deliveries can move to it. - Changing your endpoint Failing deliveries follow the endpoint you have registered now. Add an endpoint (or re-activate one) and the deliveries that are failing on your other endpoints move to it, when it is subscribed to the same event name. Deactivate or delete an endpoint, or stop subscribing to an event, and whatever was still queued for it moves to your other active endpoints subscribed to the same event — or is cancelled if there is none. Changing an endpoint's
urlretries its failing deliveries right away, with a fresh attempt budget. Only deliveries created in the last 7 days are moved or restarted. A moved delivery gets a newX-Liqfy-Delivery-Id; the canonical event'sid(evt_…) stays the same. - 429 handling If your endpoint returns
429with aRetry-Afterheader (seconds) or a JSON body{ "retry_after": <seconds> }, that value (the larger of the two) is honored (capped at 5 minutes) instead of the blind exponential backoff. - Delivery id stability
X-Liqfy-Delivery-Idis identical across every attempt of the same delivery — use it as your primary dedup key.
⚠️ A delivery may arrive more than once. Always make your handler idempotent by deduplicating on
X-Liqfy-Delivery-Id(or the canonical event'sid/evt_…, which stays the same when a delivery moves to another endpoint).
#Idempotent handler pattern
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. Pull / reconciliation API
If a push was missed (your receiver was down for hours), pull the events you missed instead of losing them.
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="
}- Cursor pagination on
(createdAt desc, id desc)— passnextCursorback ascursorfor the next page; treat it as an opaque token.nextCursorisnullon the last page. limitdefaults to 50, max 100.- Filters:
since,until(ISO 8601),status,eventType. - Each item is a delivery: an event sent to two of your endpoints appears twice. Deduplicate on the payload's
id(evt_…) for canonical events. - Scoped strictly to your own endpoints — never returns platform-internal deliveries.
#6. Testing an endpoint
POST /v1/webhooks/endpoints/:id/test sends a synchronous sample delivery — no delivery record, no retries — so you can check status, latency, and signature handling. It needs the same permission as the other writes and counts toward the hourly test limit (see Limits and permissions).
curl -X POST "https://api.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "Authorization: Bearer $LIQFY_API_KEY"The sample follows what the endpoint is subscribed to:
charge.paid(default, and whenever the endpoint subscribes tocharge.paid) — versionedevt_envelope and timestampedt=,v1=signature. The object is achargewith idch_test_…,amount: 10000,status: "paid",payment_method: "pix"andmetadata.test: true.- legacy name — only when the endpoint subscribes to the retired name of
charge.paidand not tocharge.paid:{event,data}body andsha256=signature, withdata.test: trueand illustrativeproviderFeeandnetAmount, which no production delivery emits — do not code against them.
The response tells you what happened and what was sent:
{
"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(no answer in 10 seconds),connection(unreachable, refused, or a blocked URL),http(non-2xx answer) ornull.responseSnippet: the first 500 characters of your response body, only on 2xx; on an error you get only the status and message.
#Test deliveries
Some deliveries are tests, not business events:
- the Test button above;
- the payment of a test charge (created with a
lq_test_key or in the dashboard's Test environment); - the simulated payout of the Test environment, which sends the webhook of a completed payout that never existed (
payout.paidwith a newpo_…id). It has its own hourly limit, of the same size as the test limit, counted per delivery (each subscribed endpoint counts one).
Test deliveries carry X-Liqfy-Test: true. The Test button also stamps metadata.test: true in the charge object (data.test: true in the legacy sample); the paid test charge and the simulated payout deliver the same body as the corresponding business event. The charge.created of a test charge is marked only when the platform could read the charge details — if that lookup fails, it goes out without the header. Never fulfill an order or credit a customer from a test delivery.
#7. Production checklist
- Endpoint is HTTPS with a valid TLS certificate.
- Signature is verified on the raw body, before JSON parsing.
- Deliveries with
X-Liqfy-Test: truenever fulfill an order. - Comparison uses constant-time (e.g.
timingSafeEqual/hash_equals/hmac.compare_digest). - Your verifier supports both signature schemes if any endpoint is still subscribed to a legacy event.
- Handler responds
2xxin under 5 seconds. Heavy work goes to a queue. - Idempotency on
X-Liqfy-Delivery-Id(orevt_…for canonical events). - Unknown
type/eventnames are ignored gracefully (200 OK, no error). - Secret is loaded from a secret manager — never committed.
- An alert fires if no webhook is received within an expected window; use the pull API as a backstop.
#8. Operations
#List endpoints
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
}Rows never include the secret. access.canManage says whether you may write (create,
update, delete, rotate, test, replay) from this caller; httpsOnly says whether the
environment enforces https:// on endpoint URLs.
#Endpoint health
Each endpoint row also carries its delivery health:
| Field | Meaning |
|---|---|
consecutiveFailures | Failed delivery attempts in a row, with no success in between |
lastSuccessAt | Last attempt answered with 2xx |
lastStatusCode | HTTP status of the last attempt (null on a transport error) |
failingSince | When the endpoint was first considered failing; null while healthy |
failing | true while failingSince is set |
An endpoint becomes failing when it reaches 20 failed attempts in a row (default), or when it received attempts in the last hour and none of them succeeded. It stays failing — even if traffic stops — until an attempt succeeds. While it is failing, the account is notified by e-mail and in the dashboard at most once every 6 hours. Health only observes: it never stops, delays or changes a delivery.
#List recent deliveries
curl "https://api.liqfy.com.br/v1/webhooks/deliveries?limit=25" \
-H "Authorization: Bearer $LIQFY_API_KEY"The response is { data, total, limit, offset }. Each entry includes the attempt count
(attempts / maxAttempts), last status code, the delivered payload, the lastError
text, nextRetryAt, deliveredAt and, for a delivery moved to another endpoint,
cancelReason / supersededById. The last response body is not here — it
is only on the delivery detail. Optional filters: status,
eventType, endpointId, limit (default 25, max 100), offset.
#Inspect one delivery
curl "https://api.liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>" \
-H "Authorization: Bearer $LIQFY_API_KEY"Returns the full delivery record — including lastResponseBody, paymentMethod and
test — plus the attempt log and the endpoint it belongs to:
{
"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 lists every attempt in order, each with the URL used at that moment. A delivery that was moved to another endpoint carries
cancelReason (ENDPOINT_REPLACED, ENDPOINT_INACTIVE or ENDPOINT_DELETED) and, when
it was superseded, supersededById. An unknown id returns 404.
#Replay a failed or cancelled delivery
curl -X POST "https://api.liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "Authorization: Bearer $LIQFY_API_KEY"Resets the attempt counter and requeues immediately; it answers { "id", "status": "PENDING", "attempts": 0, "nextRetryAt" }. Bulk replay is available at POST /v1/webhooks/deliveries/replay-bulk with an optional { status, endpointId, limit } filter (default limit 50, max 500; its status defaults to FAILED + CANCELLED) and answers { "requeued": <count> }.
Filter semantics differ between reads and bulk replay. GET /deliveries and
GET /events silently ignore an invalid status, an invalid date or a malformed
cursor (you get an unfiltered page). POST /deliveries/replay-bulk, instead, answers
400 on an unknown status. Known issue (documented as-is): bulk replay also accepts
status: DELIVERED or PROCESSING and re-queues those deliveries, while the single
replay above refuses both with 400.
#Rotate a secret
curl -X POST "https://api.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "Authorization: Bearer $LIQFY_API_KEY"The new secret is returned once, as { "id": "…", "secret": "…" }.
Rotation is an instantaneous server-side swap — every webhook Liqfy signs after a successful rotate-secret call uses the new secret. There is no server-side overlap window.
To rotate without dropped events, your verifier must temporarily accept both the old and new secret during your deploy:
// 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();Order of operations:
- Call rotate-secret → store the new secret alongside the old.
- Deploy your verifier with both secrets active.
- Soak for at least one minute (any in-flight retries clear).
- Remove the old secret on the next deploy.
#Update or delete an endpoint
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 updates only the fields you send (url, events, status: ACTIVE/INACTIVE), answers { id, url, events, status } and never returns the secret. It answers 404 for an unknown endpoint, 400 when no field is sent or status is not ACTIVE/INACTIVE, and 409 WEBHOOK_ENDPOINT_LIMIT when a reactivation goes over the limit (the edit is undone). DELETE answers { "deleted": true } and deactivates the endpoint for good: it leaves your list and receives nothing else, while its delivery history is kept. Deliveries still queued for it move to your other active endpoints subscribed to the same event — see Delivery guarantees.
#Stats
curl "https://api.liqfy.com.br/v1/webhooks/stats" \
-H "Authorization: Bearer $LIQFY_API_KEY"Returns delivery counts by state (PENDING, PROCESSING, DELIVERED, FAILED, CANCELLED) plus total.
#FAQ
Q: Can I have multiple endpoints? A: Yes, up to the active-endpoint limit of your account (10 with approved KYC, 2 without — see Limits and permissions) — useful for separating staging, production, and observability sinks. Each URL can be registered only once per account.
Q: Can an endpoint mix canonical and legacy events?
A: Yes. events on a single endpoint can include both families (e.g. ["charge.paid", "kyc.approved"]); each delivered event uses the envelope/signature scheme that matches its own name.
Q: What happens if my endpoint is down for hours?
A: The delivery keeps being retried for up to 15 attempts, with backoff up to 6 hours. After that it moves to CANCELLED, without being deleted — you can replay it manually or pull everything through the pull API.
Retired webhook subscriptions Retired charge and payout names cannot be added to subscriptions. Existing subscriptions keep their message format and signature. The dashboard shows saved retired names with a removal notice and their replacement; merchants can remove them and select a canonical name. Canonical names use the versioned envelope and timestamped signature.
See settlement identifiers and lookup for E2E guarantees and retention.