Skip to content
LIQFYdocs
PTENENEN
Go to dashboard

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 PSPLiqfy
/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 eventoUna ruta, muchos eventos; el campo type (o el heredado event) los distingue

URL genérica recomendada:

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

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

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

#Reembolsos — ¿qué evento?

NecesidadEventoFamilia
Cobro pagadocharge.paidcanónico
Cobro fallido / expiradocharge.failed / charge.expiredcanónico
Reembolso de cobrocharge.refundedcanónico
Payout liquidado / fallidopayout.paid / payout.failedcanónico (no es un reembolso)
Disputa Pix (MED) abierta / modificada / resueltamed_case.created / med_case.updated / med.resolvedcanó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:

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

  1. Integrations → Webhooks → New endpoint
  2. URL HTTPS genérica (p. ej. …/hooks/liqfy)
  3. Marca como mínimo: charge.created, charge.paid, charge.failed, charge.expired (+ charge.refunded si necesitas reembolsos)
  4. Guarda el secret (se muestra una sola vez)
  5. 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:

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_…) 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.object es un objeto charge/payout/med_case pú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 que GET /v1/med/cases/{id};
    • charge.created es el payload de charge más completo: puede incluir customer (name, document, email, phone), pix (br_code, expires_at), metadata y created_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 de br_code o consulta el cobro. payment_method se rellena solo cuando el cobro acepta un único método;
    • charge.paid, charge.failed y charge.expired pueden incluir idempotency_key y metadata; la metadata usa el mismo filtro público que GET /v1/charges/:id. Ninguno de estos eventos de estado añade customer, pix ni created_at. Lee data.object.status: paid; en charge.failed, failed, cancelled, expired o disputed;
    • Los eventos terminales payout.paid / payout.failed pueden incluir idempotency_key, amount, currency, pix_key y outcome; payout.failed también puede incluir refunded y legs. No incluyen fee. El status público sigue siendo paid o failed. payout.paid puede conservar settlement: end_to_end_id en un tramo, o end_to_end_id: null con legs[] en varios tramos.
    • charge.paid también puede incluir payer y los payout.paid / payout.failed terminales pueden incluir beneficiary. Cada uno es { name, document } según lo informado por el proveedor de pago, y cualquiera de los dos valores puede ser null. El document va 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 el customer que enviaste al crear el cobro.
  • settlement.end_to_end_id se rellena solo cuando se conoce un id end-to-end de Pix válido — en la práctica en charge.paid y charge.refunded. charge.created siempre tiene settlement: {}.
  • 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):

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

EventoCuándo se dispara
charge.createdCobro creado (Pix/tarjeta generado, esperando pago).
charge.paidCobro pagado y confirmado (Pix/tarjeta liquidado).
charge.failedEl cobro falló o fue cancelado.
charge.expiredEl cobro expiró sin pago.
charge.refundedCobro 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.createdPayout solicitado.
payout.approvedPayout aprobado.
payout.paidPayout liquidado con éxito.
payout.failedEl payout falló o fue rechazado.
kyc.submittedKYC enviado para análisis.
kyc.approvedKYC aprobado.
kyc.rejectedKYC rechazado.
tenant.createdNueva cuenta creada.
tenant.updatedCuenta actualizada.
med.resolvedMED (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.createdCaso MED abierto contra uno de tus cobros — incluye el importe disputado y el plazo para impugnar.
med_case.updatedEl 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):

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

Ejemplo de payout.failed (tramo fallido sin reembolso):

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
        }
      ]
    }
  }
}
Campo en data.objectSignificado
idempotency_keyClave 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.

outcomestatus públicoEvento
completedpaidpayout.paid
failed, rejected, cancelledfailedpayout.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.failed solo 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 con payout.failed (reembolso registrado) o payout.paid (el PIX salió). Puedes reembolsar a tu usuario en cuanto llegue payout.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 evento payout.*. Consulta GET /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.paid Pix 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ía charge.refunded. Suscríbete en su lugar a los eventos MED; al volver a leer el cobro verás status: "disputed" mientras el caso esté abierto. Si un cambio de estado de disputa del cobro llega a las notificaciones del cobro, llega como charge.failed con data.object.status: "disputed" — nunca lo trates como un rechazo ni canceles el pedido por ello. Consulta Disputas y MED.

Un charge.paid tardío puede seguir a un charge.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, entrega charge.paid para ese mismo cobro. El charge.paid posterior es el estado final — trátalo como pagado, aunque antes hayas recibido charge.expired. Nunca asumas que charge.expired es terminal.

Una vez pagado, los fallos posteriores no se entregan. Tras charge.paid, un estado posterior failed / expired / cancelled del 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 a charge.failed recibe la expiración como charge.failed con data.object.status: "expired". No trates cada charge.failed como un rechazo — lee data.object.status para distinguirlos.

#2. Registra tu endpoint

Endpoint POST /v1/webhooks/endpoints

Headers

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

El header de autenticación canónico es Authorization: Bearer <key>. La forma heredada apikey: <key> sigue funcionando por compatibilidad, pero las integraciones nuevas deben usar Authorization: Bearer.

Body

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

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

Guarda el secret inmediatamente. 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

