KoonniPara desarrolladores

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 2xx en 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 410 dice «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.

Siguiente: Zapier y Make →