VerifikaDocs
Guías

Eventos de webhook

El catálogo completo, qué dispara cada evento y qué JSON llega, con ejemplos reales.

Esta es la referencia de qué te llega. Cómo se crea un webhook, cómo se verifica la firma y qué pasa cuando tu servidor falla está en Webhooks.

El catálogo vivo lo devuelve GET /v1/webhook-events, con allowed según tu plan. Esta página es el mismo catálogo con el cuerpo de cada uno.

El sobre es siempre igual

Cambie el evento que cambie, la forma exterior no se mueve. Lo único que cambia es data.object.

{
  "id": "evt_cms9qvef700013jw1qawn586g",
  "type": "income.verified",
  "created": "2026-09-06T15:04:05.000Z",
  "apiVersion": "v1",
  "companyId": "cmt7xbfrg0001q3w1itst7uwo",
  "livemode": true,
  "data": {
    "object": { "…": "el recurso, tal como lo devuelve su GET en /v1" }
  }
}
CampoQué es
idId del evento, no de la entrega. Es con el que deduplicas.
typeLa clave del catálogo.
createdCuándo ocurrió el hecho, en UTC. No cuándo te lo mandamos.
apiVersionHoy siempre v1.
companyIdSobre qué negocio. Importa si un mismo endpoint atiende a varios.
livemodefalse = lo provocó una llave de prueba (solo llega a los webhooks con «Recibir también eventos de prueba»). También va en la cabecera Verifika-Livemode.
data.objectEl recurso. Su campo object dice de qué tipo es.

El payload está congelado

data.object es una foto del recurso en el momento del hecho. Si el pago cambia mañana, la entrega de hoy sigue contando lo de hoy — también cuando la reintentamos dos días después. Para el estado actual, pide el GET.

✅ Verificaciones por API

Los eventos de las verificaciones que hiciste con POST /v1/verifications y quedaron pendientes. Son los que necesita un bot para avisarle a su cliente minutos después: traen la verificación entera, con tu metadata, tu reporter y el message nuevo para quien pagó. El caso completo está en Cuando el comprobante llega antes que el banco.

EventoCuándo se disparadata.object
verification.verifiedUna verificación pendiente se confirmó: llegó el aviso del banco, o una persona del negocio la aprobó (también una que había quedado NOT_FOUND).verification + previousStatus + previousIncomeStatus
verification.not_foundVenció la espera (nextStep.until) sin aviso del banco.verification + previousStatus + previousIncomeStatus
verification.not_found
{
  "id": "evt_cmuh4a8b10043x8w1q6r7s8tu",
  "type": "verification.not_found",
  "created": "2026-10-12T20:35:00.000Z",
  "apiVersion": "v1",
  "companyId": "cmt7xbfrg0001q3w1itst7uwo",
  "livemode": true,
  "data": {
    "object": {
      "object": "verification",
      "id": "vrf_3f9a1c0e7b2d4e8fa61c5d90b7e24a13",
      "livemode": true,
      "status": "NOT_FOUND",
      "code": "NOT_FOUND_IN_BANK",
      "message": "No encontramos en el banco el pago por $85.000 de tu comprobante. Si ya pagaste, comunícate con el negocio para revisarlo.",
      "mode": "IMAGE",
      "previousStatus": "PENDING",
      "previousIncomeStatus": "PENDING",
      "createdAt": "2026-10-12T20:00:06.000Z",
      "resolvedAt": "2026-10-12T20:35:00.000Z",
      "extracted": { "isReceipt": true, "amount": 85000, "reference": "M48213907", "bankLabel": "Nequi", "…": "…" },
      "income": { "object": "income", "id": "cmuh2k7v40031x8w1d9q2n5rt", "status": "NOT_FOUND", "…": "…" },
      "reporter": { "phone": "+573104567890", "name": "Laura Restrepo", "externalId": "wa_573104567890" },
      "metadata": { "chatId": "wa_573104567890", "pedido": "PED-10293" },
      "nextStep": null,
      "warnings": []
    }
  }
}