ReglaValor
Endpoints activos por cuenta10 con KYC aprobado, 2 sin él. Se comprueba al crear y al reactivar (PATCH con status: ACTIVE)
Entregas de prueba por hora por cuenta30 con KYC aprobado, 10 sin él
Estado de la cuenta no disponiblecuando Liqfy no puede confirmar el estado de la cuenta, se aplican los límites sin KYC
Formato de URLdebe empezar por http:// o https:// — 400 en caso contrario
Esquema de URLhttps:// fuera de SANDBOX/STAGING — 400 WEBHOOK_HTTPS_REQUIRED en caso contrario. Un endpoint http:// guardado antes de la regla sigue recibiendo entregas
Host de URLno puede ser el propio dominio de la plataforma — 400 WEBHOOK_OWN_DOMAIN
URL en el momento del envíodebe resolver a una dirección pública y no a la plataforma; de lo contrario el intento falla (SSRF_BLOCKED)
eventsarray no vacío de nombres conocidos — 400 con la lista de eventos válidos en caso contrario
URL duplicada en la cuenta409 WEBHOOK_ENDPOINT_DUPLICATE, con details.endpointId del endpoint existente — edita sus eventos en su lugar
Por encima del límite de endpoints409 WEBHOOK_ENDPOINT_LIMIT (details.limit); una reactivación por encima del límite se deshace
Por encima del límite de pruebas429 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ñauna sesión emitida antes del cambio de contraseña se rechaza — 401 Session no longer valid
Cuenta suspendida o bloqueadano 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.

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

EventoEnvelope / firmaCuándo se dispara
med_case.createdevt_ canónico / t=,v1=Se abre un caso MED contra uno de tus cobros.
med_case.updatedevt_ 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:

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

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

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 es el id del caso sin el prefijo med_ y transactionId es el id del cobro sin el prefijo ch_: med_ + infractionId es el id que usas en /v1/med/cases/{id}.
  • outcome es refunded_to_merchant (el importe retenido volvió a tu saldo), closed_without_refund (cerrado, el importe no volvió) o lost (el importe sigue debitado). Es el mismo valor que el money_outcome del 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 el med_case.updated con status: "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.

HeaderEsquemaSe aplica a
X-Liqfy-Signaturet=<unix-seconds>,v1=<hex> — el payload firmado es "<t>.<rawBody>"Eventos canónicos (charge.*, payout.*, med_case.*)
X-Liqfy-Signaturesha256=<hex> — el payload firmado es solo el body brutoEventos heredados (kyc.*, med.resolved y los eventos de ciclo de vida de la cuenta anteriores)
X-Liqfy-Delivery-IdId opaco, estable en todos los reintentos de la misma entregaAmbos
X-Liqfy-Event-TypeRefleja el nombre del evento entregadoAmbos
X-Liqfy-Testtrue, solo en una entrega de prueba; ausente en todas las realesAmbos
User-AgentLiqfy-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

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

¿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. Consulta 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

La 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

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) — esquema heredado

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. Garantías de entrega, reintentos y DLQ

  • Éxito Cualquier respuesta 2xx confirma 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 defecto 21_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 a CANCELLED (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 url de 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 nuevo X-Liqfy-Delivery-Id; el id (evt_…) del evento canónico se mantiene.
  • Manejo de 429 Si tu endpoint devuelve 429 con un header Retry-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-Id es 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 el id / evt_… del evento canónico, que se mantiene cuando una entrega se mueve a otro endpoint).

#Patrón de handler idempotente

js
async function handle({ deliveryId, eventId, type, data }) {
  // Atomic insert — fails if we've seen this delivery before
  const inserted = await db.processedWebhooks.insertIgnore({
    id: deliveryId ?? eventId,
    receivedAt: new Date(),
  });
  if (!inserted) return; // already handled

  if (type === 'charge.paid') {
    await orders.markPaid(data.object.id, data.object);
  }
}

#5. API 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.

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="
}
  • Paginación por cursor sobre (createdAt desc, id desc) — devuelve nextCursor como cursor para la siguiente página; trátalo como un token opaco. nextCursor es null en la última página.
  • limit vale 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).

bash
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 a charge.paid) — envelope versionado evt_ y firma t=,v1= con timestamp. El objeto es un charge con id ch_test_…, amount: 10000, status: "paid", payment_method: "pix" y metadata.test: true.
  • nombre antiguo — solo cuando el endpoint está suscrito al nombre antiguo de charge.paid y no a charge.paid: body {event,data} y firma sha256=, con data.test: true y providerFee y netAmount ilustrativos, que ninguna entrega de producción emite — no programes contra ellos.

La respuesta te indica qué ocurrió y qué se envió:

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 (sin respuesta en 10 segundos), connection (inaccesible, rechazado o una URL bloqueada), http (respuesta no 2xx) o null.
  • 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.paid con un nuevo id po_…). 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: true nunca 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 2xx en menos de 5 segundos. El trabajo pesado va a una cola.
  • Idempotencia por X-Liqfy-Delivery-Id (o evt_… para eventos canónicos).
  • Los nombres type/event desconocidos 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

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
}

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:

CampoSignificado
consecutiveFailuresIntentos de entrega fallidos consecutivos, sin ningún éxito entre ellos
lastSuccessAtEl último intento respondió con 2xx
lastStatusCodeEstado HTTP del último intento (null en un error de transporte)
failingSinceCuándo se consideró por primera vez que el endpoint estaba fallando; null mientras está sano
failingtrue 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

bash
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

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

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

bash
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

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

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

Orden de operaciones:

  1. Llama a rotate-secret → guarda el nuevo secret junto al antiguo.
  2. Despliega tu verificador con ambos secrets activos.
  3. Espera al menos un minuto (se completan los reintentos en curso).
  4. Elimina el secret antiguo en el siguiente despliegue.

#Actualizar o eliminar un 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 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

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