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"]
}'{
"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. |
auth | objeto | No | La credencial con la que llamamos a tu endpoint, si el tuyo no está abierto. Ver §3. |
headers | {name,value}[] | No | Cabeceras propias que mandamos en cada entrega. Máximo 10. |
testEvents | boolean | No | Recibir 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.
| Familia | Eventos |
|---|---|
| Verificaciones por API | verification.verified · verification.not_found |
| Ingresos | income.created · income.verified · income.status_changed · income.updated · income.deleted |
| Egresos | expense.created · expense.updated · expense.deleted |
| Pagos sospechosos | suspicious.created · suspicious.resolved |
| Conexión con bancos | connection.status_changed |
| Conciliación bancaria | bank_statement.processed · bank_statement.failed |
| Cuentas por cobrar | receivable.account.created · receivable.entry.created · receivable.account.paid · receivable.entry.voided · receivable.account.updated · receivable.account.archived · receivable.account.restored |
| Equipo y plan | team.member_added · team.member_removed · plan.limit_reached |
| Sistema | webhook.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": "…"
}
}type | Qué mandamos |
|---|---|
NONE | Nada. Es el valor por defecto y lo que hacían todos los webhooks hasta ahora. |
BASIC | Authorization: Basic base64(usuario:contraseña) |
BEARER | Authorization: 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 hOcho 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.
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.