Меню руководств

Webhook

Когда пользователь создаёт обращение в чате, Kavchat может сразу отправить его в вашу систему: службу поддержки, CRM или боту в Slack. Каждая отправка подписывается вашим секретным ключом.

Webhook входит в тариф Премиум.

Настройка

  1. Создайте на сервере HTTPS-адрес, который принимает POST с телом в формате JSON.
  2. В панели управления откройте Настройки и укажите адрес в разделе Webhook.
  3. Проверяйте подпись каждого запроса, как описано ниже, и отвечайте любым кодом статуса 2xx.

Адрес должен начинаться с https:// и быть общедоступным. Адреса в частных сетях блокируются.

Содержимое сообщения

Kavchat отправляет два типа событий: ticket.created для обращения, отправленного через форму обращения, и lead.created для данных, которые посетитель оставил на шаге сбора лидов в сценарии. Поле data содержит обращение или лид в том же формате, который возвращает серверный API, включая историю переписки.

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 содержит значение поля sub из токена идентификации, иначе null. Поля name, email, phone и поля темы также могут иметь значение null.

Лиды и персональные данные

Сценарий может запрашивать у посетителя имя, телефон, электронную почту, число или произвольный текст, проверять каждый ответ и сохранять его в переменную. Шаг сбора лидов отправляет эти данные в раздел лидов в панели управления. В событии lead.created поле data.variables содержит все собранные значения с именами переменных в качестве ключей. Основные значения также доступны в полях data.name, data.email и data.phone. Номера телефонов передаются в международном формате (E.164).

Эти сведения относятся к персональным данным. Kavchat хранит их только в том лиде, обращении или чате с оператором, к которому они относятся, и никогда не сохраняет в отчётах или журналах. Лиды удаляются по истечении выбранного вами срока хранения (30 дней на бесплатном тарифе; на тарифе Премиум можно также хранить их бессрочно). Отдельный лид можно удалить в любой момент. Если вы собираете персональные данные, укажите это в своей политике конфиденциальности. В тексте согласия на шаге сбора лидов объясните посетителям, как вы будете использовать их данные.

Заголовок Kavchat-Signature

Заголовок имеет вид t=<unix seconds>,v1=<hex>, где:

  • t — время отправки в секундах Unix.
  • v1 — HMAC-SHA256(secretKey, "<t>.<raw body>") в шестнадцатеричном представлении. В качестве ключа используется ваш полный секретный ключ.

Для проверки вычислите тот же HMAC по исходному телу запроса, в точности в том виде, в котором оно пришло, выполните сравнение за постоянное время и отклоняйте запросы старше 5 минут. Если тело запроса было разобрано и заново преобразовано в JSON, подпись не совпадёт.

Проверка с помощью SDK

Функция verifyWebhook выполняет все эти действия и возвращает разобранное событие. При сбое она выбрасывает исключение с кодом malformed_header, timestamp_out_of_range, invalid_signature или invalid_payload. В этих случаях отвечайте кодом HTTP 400. Установка SDK описана в разделе Идентификация пользователей.

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

По умолчанию допустимое отклонение времени составляет 5 минут (300 секунд). В каждом SDK его можно изменить, например kavchat.verifyWebhook(body, header, { toleranceSeconds: 300 }) в Node.js.

Без 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'));
}

Отправка и повторные попытки

  • Отправка считается успешной, если ваш сервер отвечает кодом статуса 2xx в течение 8 секунд.
  • При неудачной отправке повторные попытки выполняются через 5 секунд, 30 секунд и 2 минуты: всего четыре попытки.
  • На пользователя это не влияет: обращение в любом случае сохраняется в разделе обращений в панели управления, и его всегда можно получить через серверный API.
  • Если ваш сервер получил запрос, но ответил позже чем через 8 секунд, запрос будет отправлен повторно. Используйте id обращения, чтобы отфильтровывать дубликаты.