Docs menu

Webhooks

When a user opens a request from the chat, Kavchat can send it to your system right away: your help desk, CRM or a Slack bot. Every delivery is signed with your secret key.

Webhooks are part of the Premium plan.

Set up

  1. Create an HTTPS endpoint on your server that accepts POST with a JSON body.
  2. In the dashboard, open Settings and enter the URL under Webhook.
  3. Verify the signature of every request, as shown below, and answer with any 2xx status.

The URL must use https:// and point to a public address. Addresses in private networks are blocked.

Payload

Kavchat sends two event types: ticket.created for a request sent from the contact form, and lead.created for details a visitor leaves at a lead step in a script. The data field contains the request or the lead in the same format returned by the Server API, including the conversation transcript.

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 is the sub claim of the identity token when the user was identified, otherwise null. name, email, phone and the topic fields can be null too.

Leads and personal information

A script can ask visitors for a name, phone number, email, number or free text, validate each answer and save it in a variable. A lead step sends these details to the leads inbox in the dashboard. In a lead.created event, data.variables contains all collected values, keyed by variable name. Common values also appear in data.name, data.email and data.phone. Phone numbers use international format (E.164).

These details are personal information. Kavchat stores them only in the lead, request or live chat they belong to, never in analytics or logs. Leads are deleted at the end of your chosen retention period (30 days on the Free plan; Premium also lets you keep them indefinitely). You can delete an individual lead at any time. If you collect personal details, say so in your privacy policy. Use the lead step's consent text to explain to visitors how you will use their details.

The Kavchat-Signature header

The header looks like t=<unix seconds>,v1=<hex>, where:

  • t is the time of sending, in Unix seconds.
  • v1 is HMAC-SHA256(secretKey, "<t>.<raw body>") in hex. The key is your full secret key.

To verify, compute the same HMAC over the raw body exactly as it arrived, compare it in constant time, and reject requests older than 5 minutes. A body that was parsed and serialized again will not match.

Verify with an SDK

verifyWebhook does all of the above and returns the parsed event. On failure it throws an error with a code: malformed_header, timestamp_out_of_range, invalid_signature or invalid_payload. Answer those with HTTP 400. Installing the SDKs is described in Identify users.

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

The default tolerance is 5 minutes (300 seconds). Each SDK lets you change it, for example kavchat.verifyWebhook(body, header, { toleranceSeconds: 300 }) in Node.js.

Without an 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'));
}

Delivery and retries

  • A delivery succeeds when your endpoint answers with a 2xx status within 8 seconds.
  • Failed deliveries are retried after 5 seconds, 30 seconds and 2 minutes: four attempts in all.
  • The user is never affected: the request is saved in the dashboard inbox either way, and you can always read it with the Server API.
  • If your endpoint received a delivery but answered after more than 8 seconds, it is sent again. Use the request id to ignore duplicates.