data.object es la misma forma de GET /v1/verifications/{id}, más previousStatus (el de la verificación) y previousIncomeStatus (el del ingreso). Las verificaciones que se resuelven en la misma llamada (VERIFIED, DUPLICATE, REJECTED) no emiten nada: ya tienes el veredicto en la respuesta.

💵 Ingresos

EventoCuándo se disparadata.object
income.createdSe registró un pago, venga del bot, de la app, del banco o de la APIincome
income.verifiedEl banco confirmó el pago. El que casi todos quierenincome + previousStatus
income.status_changedPasó a verificado, a duplicado o a no encontrado (venció la espera y el banco nunca lo respaldó). Ver el caso completoincome + previousStatus
income.updatedAlguien corrigió un pago declarado a manoincome
income.deletedSe borró un pago declarado a manolápida

Al verificarse llegan DOS eventos

income.status_changed e income.verified, con el mismo cuerpo. No es redundancia: si solo existiera el genérico, quien únicamente quiere enterarse de los pagos buenos tendría que suscribirlo y filtrar dentro de su handler — y tragarse un evento por cada duplicado y cada no-encontrado del negocio. Suscribe uno de los dos, no los dos.

income.verified
{
  "id": "evt_cms9qvef700013jw1qawn586g",
  "type": "income.verified",
  "created": "2026-09-06T15:04:05.000Z",
  "apiVersion": "v1",
  "companyId": "cmt7xbfrg0001q3w1itst7uwo",
  "data": {
    "object": {
      "object": "income",
      "id": "cmtmf1k8m006faqw1lh27ozcr",
      "amount": 45000,
      "currency": "COP",
      "status": "VERIFIED",
      "previousStatus": "PENDING",
      "origin": "WHATSAPP",
      "occurredAt": "2026-09-06T15:02:41.000Z",
      "createdAt": "2026-09-06T15:03:10.000Z",
      "verifiedAt": "2026-09-06T15:04:05.000Z",
      "reference": "M1A2B3C4",
      "customerName": "Laura Restrepo",
      "bankLabel": "Nequi",
      "accountLabel": "Nequi ··3421",
      "duplicateOfId": null,
      "branch": { "id": "cmt7xc1200003q3w1r0mfa9wq", "name": "Sede norte" },
      "confirmedBy": {
        "id": "cmt7xbz9y0002q3w1c8i2y0hb",
        "name": "Andrés Gómez",
        "phone": "+573001112233"
      },
      "attachments": [
        {
          "id": "cmtmf1k8m006gaqw1t0v2b3ln",
          "contentType": "image/jpeg",
          "originalName": "comprobante.jpg",
          "size": 184320
        }
      ],
      "bankChannels": ["EMAIL"]
    }
  }
}
Campo del incomeQué es
amountPesos, como número con hasta 2 decimales: 45000 son $45.000; 146357.19, $146.357,19. Sin centavos no trae decimales.
statusPENDING, VERIFIED, DUPLICATE, NOT_FOUND o DECLARED.
previousStatusSolo en status_changed y verified. De dónde venía.
originPor dónde entró: WHATSAPP (el bot de Verifika), APP, API (tu llave), BANK (lo detectó el banco antes que el comprobante), DECLARED (anotado a mano), AI_ASSISTANT, RECEIVABLE (un abono de cuentas por cobrar)… Trata cualquier valor nuevo como desconocido, sin romperte.
occurredAtCuándo se hizo el pago. createdAt es cuándo se registró aquí.
referenceLa referencia del comprobante. Es lo que cruza con el aviso del banco.
duplicateOfIdCon status: "DUPLICATE", el id del pago original.
confirmedByQuién lo recibió. phone viene del bot de WhatsApp.
bankChannelsPor qué canales lo respaldó el banco (EMAIL, SMS, PUSH). Vacío = todavía ninguno.
income.deleted — la lápida
{
  "object": "income",
  "id": "cmtmf1k8m006faqw1lh27ozcr",
  "deleted": true
}

