Skip to content
LIQFYdocs
PTENENEN
Go to dashboard

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 PSPsLiqfy
/webhook/cashin, /webhook/cashout, /webhook/refund (path per type)https://your-shop.com/hooks/liqfy + event list at registration
One HTTP route per eventOne route, many events; the type field (or legacy event) distinguishes them

Recommended generic URL:

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

Avoid 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:

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

#Refunds — which event?

NeedEventFamily
Charge paidcharge.paidcanonical
Charge failed / expiredcharge.failed / charge.expiredcanonical
Charge refundcharge.refundedcanonical
Payout settled / failedpayout.paid / payout.failedcanonical (not a refund)
Pix dispute (MED) opened / changed / resolvedmed_case.created / med_case.updated / med.resolvedcanonical / 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:

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 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)

  1. Integrations → Webhooks → New endpoint
  2. Generic HTTPS URL (e.g. …/hooks/liqfy)
  3. Check at least: charge.created, charge.paid, charge.failed, charge.expired (+ charge.refunded if you need refunds)
  4. Save the secret (shown once)
  5. 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:

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_…) is the business-event id — stable across every delivery attempt and every endpoint fan-out. Deduplicate on it.
  • data.object is a public, allowlisted charge/payout/med_case object (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 as GET /v1/med/cases/{id};
    • charge.created is the richest charge payload: it may carry customer (name, document, email, phone), pix (br_code, expires_at), metadata and created_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 from br_code or read the charge. payment_method is filled only when the charge accepts a single method;
    • charge.paid, charge.failed and charge.expired can include idempotency_key and metadata; the metadata has the same public filtering as GET /v1/charges/:id. None of these status events adds customer, pix or created_at. Read data.object.status to know what happened: paid; on charge.failed, failed, cancelled, expired (an expiry, see below) or disputed;
    • Terminal payout.paid / payout.failed can include idempotency_key, amount, currency, pix_key and outcome; payout.failed can also include refunded and legs. fee remains absent. status is one of pending, processing, paid, failed. payout.paid may also carry the existing settlement: end_to_end_id for a single-leg payout, or end_to_end_id: null plus legs[] (index, amount, status: "paid", end_to_end_id) for a multi-leg payout.
    • charge.paid can also include payer and terminal payout.paid / payout.failed can include beneficiary. Each is { name, document } as reported by the payment provider, and either value can be null. document is 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 the customer you sent when creating the charge.
  • settlement.end_to_end_id is filled only when a valid Pix end-to-end id is known — in practice on charge.paid and charge.refunded. charge.created always has settlement: {}.
  • Always handle unknown type values 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):

bash
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):

EventWhen it fires
charge.createdCobrança criada (Pix/cartão gerado, aguardando pagamento).
charge.paidCobrança paga e confirmada (Pix/cartão liquidado).
charge.failedCobrança falhou ou foi cancelada.
charge.expiredCobrança expirou sem pagamento.
charge.refundedCobranç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.createdSaque solicitado.
payout.approvedSaque aprovado.
payout.paidSaque liquidado com sucesso.
payout.failedSaque falhou ou foi rejeitado.
kyc.submittedKYC enviado para análise.
kyc.approvedKYC aprovado.
kyc.rejectedKYC rejeitado.
tenant.createdNew account created.
tenant.updatedAccount updated.
med.resolvedMED (devolução pedida pelo pagador) resolvida — informa o destino do valor: devolvido ao lojista, encerrado sem devolução ou encerrado como perda.
med_case.createdCaso MED aberto contra uma cobrança sua — traz o valor contestado e o prazo para contestar.
med_case.updatedCaso 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):

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 example (a failed leg with no refund):

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
        }
      ]
    }
  }
}
Field in data.objectMeaning
idempotency_keyKey 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.

outcomePublic statusEvent
completedpaidpayout.paid
failed, rejected, cancelledfailedpayout.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.failed is 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 with payout.failed (refund booked) or payout.paid (the Pix went out). You can refund your end user as soon as payout.failed arrives. 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), no payout.* event is delivered at all. Read GET /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.paid without 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, so charge.refunded is not sent. Subscribe to the MED events instead; re-reading the charge shows status: "disputed" while the case is open. If a dispute status change of the charge does reach the charge notifications, it arrives as charge.failed with data.object.status: "disputed" — never treat that as a refusal or cancel the order because of it. See Disputes and MED.

A late charge.paid can follow a charge.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, delivers charge.paid for that same charge. The later charge.paid is the final state — treat it as paid, even though you had received charge.expired before. Never rely on charge.expired being terminal.

Once paid, later failures are not delivered. After charge.paid, a later failed / expired / cancelled status 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.failed subscribers. When a charge expires, the event fans out to both names: an endpoint subscribed only to charge.failed receives the expiry as a charge.failed with data.object.status: "expired". Do not treat every charge.failed as a refusal — read data.object.status to tell them apart.

#2. Register your endpoint

Endpoint POST /v1/webhooks/endpoints

Headers

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

The canonical auth header is Authorization: Bearer <key>. The legacy apikey: <key> form still works as compatibility, but new integrations should use Authorization: Bearer.

Body

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

Response 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"
}

Save the secret immediately. It is shown only at creation time and is required to verify every incoming webhook. We never display it again.

