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
- Create an HTTPS endpoint on your server that accepts
POSTwith a JSON body. - In the dashboard, open Settings and enter the URL under Webhook.
- Verify the signature of every request, as shown below, and answer with any
2xxstatus.
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.
POST /kavchat/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Kavchat-Webhook/1
Kavchat-Signature: t=1792915200,v1=5f2b7c...e91a{
"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:
tis the time of sending, in Unix seconds.v1isHMAC-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.
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;
}
});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 "", 200use 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);
}@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());
}
}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);
}
});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)
}
})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
endThe 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
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
2xxstatus 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
idto ignore duplicates.