Un borrado manda solo el id, no el recurso entero. El recurso ya no existe, y devolver su última foto invitaría a guardarla como si siguiera viva.

🧾 Egresos

EventoCuándodata.object
expense.createdSe registró un gastoexpense
expense.updatedCambiaron sus datosexpense
expense.deletedSe borrólápida
expense.created
{
  "object": "expense",
  "id": "cmtmg44p1008kaqw1zx0f2q7d",
  "amount": 120000,
  "currency": "COP",
  "paidTo": "Harina La Espiga",
  "occurredAt": "2026-09-06T13:40:00.000Z",
  "createdAt": "2026-09-06T13:41:22.000Z",
  "accountLabel": "Bancolombia ··8890",
  "paymentMethod": "TRANSFER",
  "notes": "Pedido semanal",
  "source": "API",
  "category": { "id": "cmt7xc9kk0006q3w1p2ny4l1s", "name": "Proveedores", "emoji": "📦" },
  "branch": { "id": "cmt7xc1200003q3w1r0mfa9wq", "name": "Sede norte" },
  "createdBy": { "id": null, "name": null, "phone": null }
}

source dice de dónde salió (PANEL, APP, API). Un gasto creado con una llave nace con source: "API" y createdBy en nulos: detrás de una llave no hay una persona, y el rastro de quién lo hizo está en la llave, que sí tiene dueño y auditoría.

📒 Cuentas por cobrar

El cuaderno de fiados (guía). data.object es el mismo objeto que devuelve /v1/receivables: la cuenta (receivableAccount) o el movimiento con la cuenta como quedó (receivableEntry + account).

EventoCuándo se disparadata.object
receivable.account.createdSe creó un cliente que debereceivableAccount + entries (los movimientos iniciales)
receivable.entry.createdSe registró un fiado, un abono o una condonación. type dice cuálreceivableEntry + account
receivable.account.paidEl saldo llegó a cero por un abono, una condonación o «Saldar cuenta». El que casi todos quierenreceivableAccount + closingEntry
receivable.entry.voidedSe anuló un movimiento; el saldo se recalculó sin élreceivableEntry (con voided: true) + account
receivable.account.updatedCambiaron los datos del cliente, su fecha acordada o su etapareceivableAccount + changedFields + previousStage
receivable.account.archivedSalió del tablero y de los totalesreceivableAccount
receivable.account.restoredUn archivado volvió al tableroreceivableAccount

Avisan venga de donde venga

Se emiten desde el servicio, no desde la API: te llegan igual si el abono lo anotó la cajera en la app, la dueña en el panel, el asistente de IA o tu propia llave. Si tu sistema escribe por API y está suscrito, va a recibir el eco de sus propias escrituras — deduplica por el id del movimiento.

