VerifikaDocs
Guías

Webhooks

Recibe cada pago verificado en tu propia URL, firmado y con reintentos.

En vez de preguntar cada minuto si entró un pago, nos das una URL y te avisamos cuando pasa.

Tu plan tiene que incluir los webhooks (features.webhooks_outbound en GET /v1/plan); la llave, los permisos webhooks.read y webhooks.write. ¿Buscas el caso del bot que le confirma al cliente minutos después? Está completo en Cuando el comprobante llega antes que el banco.

1. Crea el endpoint

Desde el panel —Integraciones → Webhooks → Agregar webhook— o por API:

curl -X POST https://api.verifika.tech/v1/webhook-endpoints \
  -H "Authorization: Bearer $VERIFIKA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://tusistema.com/verifika",
    "description": "Producción",
    "events": ["income.verified", "suspicious.created"]
  }'
Respuesta
{
  "object": "webhookEndpoint",
  "id": "cmtnoypsk00076kw1lggjtcjx",
  "url": "https://tusistema.com/verifika",
  "events": ["income.verified", "suspicious.created"],
  "status": "ENABLED",
  "secret": "whsec_f0CLZeUj..."
}

Qué manda cada campo

CampoTipo¿Obligatorio?Qué significa
urlstring URLSíA dónde mandamos los eventos. Tiene que ser https y resolver a una dirección pública: por http los datos de dinero de un negocio viajarían en claro.
eventsstring[]SíQué quieres recibir. Claves del catálogo (GET /v1/webhook-events). Al menos una — un webhook sin eventos no recibiría nada, y esa es una trampa silenciosa.
descriptionstringNoPara ti: «producción», «staging», «el ERP del contador». Sale en la lista del panel para que sepas cuál es cuál.
authobjetoNoLa credencial con la que llamamos a tu endpoint, si el tuyo no está abierto. Ver §3.
headers{name,value}[]NoCabeceras propias que mandamos en cada entrega. Máximo 10.
testEventsbooleanNoRecibir también eventos de prueba, los que provocan las llaves vk_test_. Apagado por defecto. Ver Modo de prueba.

Guarda el secreto

Con él verificas que cada entrega vino de nosotros. A diferencia de una API key, este sí se puede volver a consultar desde el panel: lo necesitamos nosotros para firmar, así que ya está guardado de forma reversible y ocultarlo no protegería nada.

La URL tiene que ser https y resolver a una dirección pública. Se valida al guardarla y otra vez en cada entrega — un dominio que hoy apunta a un servidor público puede apuntar mañana a una red interna, y no vamos a ser nosotros quienes toquen esa puerta.

2. Los eventos

Consulta el catálogo con GET /v1/webhook-events: devuelve todos, con allowed según tu plan.

FamiliaEventos
Verificaciones por APIverification.verified · verification.not_found
Ingresosincome.created · income.verified · income.status_changed · income.updated · income.deleted
Egresosexpense.created · expense.updated · expense.deleted
Pagos sospechosossuspicious.created · suspicious.resolved
Conexión con bancosconnection.status_changed
Conciliación bancariabank_statement.processed · bank_statement.failed
Cuentas por cobrarreceivable.account.created · receivable.entry.created · receivable.account.paid · receivable.entry.voided · receivable.account.updated · receivable.account.archived · receivable.account.restored
Equipo y planteam.member_added · team.member_removed · plan.limit_reached
Sistemawebhook.test

Cada uno con su JSON, en su propia página

Eventos de webhook trae qué dispara cada evento, qué campos llegan y un cuerpo de ejemplo real por familia — además de los tres que están en el catálogo pero todavía no se emiten, que conviene saber antes de montar una alerta encima.

income.verified e income.status_changed llegan los dos

Cuando un pago pasa a verificado emitimos ambos. Suscribe solo income.verified si eso es lo que te importa: te ahorras filtrar dentro de tu handler y no te llega ruido por cada duplicado y cada no-encontrado del negocio.

3. Si tu endpoint pide credencial

Hasta aquí todo supone que tu URL acepta un POST de cualquiera y decide con la firma. Muchos receptores no son así: un API gateway, un n8n con autenticación, una función protegida o un ERP corporativo rechazan la petición antes de leer el cuerpo, así que nunca llegan a comprobar la firma.

Para eso está auth. Se configura en el panel (Integraciones → Webhooks → Suscripciones → Autenticación) o al crear el endpoint por API.

