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" }
}
}| Campo | Qué es |
|---|---|
id | Id del evento, no de la entrega. Es con el que deduplicas. |
type | La clave del catálogo. |
created | Cuándo ocurrió el hecho, en UTC. No cuándo te lo mandamos. |
apiVersion | Hoy siempre v1. |
companyId | Sobre qué negocio. Importa si un mismo endpoint atiende a varios. |
livemode | false = 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.object | El 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.
| Evento | Cuándo se dispara | data.object |
|---|---|---|
verification.verified | Una 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_found | Venció la espera (nextStep.until) sin aviso del banco. | verification + previousStatus + previousIncomeStatus |
{
"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
| Evento | Cuándo se dispara | data.object |
|---|---|---|
income.created | Se registró un pago, venga del bot, de la app, del banco o de la API | income |
income.verified | El banco confirmó el pago. El que casi todos quieren | income + previousStatus |
income.status_changed | Pasó a verificado, a duplicado o a no encontrado (venció la espera y el banco nunca lo respaldó). Ver el caso completo | income + previousStatus |
income.updated | Alguien corrigió un pago declarado a mano | income |
income.deleted | Se borró un pago declarado a mano | lá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.
{
"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 income | Qué es |
|---|---|
amount | Pesos, como número con hasta 2 decimales: 45000 son $45.000; 146357.19, $146.357,19. Sin centavos no trae decimales. |
status | PENDING, VERIFIED, DUPLICATE, NOT_FOUND o DECLARED. |
previousStatus | Solo en status_changed y verified. De dónde venía. |
origin | Por 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. |
occurredAt | Cuándo se hizo el pago. createdAt es cuándo se registró aquí. |
reference | La referencia del comprobante. Es lo que cruza con el aviso del banco. |
duplicateOfId | Con status: "DUPLICATE", el id del pago original. |
confirmedBy | Quién lo recibió. phone viene del bot de WhatsApp. |
bankChannels | Por qué canales lo respaldó el banco (EMAIL, SMS, PUSH). Vacío = todavía ninguno. |
{
"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
| Evento | Cuándo | data.object |
|---|---|---|
expense.created | Se registró un gasto | expense |
expense.updated | Cambiaron sus datos | expense |
expense.deleted | Se borró | lápida |
{
"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).
| Evento | Cuándo se dispara | data.object |
|---|---|---|
receivable.account.created | Se creó un cliente que debe | receivableAccount + entries (los movimientos iniciales) |
receivable.entry.created | Se registró un fiado, un abono o una condonación. type dice cuál | receivableEntry + account |
receivable.account.paid | El saldo llegó a cero por un abono, una condonación o «Saldar cuenta». El que casi todos quieren | receivableAccount + closingEntry |
receivable.entry.voided | Se anuló un movimiento; el saldo se recalculó sin él | receivableEntry (con voided: true) + account |
receivable.account.updated | Cambiaron los datos del cliente, su fecha acordada o su etapa | receivableAccount + changedFields + previousStage |
receivable.account.archived | Salió del tablero y de los totales | receivableAccount |
receivable.account.restored | Un archivado volvió al tablero | receivableAccount |
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.
{
"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
}
}
}
}| Campo | Qué es |
|---|---|
type | CHARGE (fiado, sube el saldo), PAYMENT (abono) o WRITE_OFF (condonación: baja el saldo sin ser un pago). |
amount | Pesos, con hasta 2 decimales, siempre positivo. El signo lo da type. balanceAfter y los saldos de account también pueden traer centavos. |
balanceAfter | Saldo del cliente justo después de este movimiento. |
source | Por dónde se anotó: PANEL, APP, ASSISTANT o API. Con API, createdBy viene en null. |
account.balance | Lo que debe ahora. Es lo que casi siempre quieres. |
account.isOverdue | Saldo mayor que cero y la fecha acordada ya pasó (día de Bogotá). No es una etapa. |
{
"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.paidsale 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 enPAID, pero no emite este evento: llega comoreceivable.entry.voided.- Los movimientos iniciales no se cuentan dos veces. Al crear un cliente con
initialDebteinitialPaid, «Deuda inicial» y «Abono inicial» viajan dentro dereceivable.account.created(enentries) y no emiten ademásreceivable.entry.created. Si sincronizas movimientos, suma los de los dos eventos. changedFieldsenreceivable.account.updatedlista los campos que venían en la edición (["stage", "dueDate"]), ypreviousStagela etapa de antes. Los cambios de etapa que provoca un movimiento (un abono que pasa la cuenta aPARTIAL) no emitenupdated: van en elaccountdel 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
| Evento | Cuándo | data.object |
|---|---|---|
suspicious.created | Un comprobante repetido, o uno que el banco nunca respaldó | suspiciousAttempt |
suspicious.resolved | Alguien lo resolvió desde el panel | id + 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.
{
"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"
}
}| Campo | Qué es |
|---|---|
kind | DUPLICATE (ese comprobante ya se cobró) o NOT_FOUND (el banco nunca lo respaldó). |
status | OPEN, RESOLVED o DISMISSED. |
attemptedAt | Cuándo se intentó cobrar otra vez. occurredAt es la fecha del comprobante. |
originalIncome | Con 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.
{
"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
| Evento | Cuándo | data.object |
|---|---|---|
connection.status_changed | Un canal se conectó, se cayó o quedó en atención | connection |
{
"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
| Evento | Cuándo se dispara | data.object |
|---|---|---|
bank_statement.processed | Terminó 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.failed | Una 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 } |
{
"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
| Evento | Cuándo | data.object |
|---|---|---|
webhook.test | Lo dispara el botón Enviar prueba del panel | 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.
| Evento | Qué hará |
|---|---|
team.member_added | Se aceptó una invitación o se agregó un usuario de WhatsApp |
team.member_removed | Se quitó a alguien del equipo |
plan.limit_reached | Se 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,GETdel ingreso desde una sesión, oget_income_receiptpor MCP. - Correos ni fotos del equipo.
confirmedBytrae 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.