receivable.entry.created
{
  "id": "evt_cmu4h7r3a0015bnw1k8s2qp4d",
  "type": "receivable.entry.created",
  "created": "2026-09-26T21:05:33.118Z",
  "apiVersion": "v1",
  "companyId": "cmt7xbfrg0001q3w1itst7uwo",
  "data": {
    "object": {
      "object": "receivableEntry",
      "id": "cmu4h7r2t0014bnw1y2e6kd9v",
      "accountId": "cmu4a2k9x000b7qw1h3r5tz8e",
      "type": "PAYMENT",
      "amount": 80000,
      "currency": "COP",
      "balanceAfter": 170000,
      "note": "Abono en efectivo",
      "occurredAt": "2026-09-26T21:05:33.000Z",
      "createdAt": "2026-09-26T21:05:33.118Z",
      "source": "APP",
      "createdBy": { "id": "cmt8u1p9r00035pw1kq0v2xdc", "name": "Laura Gómez" },
      "voided": false,
      "voidedAt": null,
      "voidedBy": null,
      "account": {
        "object": "receivableAccount",
        "id": "cmu4a2k9x000b7qw1h3r5tz8e",
        "name": "Gloria Restrepo",
        "phone": "+573104567890",
        "email": null,
        "notes": "Mercado de la semana. Paga los viernes.",
        "stage": "PARTIAL",
        "dueDate": "2026-10-15T05:00:00.000Z",
        "isOverdue": false,
        "balance": 170000,
        "currency": "COP",
        "totalCharged": 300000,
        "totalPaid": 130000,
        "totalWrittenOff": 0,
        "chargesCount": 1,
        "paymentsCount": 2,
        "lastChargeAt": "2026-09-22T14:12:08.431Z",
        "lastPaymentAt": "2026-09-26T21:05:33.000Z",
        "lastActivityAt": "2026-09-26T21:05:33.118Z",
        "branchId": "cmt7xbfrj0002q3w1iq4ysatr",
        "source": "API",
        "createdBy": null,
        "createdAt": "2026-09-22T14:12:08.431Z",
        "updatedAt": "2026-09-26T21:05:33.118Z",
        "archivedAt": null
      }
    }
  }
}
CampoQué es
typeCHARGE (fiado, sube el saldo), PAYMENT (abono) o WRITE_OFF (condonación: baja el saldo sin ser un pago).
amountPesos, con hasta 2 decimales, siempre positivo. El signo lo da type. balanceAfter y los saldos de account también pueden traer centavos.
balanceAfterSaldo del cliente justo después de este movimiento.
sourcePor dónde se anotó: PANEL, APP, ASSISTANT o API. Con API, createdBy viene en null.
account.balanceLo que debe ahora. Es lo que casi siempre quieres.
account.isOverdueSaldo mayor que cero y la fecha acordada ya pasó (día de Bogotá). No es una etapa.
receivable.account.paid
{
  "id": "evt_cmu8c3w6b0031bnw1t5m7xe2r",
  "type": "receivable.account.paid",
  "created": "2026-09-29T16:20:11.207Z",
  "apiVersion": "v1",
  "companyId": "cmt7xbfrg0001q3w1itst7uwo",
  "data": {
    "object": {
      "object": "receivableAccount",
      "id": "cmu4a2k9x000b7qw1h3r5tz8e",
      "name": "Gloria Restrepo",
      "phone": "+573104567890",
      "email": null,
      "notes": "Mercado de la semana. Paga los viernes.",
      "stage": "PAID",
      "dueDate": "2026-10-15T05:00:00.000Z",
      "isOverdue": false,
      "balance": 0,
      "currency": "COP",
      "totalCharged": 300000,
      "totalPaid": 300000,
      "totalWrittenOff": 0,
      "chargesCount": 1,
      "paymentsCount": 3,
      "lastChargeAt": "2026-09-22T14:12:08.431Z",
      "lastPaymentAt": "2026-09-29T16:20:11.207Z",
      "lastActivityAt": "2026-09-29T16:20:11.207Z",
      "branchId": "cmt7xbfrj0002q3w1iq4ysatr",
      "source": "API",
      "createdBy": null,
      "createdAt": "2026-09-22T14:12:08.431Z",
      "updatedAt": "2026-09-29T16:20:11.207Z",
      "archivedAt": null,
      "closingEntry": {
        "object": "receivableEntry",
        "id": "cmu8c3w5m002zbnw1q7t1ha0g",
        "accountId": "cmu4a2k9x000b7qw1h3r5tz8e",
        "type": "PAYMENT",
        "amount": 170000,
        "currency": "COP",
        "balanceAfter": 0,
        "note": "Pagó el saldo",
        "occurredAt": "2026-09-29T16:20:11.207Z",
        "createdAt": "2026-09-29T16:20:11.207Z",
        "source": "PANEL",
        "createdBy": { "id": "cmt8u1p9r00035pw1kq0v2xdc", "name": "José Quintero" },
        "voided": false,
        "voidedAt": null,
        "voidedBy": null
      }
    }
  }
}