La credencial NO sustituye a la firma

Son dos cosas distintas y hacen falta las dos. La credencial abre la puerta; la firma prueba quién llamó. Un endpoint que solo compruebe la credencial acepta cualquier cosa que alguien pegue en esa URL con esa cabecera — y esa cabecera va a estar en la configuración de tu gateway, en tu gestor de secretos y en el portapapeles de quien la puso. Comprueba Verifika-Signature igual que si no hubiera credencial.

Los cuatro tipos

{
  "auth": {
    "type": "BASIC",
    "username": "verifika",
    "password": "…"
  }
}
typeQué mandamos
NONENada. Es el valor por defecto y lo que hacían todos los webhooks hasta ahora.
BASICAuthorization: Basic base64(usuario:contraseña)
BEARERAuthorization: Bearer <token>
API_KEY<headerName>: <prefix> <value>, con el prefijo solo si lo pones (Token, ApiKey…)

Cabeceras propias

Para lo que no es una credencial: a qué inquilino, a qué entorno, de qué origen.

{
  "headers": [
    { "name": "X-Tenant-Id", "value": "acme" },
    { "name": "X-Environment", "value": "production" }
  ]
}

Máximo 10. Los nombres tienen que ser nombres de cabecera válidos —letras, números y - _ ., sin espacios— y hay unos cuantos que no se pueden pisar porque los ponemos nosotros: Content-Type, User-Agent, Host, Content-Length, cualquiera que empiece por Verifika- y Authorization, que se configura en auth y no aquí.

Cómo se guardan y cómo se cambian

auth y headers se guardan cifrados (AES-256-GCM), igual que el secreto de firma: hay que volver a presentarlos en cada entrega, así que no se pueden hashear. Las cabeceras van cifradas también aunque casi ninguna sea secreta — es exactamente el campo donde alguien va a pegar su X-Api-Key pensando que es «una cabecera más».

En el listado (GET /v1/webhook-endpoints) sale qué hay puesto, nunca el valor:

{
  "object": "webhookEndpoint",
  "id": "cmtmi0q4400b2aqw1s7dl3x8m",
  "url": "https://tusistema.com/verifika",
  "auth": { "type": "API_KEY", "headerName": "X-Api-Key" },
  "headerNames": ["X-Tenant-Id", "X-Environment"]
}

En un PATCH se mandan enteros

auth y headers reemplazan, no se fusionan. Un PATCH con solo la contraseña obligaría a mezclarla con lo que ya hay, y así es como se acaba mandando el usuario nuevo con la contraseña vieja. Omítelos para dejarlos como estaban; mándalos completos para cambiarlos.

4. La entrega

POST https://tusistema.com/verifika
X-Tenant-Id: acme
Authorization: Bearer eyJhbGciOi…
Content-Type: application/json
Verifika-Event-Id: evt_cms9qvef700013jw1qawn586g
Verifika-Event-Type: income.verified
Verifika-Delivery-Id: dlv_cms9qvef700013jw1qawn586h
Verifika-Attempt: 1
Verifika-Livemode: true
Verifika-Signature: t=1789012345,v1=5257a869e7...

Las entregas llegan con User-Agent: Verifika-Webhooks/1.0. Tus cabeceras y tu credencial van primero; las nuestras se ponen después y ganan siempre. Un Verifika-Event-Id propio no puede reescribir el nuestro ni por accidente ni a propósito.

{
  "id": "evt_cms9qvef700013jw1qawn586g",
  "type": "income.verified",
  "created": "2026-09-04T15:04:05.000Z",
  "apiVersion": "v1",
  "companyId": "cmt7xbfrg0001q3w1itst7uwo",
  "livemode": true,
  "data": {
    "object": {
      "object": "income",
      "id": "cmtmf1k8m006faqw1lh27ozcr",
      "amount": 45000,
      "currency": "COP",
      "status": "VERIFIED",
      "previousStatus": "PENDING",
      "reference": "M1A2B3C4",
      "verifiedAt": "2026-09-04T15:04:05.000Z"
    }
  }
}

data.object es exactamente el mismo objeto que devuelve el GET de ese recurso. No hay dos formas del mismo dato que aprender.

livemode (en el cuerpo y en la cabecera Verifika-Livemode) es false en los eventos de una llave de prueba, que solo llegan a los webhooks con testEvents. Si tu webhook los recibe, míralo antes de despachar nada.

