Webhooks
¿Migrando desde un evento heredado? Consulta la guía de migración.
Los webhooks son la forma en que Liqfy envía actualizaciones de estado a tu backend en tiempo real. Úsalos para el fulfillment — nunca dependas solo del polling. Si se pierde una entrega, usa la API de pull/reconciliación para ponerte al día.
#0. Empieza aquí (preguntas más frecuentes)
#¿Una URL o una URL por evento?
Una URL. En el panel (Integrations → Webhooks → New endpoint) registras un endpoint HTTPS y marcas varios eventos. Liqfy envía un POST independiente por cada ocurrencia, siempre a la misma URL.
| Algunos otros PSP | Liqfy |
|---|---|
/webhook/cashin, /webhook/cashout, /webhook/refund (ruta por tipo) | https://your-shop.com/hooks/liqfy + lista de eventos en el registro |
| Una ruta HTTP por evento | Una ruta, muchos eventos; el campo type (o el heredado event) los distingue |
URL genérica recomendada:
https://your-domain.com/hooks/liqfyEvita rutas como /api/charge/created salvo que tu router lo exija — la ruta no selecciona el evento; lo hacen las casillas del panel (o el array events en la API).
#¿Cómo se ve «todos los eventos en una URL»?
No recibes un payload con todos los tipos de evento. Recibes un POST por cada cambio de estado. Ramifica según el tipo:
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#Reembolsos — ¿qué evento?
| Necesidad | Evento | Familia |
|---|---|---|
| Cobro pagado | charge.paid | canónico |
| Cobro fallido / expirado | charge.failed / charge.expired | canónico |
| Reembolso de cobro | charge.refunded | canónico |
| Payout liquidado / fallido | payout.paid / payout.failed | canónico (no es un reembolso) |
| Disputa Pix (MED) abierta / modificada / resuelta | med_case.created / med_case.updated / med.resolved | canónico / canónico / envelope heredado (no es un reembolso) |
Suscríbete a charge.refunded en el mismo endpoint si necesitas reembolsos — se dispara en cada reembolso: los que emites tú (o el equipo de Liqfy, en tu nombre) y la devolución automática por CPF del pagador.
Un MED no es un reembolso: cuando el banco del pagador abre un MED, no se crea ningún reembolso y charge.refunded no se dispara — ni cuando el MED se abre ni cuando se gana o se pierde. Sigue las disputas con los eventos MED.
El objeto de reembolso incluye el importe reembolsado y su origen:
{
"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 es MERCHANT para un reembolso iniciado por el comerciante/admin o AUTOMATIC_PAYER_RESTRICTION para la devolución Pix automática disparada cuando el CPF/CNPJ de un pagador liquidado no coincidía con el pagador esperado del cobro. amount_refunded es el total reembolsado hasta el momento (igual a refund.amount para un único reembolso; mayor en reembolsos parciales). En este evento data.object.amount es el total original del cobro (amount_refunded es la parte reembolsada), y payment_method es siempre null (el evento de reembolso no incluye método de pago). La excepción son los eventos de reembolso emitidos antes de que existiera el total original: entregan amount: 0, así que consulta el cobro si necesitas el total original de un reembolso antiguo.
refund.reason está presente solo en reembolsos de origen comerciante (MERCHANT) — un reembolso automático nunca incluye motivo. refund.origin puede faltar en eventos emitidos antes de que existiera el campo.
#Varias cuentas / tiendas en la misma URL
Sin problema. Cada cuenta de Liqfy tiene sus propias claves y endpoints, pero la URL de tu servidor puede compartirse. Segmenta usando el payload (data.object.id = ch_…, metadata que defines al crear el cobro, etc.).
#Ruta en el panel (sin API)
- Integrations → Webhooks → New endpoint
- URL HTTPS genérica (p. ej.
…/hooks/liqfy) - Marca como mínimo:
charge.created,charge.paid,charge.failed,charge.expired(+charge.refundedsi necesitas reembolsos) - Guarda el secret (se muestra una sola vez)
- Haz clic en Test y confirma que tu servidor devolvió 2xx
#1. El envelope canónico del evento
Las integraciones nuevas se suscriben a los eventos canónicos (charge.*, payout.*, med_case.*). Cada entrega es un POST HTTPS a tu URL registrada con esta forma:
{
"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_…) es el id del evento de negocio — estable en todos los intentos de entrega y en todo el fan-out a endpoints. Deduplica por él.data.objectes un objetocharge/payout/med_casepúblico y en lista permitida (aquí no pueden aparecer nombre del proveedor, costo, secreto ni payload bruto del PSP), pero no siempre es la forma REST completa:med_case.*es la única paridad real — el mismo objeto queGET /v1/med/cases/{id};charge.createdes el payload dechargemás completo: puede incluircustomer(name,document,email,phone),pix(br_code,expires_at),metadataycreated_at. Provienen de una consulta best-effort — si falla, el evento sale en la forma reducida. No hay URL/base64 de imagen QR: genera el QR a partir debr_codeo consulta el cobro.payment_methodse rellena solo cuando el cobro acepta un único método;charge.paid,charge.failedycharge.expiredpueden incluiridempotency_keyymetadata; la metadata usa el mismo filtro público queGET /v1/charges/:id. Ninguno de estos eventos de estado añadecustomer,pixnicreated_at. Leedata.object.status:paid; encharge.failed,failed,cancelled,expiredodisputed;- Los eventos terminales
payout.paid/payout.failedpueden incluiridempotency_key,amount,currency,pix_keyyoutcome;payout.failedtambién puede incluirrefundedylegs. No incluyenfee. Elstatuspúblico sigue siendopaidofailed.payout.paidpuede conservarsettlement:end_to_end_iden un tramo, oend_to_end_id: nullconlegs[]en varios tramos. charge.paidtambién puede incluirpayery lospayout.paid/payout.failedterminales pueden incluirbeneficiary. Cada uno es{ name, document }según lo informado por el proveedor de pago, y cualquiera de los dos valores puede sernull. Eldocumentva siempre enmascarado (***.982.247-**para un CPF,**.***.111/0001-**para un CNPJ); el documento completo nunca se envía. El objeto completo se omite cuando el proveedor no informó ninguno de los dos valores. No es elcustomerque enviaste al crear el cobro.
settlement.end_to_end_idse rellena solo cuando se conoce un id end-to-end de Pix válido — en la práctica encharge.paidycharge.refunded.charge.createdsiempre tienesettlement: {}.- Maneja siempre con tolerancia los valores desconocidos de
type(200 OK+ ignorar) para que la incorporación de nuevos tipos de evento nunca te rompa.
#Catálogo de eventos
GET /v1/webhooks/event-catalog devuelve la lista viva y autoritativa. Requiere tu
clave de API como cualquier otra ruta bajo /v1/webhooks (solo las rutas de entrada de proveedores están
abiertas):
curl https://api.liqfy.com.br/v1/webhooks/event-catalog \
-H "Authorization: Bearer $LIQFY_API_KEY"La respuesta es un array de objetos { "event", "description" } (una entrada también puede incluir legacy: true):
| Evento | Cuándo se dispara |
|---|---|
charge.created | Cobro creado (Pix/tarjeta generado, esperando pago). |
charge.paid | Cobro pagado y confirmado (Pix/tarjeta liquidado). |
charge.failed | El cobro falló o fue cancelado. |
charge.expired | El cobro expiró sin pago. |
charge.refunded | Cobro reembolsado (total o parcial) — reembolso del comerciante o devolución automática (bloqueo de CPF). No se dispara para MED (usa med_case.* y med.resolved). |
payout.created | Payout solicitado. |
payout.approved | Payout aprobado. |
payout.paid | Payout liquidado con éxito. |
payout.failed | El payout falló o fue rechazado. |
kyc.submitted | KYC enviado para análisis. |
kyc.approved | KYC aprobado. |
kyc.rejected | KYC rechazado. |
tenant.created | Nueva cuenta creada. |
tenant.updated | Cuenta actualizada. |
med.resolved | MED (devolución solicitada por el pagador) resuelto — indica el destino del importe: devuelto al comerciante, cerrado sin devolución o cerrado como pérdida. |
med_case.created | Caso MED abierto contra uno de tus cobros — incluye el importe disputado y el plazo para impugnar. |
med_case.updated | El caso MED cambió — impugnado, aceptado, cerrado, evidencias solicitadas. |
Los eventos de KYC y de cuenta usan el envelope heredado — consulta Eventos de cuenta/KYC.
#Campos de payouts terminales
Los ejemplos muestran callbacks canónicos enriquecidos. Los eventos antiguos en cola, las entregas de prueba y un fallo en la consulta de detalles pueden omitir los nuevos campos; sigue aceptando el objeto reducido. El formato heredado {event,data} no cambia.
Ejemplo de payout.paid (settlement conserva su forma existente cuando está presente):
{
"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-**" }
}
}
}Ejemplo de payout.failed (tramo fallido sin reembolso):
{
"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
}
]
}
}
}Campo en data.object | Significado |
|---|---|
idempotency_key | Clave usada al crear el cobro o payout. |
metadata (charge.paid, charge.failed, charge.expired) | Metadata pública del comerciante, incluido order_id si se suministró; mismo filtro que GET /v1/charges/:id. |
amount, currency (payout.*) | Importe en la unidad mínima y moneda ISO 4217. |
pix_key (payout.*) | Clave Pix de destino; puede ser null. |
payer (charge.paid) | Pagador { name, document } según el proveedor; document siempre enmascarado; se omite si no se informa. |
beneficiary (payout.*) | Beneficiario { name, document } según el proveedor; document siempre enmascarado; se omite si no se informa. |
outcome (payout.*) | Resultado terminal interno en minúsculas, separado del status público. |
refunded (payout.failed) | Si el reembolso ya se registró al enviar el evento. |
legs[] (payout.failed) | index, amount, status en minúsculas, refunded y end_to_end_id solo para un tramo completado con E2E Pix válido. Puede ser [] si falló antes del despacho. |
Pagador y beneficiario son de mejor esfuerzo. Estos campos dependen de lo que el proveedor de pago informe en cada operación, por lo que pueden faltar (o traer solo uno de los dos valores) en algunos pagos. Úsalos solo como contexto adicional: no los exijas ni los uses para conciliar o aprobar nada.
outcome | status público | Evento |
|---|---|---|
completed | paid | payout.paid |
failed, rejected, cancelled | failed | payout.failed |
refunded refleja el momento del envío. Si el reembolso está retenido, payout.failed puede indicar false; liberarlo después no envía otro webhook. Consulta GET /v1/withdrawals/{id} para el estado actual. Si después se confirma que el payout sí se pagó, payout.paid llega tras el payout.failed, y el reembolso ya registrado, si lo hay, se revierte.
Solo fallos definitivos (configuración de la cuenta). Cuando está activada en tu cuenta,
payout.failedsolo se envía cuando el fallo es definitivo y el reembolso ya se registró. Mientras el reembolso está retenido para confirmarlo con el banco, no se envía ningún webhook; el payout termina conpayout.failed(reembolso registrado) opayout.paid(el PIX salió). Puedes reembolsar a tu usuario en cuanto lleguepayout.failed. Pide al soporte que la active.
refunded refleja el momento de entrega. Si el reembolso está retenido, payout.failed puede llevar false; liberarlo después no genera otro webhook. Consulta GET /v1/withdrawals/{id} para el estado actual.
#Por qué falló un payout
payout.failed incluye el motivo como código estable: data.object.failure_code
(en minúsculas, p. ej. destination_not_allowed, destination_unavailable, invalid_pix_key,
provider_rejected) más data.object.failure_message, una frase neutra para
ese código. Ambos aparecen solo cuando el fallo corresponde a un código de la tabla pública; de lo contrario
el payout no incluye ninguno.
Un payout de varios tramos que liquida solo algunos tramos no envía webhook. Cuando un payout dividido termina parcialmente (estado interno
PARTIALLY_COMPLETED), no se entrega ningún eventopayout.*. ConsultaGET /v1/withdrawals/by-idempotency-key/{key}para conciliarlo.
Retención end-to-end (configuración de la cuenta). Si está activada para tu cuenta, un
charge.paid/payout.paidPix sin E2E se retiene mientras Liqfy lo confirma. El evento se entrega con los E2E confirmados; los eventos posteriores del mismo cobro o payout esperan detrás. Si la confirmación no llega en 30 minutos, se genera una alerta operativa, pero las nuevas retenciones siguen esperando. Sin esta opción, el callback puede llegar sin E2E. Las entregas de prueba nunca se retienen.
Las disputas (MED) se siguen con los eventos MED, no con
charge.*— el cobro fue pagado, y un MED no es ni un rechazo ni un reembolso, por lo que no se envíacharge.refunded. Suscríbete en su lugar a los eventos MED; al volver a leer el cobro verásstatus: "disputed"mientras el caso esté abierto. Si un cambio de estado de disputa del cobro llega a las notificaciones del cobro, llega comocharge.failedcondata.object.status: "disputed"— nunca lo trates como un rechazo ni canceles el pedido por ello. Consulta Disputas y MED.
Un
charge.paidtardío puede seguir a uncharge.expired. Si el adquirente confirma un pago solo después de que el cobro ya expiró (webhook retrasado / API de estado con retraso), Liqfy verifica el pago y, una vez confirmado, entregacharge.paidpara ese mismo cobro. Elcharge.paidposterior es el estado final — trátalo como pagado, aunque antes hayas recibidocharge.expired. Nunca asumas quecharge.expiredes terminal.
Una vez pagado, los fallos posteriores no se entregan. Tras
charge.paid, un estado posteriorfailed/expired/cancelleddel mismo cobro se descarta; tras un reembolso total, no se entrega ningún otro estado de ese cobro. Una reentrega del mismo evento de origen también se descarta.
Una expiración también llega a los suscriptores de
charge.failed. Cuando un cobro expira, el evento se distribuye a ambos nombres: un endpoint suscrito solo acharge.failedrecibe la expiración comocharge.failedcondata.object.status: "expired". No trates cadacharge.failedcomo un rechazo — leedata.object.statuspara distinguirlos.
#2. Registra tu endpoint
Endpoint POST /v1/webhooks/endpoints
Headers
Authorization: Bearer lq_live_...
Content-Type: application/jsonEl header de autenticación canónico es
Authorization: Bearer <key>. La forma heredadaapikey: <key>sigue funcionando por compatibilidad, pero las integraciones nuevas deben usarAuthorization: Bearer.
Body
{
"url": "https://merchant.example.com/hooks/liqfy",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Respuesta 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"
}Guarda el
secretinmediatamente. Se muestra solo en el momento de la creación y es necesario para verificar cada webhook entrante. Nunca volvemos a mostrarlo.
#Límites y permisos
| Regla | Valor |
|---|---|
| Endpoints activos por cuenta | 10 con KYC aprobado, 2 sin él. Se comprueba al crear y al reactivar (PATCH con status: ACTIVE) |
| Entregas de prueba por hora por cuenta | 30 con KYC aprobado, 10 sin él |
| Estado de la cuenta no disponible | cuando Liqfy no puede confirmar el estado de la cuenta, se aplican los límites sin KYC |
| Formato de URL | debe empezar por http:// o https:// — 400 en caso contrario |
| Esquema de URL | https:// fuera de SANDBOX/STAGING — 400 WEBHOOK_HTTPS_REQUIRED en caso contrario. Un endpoint http:// guardado antes de la regla sigue recibiendo entregas |
| Host de URL | no puede ser el propio dominio de la plataforma — 400 WEBHOOK_OWN_DOMAIN |
| URL en el momento del envío | debe resolver a una dirección pública y no a la plataforma; de lo contrario el intento falla (SSRF_BLOCKED) |
events | array no vacío de nombres conocidos — 400 con la lista de eventos válidos en caso contrario |
| URL duplicada en la cuenta | 409 WEBHOOK_ENDPOINT_DUPLICATE, con details.endpointId del endpoint existente — edita sus eventos en su lugar |
| Por encima del límite de endpoints | 409 WEBHOOK_ENDPOINT_LIMIT (details.limit); una reactivación por encima del límite se deshace |
| Por encima del límite de pruebas | 429 WEBHOOK_TEST_RATE_LIMIT (details.limit) |
| Escrituras (create/update/delete/rotate-secret/test/replay) | requieren una sesión del panel OWNER o ADMIN; la suplantación de solo lectura se rechaza (403 WEBHOOK_ROLE_REQUIRED / IMPERSONATION_VIEW_ONLY). El rol se lee del token de sesión, así que un cambio de rol surte efecto cuando ese token expira. Una clave de API escribe como la propia cuenta. |
| Sesión del panel tras un cambio de contraseña | una sesión emitida antes del cambio de contraseña se rechaza — 401 Session no longer valid |
| Cuenta suspendida o bloqueada | no puede crear, editar ni probar endpoints — 403 TENANT_NOT_ACTIVE |
#Suscripciones canónicas
Las suscripciones de cobros y payouts usan solo los nombres públicos canónicos. Añadir nombres retirados se rechaza al crear/actualizar con HTTP 400, código LEGACY_WEBHOOK_EVENT, y un par event / replacement. Las suscripciones existentes siguen la política de compatibilidad de la plataforma; las actualizaciones pueden conservar los nombres ya suscritos.
{
"code": "LEGACY_WEBHOOK_EVENT",
"event": "<retired name>",
"replacement": "charge.paid",
"message": "Event <retired name> was retired; use charge.paid."
}Un endpoint suscrito tanto a un nombre canónico como a su nombre retirado recibe una entrega por ocurrencia, bajo el nombre canónico. Los nombres brutos payment.status_changed y withdrawal.status_changed siguen aceptándose al crear/actualizar y se expanden a charge.paid + charge.failed y payout.paid + payout.failed.
payout.approved informa la aprobación, antes de la liquidación. Su envelope versionado contiene data.object con object: "payout", un id po_ y status: "processing". Usa la firma HMAC con timestamp, como los demás eventos de payout. Su id de evento es estable por payout, incluidas las republicaciones.
#Eventos de cuenta/KYC (solo envelope heredado)
kyc.submitted, kyc.approved, kyc.rejected y el par de ciclo de vida de la cuenta tenant.created / tenant.updated también se disparan y siempre usan el envelope heredado { event, data } (no son charge.*/payout.*, por lo que nunca reciben el wrapper canónico evt_ ni la firma t=,v1=).
En kyc.*, data incluye documentId y documentType cuando se conocen, status (PENDING en kyc.submitted, APPROVED en kyc.approved, REJECTED en kyc.rejected), reason en kyc.rejected cuando existe, y occurredAt.
⚠️ Brecha de nomenclatura conocida: los eventos de ciclo de vida de la cuenta emiten actualmente un nombre de campo interno tal cual en su payload — una violación del glosario registrada, no una parte intencional del contrato público. Consulta la brecha conocida y seguimientos de la guía de migración para los nombres exactos del evento y del campo. No construyas una integración permanente sobre ese nombre de campo.
#Eventos MED
Tres eventos siguen una disputa Pix (MED) — el flujo completo está en Disputas y MED:
| Evento | Envelope / firma | Cuándo se dispara |
|---|---|---|
med_case.created | evt_ canónico / t=,v1= | Se abre un caso MED contra uno de tus cobros. |
med_case.updated | evt_ canónico / t=,v1= | El caso cambia de estado (appealed, accepted, rejected, closed) o la plataforma te pide evidencias. |
med.resolved | {event,data} heredado / sha256= | El caso llega a su resultado; indica adónde fue el dinero. |
med_case.created / med_case.updated incluyen el mismo objeto med_case que
GET /v1/med/cases/{id} devuelve, en 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"
}
}
}Un med_case.updated tiene la misma forma, con type: "med_case.updated" y el caso tal como
queda tras el cambio (por ejemplo status: "closed" y money_outcome: "refunded_to_merchant").
En el objeto med_case, status es uno de open, appealed, accepted, rejected,
closed; type es med, chargeback, dispute o fraud; money_outcome es null o
uno de refunded_to_merchant, closed_without_refund, lost.
med.resolved usa el body heredado {event, data} y la firma sha256=:
{
"event": "med.resolved",
"data": {
"infractionId": "3f1c2b9e-0d7a-4a55-9a51-6f0e3c1d2b10",
"transactionId": "6b0f4c1e-2a8d-4c1b-9d0e-1f2a3b4c5d6e",
"amount": 15000,
"currency": "BRL",
"outcome": "closed_without_refund",
"occurredAt": "2026-09-11T14:32:07.000Z"
}
}infractionIdes el id del caso sin el prefijomed_ytransactionIdes el id del cobro sin el prefijoch_:med_+infractionIdes elidque usas en/v1/med/cases/{id}.outcomeesrefunded_to_merchant(el importe retenido volvió a tu saldo),closed_without_refund(cerrado, el importe no volvió) olost(el importe sigue debitado). Es el mismo valor que elmoney_outcomedel caso.- El mismo resultado nunca se anuncia dos veces; un resultado diferente (una corrección) sí.
- Aceptar un caso tú mismo no envía
med.resolved— te lo indica elmed_case.updatedconstatus: "accepted".
Las notificaciones de resultado (med.resolved, y med_case.updated para accepted / rejected /
closed) pueden no llegar: un evento MED que no se logra publicar no se reintenta (la entrega a tu endpoint, una vez publicado, sigue el
calendario de reintentos normal). Trata estos eventos como una
señal y concilia con GET /v1/med/cases — la API es la fuente de verdad.
#3. Verifica la firma
Cada entrega se firma con HMAC-SHA256 sobre el body bruto de la solicitud usando el secret de tu endpoint. Hay dos esquemas, elegidos automáticamente según la familia del evento — tu verificador debe admitir ambos si tienes alguna suscripción heredada activa.
| Header | Esquema | Se aplica a |
|---|---|---|
X-Liqfy-Signature | t=<unix-seconds>,v1=<hex> — el payload firmado es "<t>.<rawBody>" | Eventos canónicos (charge.*, payout.*, med_case.*) |
X-Liqfy-Signature | sha256=<hex> — el payload firmado es solo el body bruto | Eventos heredados (kyc.*, med.resolved y los eventos de ciclo de vida de la cuenta anteriores) |
X-Liqfy-Delivery-Id | Id opaco, estable en todos los reintentos de la misma entrega | Ambos |
X-Liqfy-Event-Type | Refleja el nombre del evento entregado | Ambos |
X-Liqfy-Test | true, solo en una entrega de prueba; ausente en todas las reales | Ambos |
User-Agent | Liqfy-Webhooks/1.0 (los espacios del nombre se convierten en guiones) | Ambos |
El timestamp canónico se genera en el momento del envío, por lo que t (y la firma) cambia en cada reintento mientras el body permanece idéntico byte a byte. Durante una migración de marca, también pueden enviarse en paralelo nombres de header antiguos, con los mismos valores.
El esquema canónico incluye un timestamp en el material firmado precisamente para que puedas rechazar replays fuera de una ventana de tolerancia (recomendado: 5 minutos) — el esquema heredado no tiene timestamp y no puede hacerlo.
#Node.js (Express) — esquema canónico
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') });
},
);¿Prefieres no implementarlo a mano? Los SDK oficiales verifican ambos esquemas (canónico y heredado) por ti. El punto de entrada recomendado recibe el mapa de headers: el SDK elige el header de firma por nombre (una opción), o detecta automáticamente el único header cuyo nombre termina en
-signature— que es exactamente el header que usan las entregas de tu marca. Consultapackages/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 ListWebhookEventsQueryLa resolución es idéntica en los cuatro: un signatureHeader explícito (coincidencia de nombre,
sin distinguir mayúsculas de minúsculas) tiene prioridad; sin él, el SDK usa el único header cuyo nombre
en minúsculas termina en -signature, y falla cuando hay cero o dos headers así — nunca
adivina. Una cadena de firma simple sigue funcionando y no necesita nombre de header.
#PHP — eventos de KYC y de cuenta sin versión
$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) — esquema heredado
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. Garantías de entrega, reintentos y DLQ
- Éxito Cualquier respuesta
2xxconfirma la entrega y detiene los reintentos. - Timeout 30 segundos (10 para una entrega de prueba). Una respuesta más lenta cuenta como fallo.
- Calendario de reintentos Backoff exponencial (
1s × 2^attempt), con un máximo de 6 horas entre intentos (WEBHOOK_BACKOFF_CAP_MS, por defecto21_600_000). Las entregas pendientes se recogen cada 10 segundos. - Máximo de intentos 15 (
WEBHOOK_MAX_ATTEMPTS). Tras el último intento fallido la entrega pasa aCANCELLED(dead-lettered) — no se elimina y puede reproducirse manualmente. - Cambiar tu endpoint Las entregas fallidas siguen al endpoint que tienes registrado ahora. Añade un endpoint (o reactiva uno) y las entregas que están fallando en tus otros endpoints se mueven a él, cuando está suscrito al mismo nombre de evento. Desactiva o elimina un endpoint, o deja de suscribirte a un evento, y lo que todavía estaba en cola para él se mueve a tus otros endpoints activos suscritos al mismo evento — o se cancela si no hay ninguno. Cambiar el
urlde un endpoint reintenta de inmediato sus entregas fallidas, con un nuevo presupuesto de intentos. Solo se mueven o reinician las entregas creadas en los últimos 7 días. Una entrega movida recibe un nuevoX-Liqfy-Delivery-Id; elid(evt_…) del evento canónico se mantiene. - Manejo de 429 Si tu endpoint devuelve
429con un headerRetry-After(segundos) o un body JSON{ "retry_after": <seconds> }, ese valor (el mayor de los dos) se respeta (con un máximo de 5 minutos) en lugar del backoff exponencial ciego. - Estabilidad del id de entrega
X-Liqfy-Delivery-Ides idéntico en todos los intentos de la misma entrega — úsalo como tu clave principal de deduplicación.
⚠️ Una entrega puede llegar más de una vez. Haz siempre tu handler idempotente deduplicando por
X-Liqfy-Delivery-Id(o por elid/evt_…del evento canónico, que se mantiene cuando una entrega se mueve a otro endpoint).
#Patrón de handler idempotente
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 de pull / reconciliación
Si se perdió un push (tu receptor estuvo caído durante horas), obtén mediante pull los eventos que te perdiste en lugar de perderlos.
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="
}- Paginación por cursor sobre
(createdAt desc, id desc)— devuelvenextCursorcomocursorpara la siguiente página; trátalo como un token opaco.nextCursoresnullen la última página. limitvale 50 por defecto, máximo 100.- Filtros:
since,until(ISO 8601),status,eventType. - Cada elemento es una entrega: un evento enviado a dos de tus endpoints aparece dos veces. Deduplica por el
id(evt_…) del payload para los eventos canónicos. - Limitado estrictamente a tus propios endpoints — nunca devuelve entregas internas de la plataforma.
#6. Probar un endpoint
POST /v1/webhooks/endpoints/:id/test envía una entrega de muestra síncrona — sin registro de entrega, sin reintentos — para que puedas comprobar el estado, la latencia y el manejo de la firma. Requiere el mismo permiso que las demás escrituras y cuenta para el límite horario de pruebas (consulta Límites y permisos).
curl -X POST "https://api.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "Authorization: Bearer $LIQFY_API_KEY"La muestra sigue aquello a lo que está suscrito el endpoint:
charge.paid(por defecto, y siempre que el endpoint esté suscrito acharge.paid) — envelope versionadoevt_y firmat=,v1=con timestamp. El objeto es unchargecon idch_test_…,amount: 10000,status: "paid",payment_method: "pix"ymetadata.test: true.- nombre antiguo — solo cuando el endpoint está suscrito al nombre antiguo de
charge.paidy no acharge.paid: body{event,data}y firmasha256=, condata.test: trueyproviderFeeynetAmountilustrativos, que ninguna entrega de producción emite — no programes contra ellos.
La respuesta te indica qué ocurrió y qué se envió:
{
"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(sin respuesta en 10 segundos),connection(inaccesible, rechazado o una URL bloqueada),http(respuesta no 2xx) onull.responseSnippet: los primeros 500 caracteres del body de tu respuesta, solo en 2xx; ante un error solo recibes el estado y el mensaje.
#Entregas de prueba
Algunas entregas son pruebas, no eventos de negocio:
- el botón Test anterior;
- el pago de un cobro de prueba (creado con una clave
lq_test_o en el Test environment del panel); - el payout simulado del Test environment, que envía el webhook de un payout completado que nunca existió (
payout.paidcon un nuevo idpo_…). Tiene su propio límite horario, del mismo tamaño que el límite de pruebas, contado por entrega (cada endpoint suscrito cuenta uno).
Las entregas de prueba incluyen X-Liqfy-Test: true. El botón Test también marca metadata.test: true en el objeto del cobro (data.test: true en la muestra heredada); el cobro de prueba pagado y el payout simulado entregan el mismo body que el evento de negocio correspondiente. El charge.created de un cobro de prueba se marca solo cuando la plataforma pudo leer los detalles del cobro — si esa consulta falla, sale sin el header. Nunca completes un pedido ni acredites a un cliente a partir de una entrega de prueba.
#7. Checklist de producción
- El endpoint es HTTPS con un certificado TLS válido.
- La firma se verifica sobre el body bruto, antes del parseo JSON.
- Las entregas con
X-Liqfy-Test: truenunca completan un pedido. - La comparación usa tiempo constante (p. ej.
timingSafeEqual/hash_equals/hmac.compare_digest). - Tu verificador admite ambos esquemas de firma si algún endpoint sigue suscrito a un evento heredado.
- El handler responde
2xxen menos de 5 segundos. El trabajo pesado va a una cola. - Idempotencia por
X-Liqfy-Delivery-Id(oevt_…para eventos canónicos). - Los nombres
type/eventdesconocidos se ignoran con tolerancia (200 OK, sin error). - El secret se carga desde un gestor de secretos — nunca se commitea.
- Se dispara una alerta si no se recibe ningún webhook dentro de una ventana esperada; usa la API de pull como respaldo.
#8. Operaciones
#Listar 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
}Las filas nunca incluyen el secret. access.canManage indica si puedes escribir (crear,
actualizar, eliminar, rotar, probar, reproducir) desde este llamador; httpsOnly indica si el
entorno exige https:// en las URL de los endpoints.
#Salud del endpoint
Cada fila de endpoint también incluye su salud de entrega:
| Campo | Significado |
|---|---|
consecutiveFailures | Intentos de entrega fallidos consecutivos, sin ningún éxito entre ellos |
lastSuccessAt | El último intento respondió con 2xx |
lastStatusCode | Estado HTTP del último intento (null en un error de transporte) |
failingSince | Cuándo se consideró por primera vez que el endpoint estaba fallando; null mientras está sano |
failing | true mientras failingSince esté definido |
Un endpoint pasa a estar fallando cuando alcanza 20 intentos fallidos consecutivos (por defecto), o cuando recibió intentos en la última hora y ninguno tuvo éxito. Sigue fallando — aunque el tráfico se detenga — hasta que un intento tenga éxito. Mientras está fallando, se notifica a la cuenta por correo electrónico y en el panel como máximo una vez cada 6 horas. La salud solo observa: nunca detiene, retrasa ni modifica una entrega.
#Listar entregas recientes
curl "https://api.liqfy.com.br/v1/webhooks/deliveries?limit=25" \
-H "Authorization: Bearer $LIQFY_API_KEY"La respuesta es { data, total, limit, offset }. Cada entrada incluye el número de intentos
(attempts / maxAttempts), el último código de estado, el payload entregado, el texto de lastError,
nextRetryAt, deliveredAt y, para una entrega movida a otro endpoint,
cancelReason / supersededById. El último body de respuesta no está aquí — solo
está en el detalle de la entrega. Filtros opcionales: status,
eventType, endpointId, limit (por defecto 25, máximo 100), offset.
#Inspeccionar una entrega
curl "https://api.liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>" \
-H "Authorization: Bearer $LIQFY_API_KEY"Devuelve el registro completo de la entrega — incluidos lastResponseBody, paymentMethod y
test — más el registro de intentos y el endpoint al que pertenece:
{
"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 lista todos los intentos en orden, cada uno con la URL usada en ese momento. Una entrega que se movió a otro endpoint incluye
cancelReason (ENDPOINT_REPLACED, ENDPOINT_INACTIVE o ENDPOINT_DELETED) y, cuando
fue reemplazada, supersededById. Un id desconocido devuelve 404.
#Reproducir una entrega fallida o cancelada
curl -X POST "https://api.liqfy.com.br/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "Authorization: Bearer $LIQFY_API_KEY"Restablece el contador de intentos y la vuelve a encolar de inmediato; responde { "id", "status": "PENDING", "attempts": 0, "nextRetryAt" }. El replay masivo está disponible en POST /v1/webhooks/deliveries/replay-bulk con un filtro opcional { status, endpointId, limit } (por defecto limit 50, máximo 500; su status por defecto es FAILED + CANCELLED) y responde { "requeued": <count> }.
La semántica de los filtros difiere entre las lecturas y el replay masivo. GET /deliveries y
GET /events ignoran silenciosamente un status no válido, una fecha no válida o un
cursor mal formado (obtienes una página sin filtrar). POST /deliveries/replay-bulk, en cambio, responde
400 ante un status desconocido. Problema conocido (documentado tal cual): el replay masivo también acepta
status: DELIVERED o PROCESSING y vuelve a encolar esas entregas, mientras que el replay
individual anterior rechaza ambos con 400.
#Rotar un secret
curl -X POST "https://api.liqfy.com.br/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "Authorization: Bearer $LIQFY_API_KEY"El nuevo secret se devuelve una sola vez, como { "id": "…", "secret": "…" }.
La rotación es un intercambio instantáneo en el servidor — cada webhook que Liqfy firma después de una llamada exitosa a rotate-secret usa el nuevo secret. No hay ventana de solapamiento en el servidor.
Para rotar sin perder eventos, tu verificador debe aceptar temporalmente ambos, el secret antiguo y el nuevo, durante tu despliegue:
// 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();Orden de operaciones:
- Llama a rotate-secret → guarda el nuevo secret junto al antiguo.
- Despliega tu verificador con ambos secrets activos.
- Espera al menos un minuto (se completan los reintentos en curso).
- Elimina el secret antiguo en el siguiente despliegue.
#Actualizar o eliminar un 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 actualiza solo los campos que envías (url, events, status: ACTIVE/INACTIVE), responde { id, url, events, status } y nunca devuelve el secret. Responde 404 para un endpoint desconocido, 400 cuando no se envía ningún campo o status no es ACTIVE/INACTIVE, y 409 WEBHOOK_ENDPOINT_LIMIT cuando una reactivación supera el límite (la edición se deshace). DELETE responde { "deleted": true } y desactiva el endpoint definitivamente: sale de tu lista y no recibe nada más, mientras se conserva su historial de entregas. Las entregas aún en cola para él se mueven a tus otros endpoints activos suscritos al mismo evento — consulta Garantías de entrega.
#Estadísticas
curl "https://api.liqfy.com.br/v1/webhooks/stats" \
-H "Authorization: Bearer $LIQFY_API_KEY"Devuelve los recuentos de entregas por estado (PENDING, PROCESSING, DELIVERED, FAILED, CANCELLED) más total.
#Preguntas frecuentes
P: ¿Puedo tener varios endpoints? R: Sí, hasta el límite de endpoints activos de tu cuenta (10 con KYC aprobado, 2 sin él — consulta Límites y permisos) — útil para separar staging, producción y destinos de observabilidad. Cada URL puede registrarse solo una vez por cuenta.
P: ¿Un endpoint puede mezclar eventos canónicos y heredados?
R: Sí. events en un único endpoint puede incluir ambas familias (p. ej. ["charge.paid", "kyc.approved"]); cada evento entregado usa el esquema de envelope/firma que corresponde a su propio nombre.
P: ¿Qué ocurre si mi endpoint está caído durante horas?
R: La entrega se sigue reintentando hasta 15 intentos, con backoff de hasta 6 horas. Después pasa a CANCELLED, sin eliminarse — puedes reproducirla manualmente u obtenerlo todo mediante la API de pull.
Suscripciones de webhook retiradas Los nombres retirados de cobros y payouts no pueden añadirse a las suscripciones. Las suscripciones existentes conservan su formato de mensaje y su firma. El panel muestra los nombres retirados guardados con un aviso de eliminación y su sustituto; los comerciantes pueden eliminarlos y seleccionar un nombre canónico. Los nombres canónicos usan el envelope versionado y la firma con timestamp.
Consulta identificadores de liquidación y consulta para las garantías y la retención de E2E.