closingEntry es el movimiento que dejó la cuenta en cero: con type: "PAYMENT" pagó, con type: "WRITE_OFF" se le perdonó. Ese mismo movimiento llega además como receivable.entry.created — suscribe el que necesites, no los dos.

Cuatro detalles que conviene saber antes de montar el handler:

  • receivable.account.paid sale solo cuando un abono o una condonación bajan el saldo de más de cero a cero. Anular el único fiado de una cuenta sin abonos la deja en PAID, pero no emite este evento: llega como receivable.entry.voided.
  • Los movimientos iniciales no se cuentan dos veces. Al crear un cliente con initialDebt e initialPaid, «Deuda inicial» y «Abono inicial» viajan dentro de receivable.account.created (en entries) y no emiten además receivable.entry.created. Si sincronizas movimientos, suma los de los dos eventos.
  • changedFields en receivable.account.updated lista los campos que venían en la edición (["stage", "dueDate"]), y previousStage la etapa de antes. Los cambios de etapa que provoca un movimiento (un abono que pasa la cuenta a PARTIAL) no emiten updated: van en el account del movimiento.
  • Anular dos veces no emite dos eventos, ni restaurar un cliente que no estaba archivado.

Si no los ves al crear el webhook

Que un evento se pueda suscribir depende del plan, y el catálogo por plan lo decide el administrador de Verifika. GET /v1/webhook-events te dice cuáles tienes (allowed). Además, sin el módulo de cuentas por cobrar en el plan no hay nada que los dispare.

🚨 Pagos sospechosos

EventoCuándodata.object
suspicious.createdUn comprobante repetido, o uno que el banco nunca respaldósuspiciousAttempt
suspicious.resolvedAlguien lo resolvió desde el panelid + status

Es el evento para avisarle a alguien en el momento: un intento sospechoso que se descubre al día siguiente ya no sirve de nada.

suspicious.created
{
  "object": "suspiciousAttempt",
  "id": "cmtmh8w2z00a1aqw1kq5t9e3v",
  "kind": "DUPLICATE",
  "status": "OPEN",
  "channel": "WHATSAPP",
  "amount": 45000,
  "reference": "M1A2B3C4",
  "customerName": "Laura Restrepo",
  "bankLabel": "Nequi",
  "accountLabel": "Nequi ··3421",
  "occurredAt": "2026-09-06T15:02:41.000Z",
  "attemptedAt": "2026-09-06T18:22:09.000Z",
  "reportedByPhone": "+573004445566",
  "reportedByName": "Camila Ruiz",
  "resolvedAt": null,
  "resolutionNote": null,
  "branch": { "id": "cmt7xc1200003q3w1r0mfa9wq", "name": "Sede norte" },
  "originalIncome": {
    "id": "cmtmf1k8m006faqw1lh27ozcr",
    "reference": "M1A2B3C4",
    "amount": 45000,
    "occurredAt": "2026-09-06T15:02:41.000Z",
    "status": "VERIFIED",
    "verifiedAt": "2026-09-06T15:04:05.000Z",
    "confirmedByName": "Andrés Gómez"
  }
}
CampoQué es
kindDUPLICATE (ese comprobante ya se cobró) o NOT_FOUND (el banco nunca lo respaldó).
statusOPEN, RESOLVED o DISMISSED.
attemptedAtCuándo se intentó cobrar otra vez. occurredAt es la fecha del comprobante.
originalIncomeCon DUPLICATE, el pago bueno: quién lo recibió de verdad frente a quien lo intentó re-cobrar. Con NOT_FOUND viene en null.

Este objeto trae campos que no son contrato

