Menú de guías

Webhook

Cuando un usuario abre un ticket desde el chat, Kavchat puede enviarlo de inmediato a tu sistema de soporte, tu CRM o un bot de Slack. Cada envío se firma con tu clave secreta.

El webhook está incluido en el plan Premium.

Configuración

  1. Crea en tu servidor un endpoint HTTPS que acepte POST con un cuerpo JSON.
  2. En el panel de control, abre Configuración e introduce la URL en Webhook.
  3. Verifica la firma de cada solicitud, como se explica a continuación, y responde con cualquier código de estado 2xx.

La URL debe empezar por https:// y apuntar a una dirección pública. Las direcciones de redes privadas se bloquean.

Contenido del mensaje

Kavchat envía dos tipos de eventos: ticket.created para los tickets enviados desde el formulario de contacto y lead.created para los datos que un visitante deja en un paso de captación de leads del guion. El campo data contiene el ticket o el lead con la misma estructura que devuelve la API de servidor, incluida la transcripción de la conversación.

HTTP
POST /kavchat/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Kavchat-Webhook/1
Kavchat-Signature: t=1792915200,v1=5f2b7c...e91a
JSON
{
  "type": "ticket.created",
  "data": {
    "id": "6718f0c2a4b5c6d7e8f90123",
    "number": 128,
    "status": "open",
    "name": "Dana",
    "email": "dana@example.com",
    "phone": "+972501234567",
    "userId": "user_123",
    "topicId": "billing",
    "topicName": "Billing",
    "scriptId": "returns",
    "sessionId": "k3J9xQ2mP7aB",
    "message": "My refund has not arrived yet.",
    "transcript": [
      { "from": "bot", "text": "Hi! How can I help?", "stepId": "start", "at": "2026-10-25T09:14:02.000Z" },
      { "from": "user", "text": "Returns", "at": "2026-10-25T09:14:05.000Z" }
    ],
    "createdAt": "2026-10-25T09:15:40.000Z",
    "updatedAt": "2026-10-25T09:15:40.000Z"
  }
}

userId corresponde al campo sub del token de identidad si el usuario se ha identificado; de lo contrario, es null. Los campos name, email, phone y los campos de asunto también pueden ser null.

Leads y datos personales

Un guion puede hacer preguntas al visitante (nombre, teléfono, correo electrónico, número o texto libre), validar cada respuesta y guardarla en una variable. Un paso de captación de leads envía estos datos a la bandeja de leads del panel de control. En el evento lead.created, el campo data.variables contiene todos los valores recopilados, organizados por nombre de variable. Los valores habituales también aparecen en los campos data.name, data.email y data.phone. Los números de teléfono llegan en formato internacional (E.164).

Estos datos son información personal. Kavchat solo los guarda en el lead, el ticket o la conversación con el agente al que pertenecen, nunca en informes ni registros. Los leads se eliminan al terminar el periodo de conservación que hayas elegido (30 días en el plan gratuito; con Premium también puedes conservarlos sin eliminarlos). Puedes eliminar un lead individual en cualquier momento. Si recopilas datos personales, indícalo en tu política de privacidad. En el texto de consentimiento del paso de captación de leads, explica a los visitantes cómo usarás sus datos.

El encabezado Kavchat-Signature

El encabezado tiene este formato: t=<unix seconds>,v1=<hex>, donde:

  • t es la hora del envío, en segundos Unix.
  • v1 es HMAC-SHA256(secretKey, "<t>.<raw body>") en hexadecimal. La clave es tu clave secreta completa.

Para verificar la firma, calcula el mismo HMAC sobre el cuerpo sin procesar, exactamente como llegó, compara en tiempo constante y rechaza las solicitudes con más de 5 minutos de antigüedad. Si el cuerpo se ha deserializado y convertido de nuevo a JSON, no coincidirá con la firma.

Verificación con un SDK

verifyWebhook hace todo lo anterior y devuelve el evento deserializado. Si falla, lanza un error con uno de estos códigos: malformed_header, timestamp_out_of_range, invalid_signature o invalid_payload. Responde a estos errores con HTTP 400. La instalación de los SDK se explica en Identificación de usuarios.

Node.js
import express from 'express';
import { Kavchat, WebhookVerificationError } from '@kavchat/node';

const kavchat = new Kavchat(process.env.KAVCHAT_SECRET_KEY!);