5. Verifica la firma

HMAC-SHA256 sobre ${timestamp}.${cuerpo} con tu secreto.

Sobre el cuerpo CRUDO

Calcula el HMAC sobre los bytes tal como llegaron, antes de parsear el JSON. Si lo haces sobre el objeto re-serializado, un espacio de diferencia rompe la firma. En Express: express.raw({ type: 'application/json' }) en esa ruta.

import crypto from 'node:crypto';

export function verificarFirma(cuerpoCrudo, cabecera, secreto, toleranciaSeg = 300) {
  const partes = cabecera.split(',').map((p) => p.trim());
  const t = partes.find((p) => p.startsWith('t='))?.slice(2);
  if (!t) return false;

  // Rechaza eventos viejos: sin esto, quien capture una entrega puede
  // reenviártela mañana y tu sistema la aceptaría como buena.
  const edad = Math.abs(Date.now() / 1000 - Number(t));
  if (!Number.isFinite(edad) || edad > toleranciaSeg) return false;

  const esperada = crypto.createHmac('sha256', secreto).update(`${t}.${cuerpoCrudo}`).digest('hex');
  const buf = Buffer.from(esperada, 'hex');

  // Puede venir más de una v1 durante una rotación: basta con que una cuadre.
  return partes
    .filter((p) => p.startsWith('v1='))
    .some((p) => {
      const got = Buffer.from(p.slice(3), 'hex');
      // Comparación en tiempo constante: un `===` filtra información por lo que tarda.
      return got.length === buf.length && crypto.timingSafeEqual(got, buf);
    });
}

6. Reintentos

Éxito es cualquier 2xx en menos de 5 segundos. No seguimos redirecciones: un 3xx cuenta como fallo, así que apunta el webhook a la URL final (ojo con la barra del final o con un dominio que redirige a otro). Si falla, reintentamos con espera creciente:

1 min → 5 min → 30 min → 2 h → 6 h → 12 h → 24 h

Ocho intentos en unas 48 horas (con un poco de azar en cada espera, para que mil entregas no reintenten en el mismo segundo). Después la entrega queda FAILED y puedes reenviarla desde Webhooks → Historial o con POST /v1/webhook-deliveries/{id}/retry.

Si tu endpoint acumula 20 fallos seguidos, lo desactivamos (status: DISABLED_AUTO) y se lo avisamos al dueño de la cuenta. Se reactiva con PATCH { "enabled": true }.

Responde rápido y procesa después

Encola el trabajo y devuelve 200 de inmediato. Si procesas dentro del handler y tardas 6 segundos, para nosotros es un fallo y vas a recibir el mismo evento otra vez.

7. Dos reglas que evitan el 90 % de los bugs

El orden no está garantizado

Puedes recibir income.verified antes que income.created. Ordena por el campo created, nunca por el orden de llegada.

Puedes recibir el mismo evento dos veces

Un reintento nuestro tras un timeout de tu lado entrega algo que ya procesaste. Guarda los id de evento que ya viste y descarta los repetidos.

8. Rotar el secreto

curl -X POST https://api.verifika.tech/v1/webhook-endpoints/{id}/rotate-secret \
  -H "Authorization: Bearer vk_live_..."

Durante 24 horas firmamos cada entrega con los dos secretos (verás dos v1= en la cabecera), así despliegas el nuevo cuando quieras sin perder un solo evento.

9. Depurar

En el panel, Webhooks → Historial muestra cada entrega con el cuerpo que mandamos y la respuesta de tu servidor, y deja reenviarla.

app.verifika.tech/integraciones?seccion=webhooks&tab=historial
Webhooks → Historial. «Detalles» muestra el cuerpo que mandamos y lo que respondió tu servidor.

Por API, GET /v1/webhook-deliveries te dice qué mandamos, qué respondió tu servidor y en qué intento:

{
  "object": "webhookDelivery",
  "status": "PENDING",
  "attempt": 1,
  "responseStatus": 405,
  "responseSnippet": "<!doctype html><html lang=\"en\"><head><title>...",
  "durationMs": 563,
  "error": "El endpoint respondió 405.",
  "nextRetryAt": "2026-09-05T01:17:58.472Z"
}

Ese responseSnippet es tu propia respuesta: casi siempre dice exactamente qué salió mal sin que tengas que ir a mirar tus logs.

En esta página