suspiciousAttempt sale hoy con la misma forma que usa el panel, así que además de lo de arriba puede traer campos internos como reportedBy.image — una URL que solo sirve con una sesión del panel. No dependas de nada que no esté en la tabla. Lo de la tabla no se va a mover; lo demás puede desaparecer sin previo aviso.

suspicious.resolved
{
  "object": "suspiciousAttempt",
  "id": "cmtmh8w2z00a1aqw1kq5t9e3v",
  "status": "RESOLVED"
}

Resolver manda solo el id y el estado nuevo, no el intento entero. Si necesitas el detalle, pídelo con GET /v1/suspicious-attempts/:id.

🏦 Conexión con bancos

EventoCuándodata.object
connection.status_changedUn canal se conectó, se cayó o quedó en atenciónconnection
connection.status_changed
{
  "object": "connection",
  "type": "EMAIL",
  "status": "DOWN",
  "paused": false
}

Es el evento con el que se avisa antes de que alguien reclame: si el canal del banco está caído, los comprobantes que lleguen mientras tanto van a salir como «no encontrados» aunque sean buenos.

🧮 Conciliación bancaria

EventoCuándo se disparadata.object
bank_statement.processedTerminó de leerse y cruzarse un extracto, venga de donde venga: el panel, el asistente o la API.bankStatement completo, como GET /v1/bank-statements/{id}
bank_statement.failedUna subida asíncrona de la API (la que respondió 202) falló o se cortó.{ object, id, status: "failed", fileName, error: { code, message, details }, createdAt, finishedAt }
bank_statement.failed
{
  "object": "bankStatement",
  "id": "cmuh8q2r70044x8w1b6n3m1kd",
  "status": "failed",
  "fileName": "extracto-escaneado.pdf",
  "error": {
    "code": "STATEMENT_PASSWORD_REQUIRED",
    "message": "Este PDF está protegido con clave.",
    "details": { "savedTried": 1 }
  },
  "createdAt": "2026-10-12T21:03:02.000Z",
  "finishedAt": "2026-10-12T21:03:40.000Z"
}

Las acciones sobre las filas (conciliar, registrar, ignorar) no emiten eventos propios: registrar desde el extracto es poner al día el libro, no plata que acaba de llegar.

🧪 Sistema

EventoCuándodata.object
webhook.testLo dispara el botón Enviar prueba del paneltest
webhook.test
{
  "object": "test",
  "message": "Si estás leyendo esto, tu endpoint recibe eventos de Verifika.",
  "endpointId": "cmtmi0q4400b2aqw1s7dl3x8m"
}

No lo emite nada del negocio: es el único que puedes provocar tú cuando quieras, y por eso es con el que conviene montar el handler antes de esperar un pago de verdad.

Los que todavía no se emiten

Están en el catálogo, pero no llegan

Estos tres se pueden marcar en el panel y aparecen en GET /v1/webhook-events —el catálogo se sembró completo—, pero hoy no hay nada en el producto que los dispare. Suscribirlos no rompe nada; simplemente no vas a recibirlos.

EventoQué hará
team.member_addedSe aceptó una invitación o se agregó un usuario de WhatsApp
team.member_removedSe quitó a alguien del equipo
plan.limit_reachedSe agotó un límite del plan, por ejemplo las verificaciones del mes

Preferimos decirlo a que alguien monte una alerta encima y descubra dentro de un mes que nunca sonó.

Qué NO te vamos a mandar

  • El archivo del comprobante. Los eventos traen los metadatos del adjunto (id, tipo, nombre, tamaño), nunca los bytes ni una URL de descarga. Para verlo, GET del ingreso desde una sesión, o get_income_receipt por MCP.
  • Correos ni fotos del equipo. confirmedBy trae nombre y teléfono, que es lo que hace falta para saber quién recibió el pago.
  • El número completo de una cuenta. Siempre alias y últimos cuatro.

En esta página