// Use the raw body. A body that was parsed and serialized again will not match the signature.
app.post('/kavchat/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = kavchat.verifyWebhook(req.body, req.get('Kavchat-Signature'));
    if (event.type === 'ticket.created') console.log('New ticket', event.data.number);
    res.sendStatus(200);
  } catch (err) {
    if (err instanceof WebhookVerificationError) return res.status(400).send(err.code);
    throw err;
  }
});
Python
from flask import Flask, request
from kavchat import WebhookVerificationError

app = Flask(__name__)

@app.post("/kavchat/webhook")
def webhook():
    try:
        # raw bytes: request.get_data() in Flask, request.body in Django
        event = kavchat.verify_webhook(request.get_data(), request.headers.get("Kavchat-Signature"))
    except WebhookVerificationError as err:
        return err.code, 400
    if event["type"] == "ticket.created":
        print("New ticket", event["data"]["number"])
    return "", 200
PHP
use Kavchat\WebhookVerificationException;

try {
    $event = $kavchat->verifyWebhook(
        file_get_contents('php://input'),
        $_SERVER['HTTP_KAVCHAT_SIGNATURE'] ?? null,
    );
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit($e->errorCode);
}

if ($event->type === 'ticket.created') {
    error_log('New ticket ' . $event->data->number);
}
Java
@PostMapping("/kavchat/webhook")
public ResponseEntity<String> webhook(@RequestBody byte[] body,
                                      @RequestHeader(value = "Kavchat-Signature", required = false) String signature) {
    try {
        WebhookEvent event = kavchat.verifyWebhook(body, signature);
        if ("ticket.created".equals(event.getType())) log.info("New ticket {}", event.getData().getNumber());
        return ResponseEntity.ok("");
    } catch (WebhookVerificationException e) {
        return ResponseEntity.badRequest().body(e.getCode());
    }
}
.NET
app.MapPost("/kavchat/webhook", async (HttpRequest request, KavchatClient kavchat) =>
{
    using var ms = new MemoryStream();
    await request.Body.CopyToAsync(ms);
    try
    {
        var evt = kavchat.VerifyWebhook(ms.ToArray(), request.Headers[KavchatWebhook.SignatureHeader]);
        if (evt.Type == "ticket.created") Console.WriteLine($"New ticket {evt.Data.Number}");
        return Results.Ok();
    }
    catch (WebhookVerificationException e)
    {
        return Results.BadRequest(e.Code);
    }
});
Go
http.HandleFunc("/kavchat/webhook", func(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "read error", http.StatusBadRequest)
		return
	}
	event, err := client.VerifyWebhook(body, r.Header.Get(kavchat.SignatureHeader))
	var werr *kavchat.WebhookError
	if errors.As(err, &werr) {
		http.Error(w, werr.Code, http.StatusBadRequest)
		return
	}
	if event.Type == "ticket.created" {
		log.Printf("new ticket %d", event.Data.Number)
	}
})
Ruby
class KavchatWebhooksController < ActionController::API
  def create
    event = KAVCHAT.verify_webhook(request.raw_post, request.headers["Kavchat-Signature"])
    Rails.logger.info("New ticket #{event.data.number}") if event.type == "ticket.created"
    head :ok
  rescue Kavchat::WebhookVerificationError => e
    render plain: e.code, status: :bad_request
  end
end

La tolerancia predeterminada es de 5 minutos (300 segundos). Puedes cambiarla en cualquier SDK, por ejemplo con kavchat.verifyWebhook(body, header, { toleranceSeconds: 300 }) en Node.js.

Sin SDK

Node.js (no SDK)
import crypto from 'node:crypto';

// header: "t=<unix seconds>,v1=<hex>", rawBody: the request body exactly as received
function verifyKavchatSignature(rawBody, header, secretKey) {
  const parts = Object.fromEntries(String(header).split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 minutes
  const expected = crypto.createHmac('sha256', secretKey).update(`${t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 || '', 'hex');
  return given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(expected, 'hex'));
}

Envío y reintentos

  • Un envío se considera correcto si tu servidor responde con un código de estado 2xx en un plazo de 8 segundos.
  • Si el envío falla, se reintenta después de 5 segundos, 30 segundos y 2 minutos: cuatro intentos en total.
  • El usuario no nota nada: el ticket se guarda en la bandeja de tickets del panel de control en cualquier caso y siempre puedes consultarlo a través de la API de servidor.
  • Si tu servidor recibió la solicitud, pero tardó más de 8 segundos en responder, se volverá a enviar. Usa el id del ticket para filtrar duplicados.