Webhooks
Recibir avisos firmados, verificarlos y saber qué trae cada evento.
Un webhook es una dirección tuya a la que Koonni avisa cuando pasa algo. Se registra en Configuración › Para desarrolladores › Webhooks, eligiendo los eventos. El aviso lleva identificadores, nunca el texto de tus clientes: el contenido se consulta en la API con tu clave.
Lo que llega
{
"id": "6f1c…",
"type": "conversation.resolved",
"occurredAt": "2026-10-20T15:04:05.000Z",
"tenantId": "0b2e…",
"traceId": "9a41…",
"schemaVersion": 1,
"data": {
"conversationId": "c3d1…",
"contactId": "a77f…",
"channel": "whatsapp",
"resolvedBy": "agent",
"resolvedAt": "2026-10-20T15:04:05.000Z"
}
}id identifica el hecho, no el envío: úsalo para descartar repetidos, porque una entrega puede llegar dos veces. Qué trae data en cada evento está en la referencia.
Verificar la firma
Cada entrega trae la cabecera x-koonni-signature, con la forma t=…,v1=…: v1 es el HMAC-SHA256, en hexadecimal, de «t».«cuerpo exacto» con el secreto del destino. Calcúlalo sobre el cuerpo tal como llegó, sin volver a serializarlo, y rechaza una marca de tiempo de hace más de cinco minutos.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
// cuerpo: el texto exacto que llegó, sin volver a serializarlo.
export function esDeKoonni(cuerpo, cabecera, secreto) {
const partes = Object.fromEntries(cabecera.split(",").map((trozo) => trozo.split("=")));
const marca = Number(partes.t);
if (!marca || Math.abs(Date.now() / 1000 - marca) > 300) return false; // más de 5 minutos: se descarta
const esperada = createHmac("sha256", secreto).update(`${marca}.${cuerpo}`).digest("hex");
const recibida = partes.v1 ?? "";
return recibida.length === esperada.length && timingSafeEqual(Buffer.from(recibida), Buffer.from(esperada));
}Python
import hashlib, hmac, time
def es_de_koonni(cuerpo: bytes, cabecera: str, secreto: str) -> bool:
partes = dict(trozo.split("=", 1) for trozo in cabecera.split(","))
marca = int(partes.get("t", "0"))
if not marca or abs(time.time() - marca) > 300: # más de 5 minutos: se descarta
return False
firmado = f"{marca}.".encode() + cuerpo
esperada = hmac.new(secreto.encode(), firmado, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, partes.get("v1", ""))PHP
<?php
function esDeKoonni(string $cuerpo, string $cabecera, string $secreto): bool {
$partes = [];
foreach (explode(",", $cabecera) as $trozo) {
[$clave, $valor] = array_pad(explode("=", $trozo, 2), 2, "");
$partes[$clave] = $valor;
}
$marca = (int) ($partes["t"] ?? 0);
if (!$marca || abs(time() - $marca) > 300) return false; // más de 5 minutos: se descarta
$esperada = hash_hmac("sha256", $marca . "." . $cuerpo, $secreto);
return hash_equals($esperada, $partes["v1"] ?? "");
}
// $cuerpo = file_get_contents("php://input");
// $cabecera = $_SERVER["HTTP_X_KOONNI_SIGNATURE"] ?? "";Al rotar el secreto, el anterior sigue valiendo 24 horas: acepta los dos mientras despliegas.
Contestar, reintentos y apagado
- Contesta un
2xxen menos de 10 segundos. Haz el trabajo pesado después, en segundo plano. - Si no contestas, se intenta hasta 8 veces durante casi un día: la primera y luego a 1 minuto, 5, 15, una hora, 3, 6 y 12 horas.
- Una redirección cuenta como fallo: registra la dirección definitiva. Un
410dice «no vuelvas». - Tras 20 entregas fallidas seguidas, el destino se apaga solo y la pantalla dice por qué.
Probar antes de esperar
El botón «Enviar un evento de prueba» de cada destino manda ahora un webhook.test, firmado como los reales, y dice qué contestó tu servidor. No es un hecho de tu empresa: no lo guardes como tal.