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.
1. Crea el endpoint
Desde el panel (Integraciones → API pública y webhooks → Webhooks) 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"]
}'{
"object": "webhookEndpoint",
"id": "cmtnoypsk00076kw1lggjtcjx",
"url": "https://tusistema.com/verifika",
"events": ["income.verified", "suspicious.created"],
"status": "ENABLED",
"secret": "whsec_f0CLZeUj..."
}Qué manda cada campo
| Campo | Tipo | ¿Obligatorio? | Qué significa |
|---|---|---|---|
url | string URL | Sí | 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. |
events | string[] | 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. |
description | string | No | Para ti: «producción», «staging», «el ERP del contador». Sale en la lista del panel para que sepas cuál es cuál. |
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.
| Evento | Cuándo |
|---|---|
income.created | Se registró un pago, venga de donde venga |
income.verified | El banco confirmó el pago. El que casi todos quieren |
income.status_changed | Cambió de estado. Trae previousStatus |
income.updated · income.deleted | Cambió o se borró un pago declarado |
expense.created · expense.updated · expense.deleted | Movimientos de egreso |
suspicious.created | Comprobante repetido, o que el banco nunca respaldó |
suspicious.resolved | Alguien lo resolvió desde el panel |
connection.status_changed | Se cayó (o volvió) un canal del banco |
team.member_added · team.member_removed | Cambios de equipo |
plan.limit_reached | Se agotó un límite del plan |
webhook.test | Lo dispara el botón «Probar» |
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. La entrega
POST https://tusistema.com/verifika
Content-Type: application/json
Verifika-Event-Id: evt_cms9qvef700013jw1qawn586g
Verifika-Event-Type: income.verified
Verifika-Delivery-Id: dlv_cms9qvef700013jw1qawn586h
Verifika-Attempt: 1
Verifika-Signature: t=1789012345,v1=5257a869e7...{
"id": "evt_cms9qvef700013jw1qawn586g",
"type": "income.verified",
"created": "2026-09-04T15:04:05.000Z",
"apiVersion": "v1",
"companyId": "cmt7xbfrg0001q3w1itst7uwo",
"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.
4. 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);
});
}5. Reintentos
Éxito es cualquier 2xx en menos de 5 segundos. Si no, reintentamos con espera creciente:
1 min → 5 min → 30 min → 2 h → 6 h → 12 h → 24 hOcho intentos en unas 48 horas. Después la entrega queda FAILED y puedes reenviarla desde el
panel 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.
6. 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.
7. 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.
8. Depurar
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.