#Limits and permissions

RuleValue
Active endpoints per account10 with approved KYC, 2 without. Checked on create and on reactivation (PATCH with status: ACTIVE)
Test deliveries per hour per account30 with approved KYC, 10 without
Account status unavailablewhen Liqfy cannot confirm the account status, the no-KYC limits apply
URL formatmust start with http:// or https:// — 400 otherwise
URL schemehttps:// outside SANDBOX/STAGING — 400 WEBHOOK_HTTPS_REQUIRED otherwise. An http:// endpoint saved before the rule keeps delivering
URL hostmay not be the platform's own domain — 400 WEBHOOK_OWN_DOMAIN
URL at send timemust resolve to a public address and not to the platform; otherwise the attempt fails (SSRF_BLOCKED)
eventsnon-empty array of known names — 400 with the list of valid events otherwise
Duplicate URL in the account409 WEBHOOK_ENDPOINT_DUPLICATE, with details.endpointId of the existing endpoint — edit its events instead
Over the endpoint limit409 WEBHOOK_ENDPOINT_LIMIT (details.limit); a reactivation over the limit is undone
Over the test limit429 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 changea session issued before the password change is refused — 401 Session no longer valid
Suspended or blocked accountcannot 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.

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

EventEnvelope / signatureWhen it fires
med_case.createdcanonical evt_ / t=,v1=A MED case opens against one of your charges.
med_case.updatedcanonical evt_ / t=,v1=The case changes status (appealed, accepted, rejected, closed) or the platform asks you for evidence.
med.resolvedlegacy {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:

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"
    }
  }
}

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:

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 is the case id without the med_ prefix and transactionId is the charge id without the ch_ prefix: med_ + infractionId is the id you use in /v1/med/cases/{id}.
  • outcome is refunded_to_merchant (the held amount came back to your balance), closed_without_refund (closed, the amount did not come back) or lost (the amount stays debited). It is the same value as the case's money_outcome.
  • The same outcome is never announced twice; a different outcome (a correction) is.
  • Accepting a case yourself does not send med.resolved — the med_case.updated with status: "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.

HeaderSchemeApplies to
X-Liqfy-Signaturet=<unix-seconds>,v1=<hex> — signed payload is "<t>.<rawBody>"Canonical events (charge.*, payout.*, med_case.*)
X-Liqfy-Signaturesha256=<hex> — signed payload is the raw body aloneLegacy events (kyc.*, med.resolved, and the account-lifecycle events above)
X-Liqfy-Delivery-IdOpaque id, stable across every retry of the same deliveryBoth
X-Liqfy-Event-TypeMirrors the delivered event nameBoth
X-Liqfy-Testtrue, only on a test delivery; absent on every real oneBoth
User-AgentLiqfy-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

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') });
  },
);

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. See 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

Resolution 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

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) — legacy scheme

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. Delivery guarantees, retries and DLQ

  • Success Any 2xx response 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, default 21_600_000). Due deliveries are picked up every 10 seconds.
  • Maximum attempts 15 (WEBHOOK_MAX_ATTEMPTS). After the last failed attempt the delivery moves to CANCELLED (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 url retries 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 new X-Liqfy-Delivery-Id; the canonical event's id (evt_…) stays the same.
  • 429 handling If your endpoint returns 429 with a Retry-After header (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-Id is 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's id / evt_…, which stays the same when a delivery moves to another endpoint).

#Idempotent handler pattern

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. Pull / reconciliation API

If a push was missed (your receiver was down for hours), pull the events you missed instead of losing them.

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="
}
  • Cursor pagination on (createdAt desc, id desc) — pass nextCursor back as cursor for the next page; treat it as an opaque token. nextCursor is null on the last page.
  • limit defaults 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).

bash
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 to charge.paid) — versioned evt_ envelope and timestamped t=,v1= signature. The object is a charge with id ch_test_…, amount: 10000, status: "paid", payment_method: "pix" and metadata.test: true.
  • legacy name — only when the endpoint subscribes to the retired name of charge.paid and not to charge.paid: {event,data} body and sha256= signature, with data.test: true and illustrative providerFee and netAmount, which no production delivery emits — do not code against them.

The response tells you what happened and what was sent:

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 (no answer in 10 seconds), connection (unreachable, refused, or a blocked URL), http (non-2xx answer) or null.
  • 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.paid with a new po_… 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: true never 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 2xx in under 5 seconds. Heavy work goes to a queue.
  • Idempotency on X-Liqfy-Delivery-Id (or evt_… for canonical events).
  • Unknown type/event names 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

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
}

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:

FieldMeaning
consecutiveFailuresFailed delivery attempts in a row, with no success in between
lastSuccessAtLast attempt answered with 2xx
lastStatusCodeHTTP status of the last attempt (null on a transport error)
failingSinceWhen the endpoint was first considered failing; null while healthy
failingtrue 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

bash
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

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

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

bash
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

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

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

Order of operations:

  1. Call rotate-secret → store the new secret alongside the old.
  2. Deploy your verifier with both secrets active.
  3. Soak for at least one minute (any in-flight retries clear).
  4. Remove the old secret on the next deploy.

#Update or delete an endpoint

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

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