Webhooks
Webhooks let your backend receive inbound WhatsApp messages as they arrive.
ChatKazi sends each event as an HTTP POST request to your configured URL. Your endpoint should verify the request signature before processing the event and return a 2xx response within 10 seconds.
How webhooks work
Configure an endpoint
Save a public HTTP or HTTPS URL where ChatKazi can send events.
Store the signing secret
The first save returns a signing secret beginning with whsec_. Store it securely because later reads only show whether a secret exists.
Verify every request
Use the signing secret, timestamp header, and raw request body to verify the X-ChatKazi-Signature header.
Process the event
After verification, parse the JSON body and handle the event. Return any 2xx status to mark the delivery as successful.
Configure a webhook
PATCH /webhook
curl -X PATCH "$CHATKAZI_BASE_URL/webhook" \
-H "x-api-key: $CHATKAZI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/chatkazi",
"enabled": true
}'
The first save returns the signing secret once:
{
"message": "Webhook saved.",
"data": {
"url": "https://your-app.com/webhooks/chatkazi",
"enabled": true,
"hasSecret": true
},
"secret": "whsec_your_signing_secret"
}
Webhook request headers
Every delivery includes these headers:
| Header | Description |
|---|---|
X-ChatKazi-Event | Event type, such as message.received. |
X-ChatKazi-Delivery | Unique delivery ID for logging and deduplication. |
X-ChatKazi-Timestamp | ISO 8601 time used to create the signature. |
X-ChatKazi-Signature | HMAC-SHA256 signature in the form sha256=<hex_digest>. |
ChatKazi creates the signature from:
timestamp + "." + raw_request_body
The request body must be used exactly as received. Re-serializing parsed JSON can produce a different signature.
Signing secret scope
Each ChatKazi account has one configured webhook destination and one signing secret. The same secret signs events from every WhatsApp session owned by that account.
- A valid signature proves that ChatKazi created the request for your account.
- The signature does not identify a specific WhatsApp session. Use the payload's
sessionIdfor that. - Rotating the signing secret immediately replaces the previous secret.
- If separate systems need to receive events, configure one webhook endpoint and forward verified events from that endpoint.
Verify the signing secret
Set your signing secret on the server:
CHATKAZI_WEBHOOK_SECRET=whsec_your_signing_secret
In Express, register the webhook route with express.raw() before any global express.json() middleware:
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/webhooks/chatkazi', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.CHATKAZI_WEBHOOK_SECRET
const timestamp = req.get('X-ChatKazi-Timestamp')
const receivedSignature = req.get('X-ChatKazi-Signature')
const rawBody = req.body.toString('utf8')
if (!secret || !timestamp || !receivedSignature) {
return res.status(400).send('Missing webhook signature')
}
const expectedSignature = `sha256=${crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')}`
const received = Buffer.from(receivedSignature)
const expected = Buffer.from(expectedSignature)
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(received, expected)
if (!valid) {
return res.status(401).send('Invalid webhook signature')
}
const event = JSON.parse(rawBody)
if (event.event === 'message.received') {
console.log('Inbound message:', event.message.text)
}
return res.sendStatus(204)
})
app.use(express.json())
app.listen(3000)
X-ChatKazi-Delivery to ignore deliveries you have already processed.Event types
ChatKazi currently sends these webhook event types:
| Event | Sent when |
|---|---|
message.received | A connected WhatsApp session receives a message from a contact, group, channel, or newsletter. |
webhook.test | You request a test delivery with POST /webhook/test. |
ChatKazi does not currently send webhook events for outbound messages, message delivery receipts, session state changes, or QR updates.
Common event fields
Every webhook payload includes these top-level fields:
| Field | Type | Description |
|---|---|---|
event | string | Event type. Use this field to select the correct event handler. |
timestamp | string | ISO 8601 time when ChatKazi created the event. |
customerId | string | ChatKazi account that owns the session and webhook. |
sessionId | string | Connected WhatsApp session that produced the event. Test events use default. |
message.received
Sent when a connected WhatsApp session receives a message.
Messages sent by the connected WhatsApp account itself and WhatsApp status broadcasts are ignored.
Direct message example
{
"event": "message.received",
"timestamp": "2026-06-05T12:00:01.125Z",
"customerId": "customer_id",
"sessionId": "default",
"message": {
"id": "AC7BA799EE6E7A886B3C1BF59F57FDDD",
"from": "254712345678@s.whatsapp.net",
"replyTo": "254712345678@s.whatsapp.net",
"phoneNumber": "254712345678",
"chat": "194446171848909@lid",
"participant": null,
"rawFrom": "194446171848909@lid",
"rawChat": "194446171848909@lid",
"fromMe": false,
"pushName": "Customer",
"type": "conversation",
"text": "Hello",
"interaction": null,
"media": null,
"messageTimestamp": "2026-06-05T12:00:00.000Z"
}
}
Group media message example
{
"event": "message.received",
"timestamp": "2026-06-05T12:05:01.125Z",
"customerId": "customer_id",
"sessionId": "support",
"message": {
"id": "A5376359CDF8B3B2D67FECB863393B54",
"from": "254798765432@s.whatsapp.net",
"replyTo": "120363333241980924@g.us",
"phoneNumber": "254798765432",
"chat": "120363333241980924@g.us",
"participant": "247871504945317@lid",
"rawFrom": "247871504945317@lid",
"rawChat": "120363333241980924@g.us",
"fromMe": false,
"pushName": "Customer",
"type": "imageMessage",
"text": "Invoice attached",
"interaction": null,
"media": {
"type": "image",
"mimetype": "image/jpeg",
"fileName": null,
"caption": "Invoice attached",
"seconds": null
},
"messageTimestamp": "2026-06-05T12:05:00.000Z"
}
}
Message fields
| Field | Type | Description |
|---|---|---|
id | string or null | WhatsApp message ID. Store this if you need to deduplicate events. |
from | string or null | Best available identifier for the sender. ChatKazi resolves WhatsApp LID identifiers to phone-number JIDs when possible. |
replyTo | string or null | Use this value as to when replying. It identifies the direct conversation or group where the reply belongs. |
phoneNumber | string or null | Sender phone number without +, when ChatKazi can resolve one. It can be null for some channels or unresolved identifiers. |
chat | string or null | Original WhatsApp conversation identifier. For direct messages this can be a LID; for groups it ends with @g.us. |
participant | string or null | Original participant identifier for a group message. Usually null for direct messages. |
rawFrom | string or null | Original sender identifier received from WhatsApp before phone-number resolution. |
rawChat | string or null | Original conversation identifier received from WhatsApp. Preserve it for diagnostics, but do not normally use it when replying. |
fromMe | boolean | Whether the connected WhatsApp account sent the message. Currently emitted inbound events have this set to false. |
pushName | string or null | Sender display name supplied by WhatsApp. |
type | string | WhatsApp message content type, such as conversation, extendedTextMessage, imageMessage, or documentMessage. |
text | string | Extracted text, selected response, or media caption. Empty when the message has no supported text value. |
interaction | object or null | Structured reply or list selection. Use interaction.id to route the selected action. |
media | object or null | Media metadata for supported media messages. ChatKazi does not include the media file itself. |
messageTimestamp | string | ISO 8601 time reported by WhatsApp for the original message. |
Identifier fields
WhatsApp can represent a user with a phone-number JID such as 254712345678@s.whatsapp.net or an internal LID such as 194446171848909@lid.
| Goal | Field to use |
|---|---|
| Reply to the message | message.replyTo |
| Identify the sender | message.from |
| Read the sender phone number | message.phoneNumber |
| Determine the original conversation | message.chat |
| Debug the identifiers received from WhatsApp | message.rawFrom and message.rawChat |
sessionId and message.replyTo. Do not build a reply target from phoneNumber, from, chat, rawFrom, or rawChat.For a direct message, replyTo is normally a resolved phone-number JID. For a group message, replyTo is the group JID so your response goes back to the group rather than privately to its participant.
Interactive response example
When a recipient selects a reply button or list row, the same message.received event includes a structured interaction object:
{
"event": "message.received",
"timestamp": "2026-06-07T12:00:01.125Z",
"customerId": "customer_id",
"sessionId": "default",
"message": {
"id": "3EB0F6BBD165A34B",
"from": "254712345678@s.whatsapp.net",
"replyTo": "254712345678@s.whatsapp.net",
"phoneNumber": "254712345678",
"chat": "254712345678@s.whatsapp.net",
"participant": null,
"rawFrom": "254712345678@s.whatsapp.net",
"rawChat": "254712345678@s.whatsapp.net",
"fromMe": false,
"pushName": "Customer",
"type": "interactiveResponseMessage",
"text": "track_order",
"interaction": {
"type": "quick_reply",
"id": "track_order",
"label": null
},
"media": null,
"messageTimestamp": "2026-06-07T12:00:00.000Z"
}
}
| Interaction field | Type | Description |
|---|---|---|
type | string | WhatsApp interaction type, such as quick_reply, list, or single_select. |
id | string or null | Stable action or row ID supplied when the interactive message was sent. Use this to select application behavior. |
label | string or null | Display label when WhatsApp includes it. Do not rely on it as a command ID. |
Concurrent events and reply routing
Webhook deliveries can arrive close together or be processed concurrently. Keep the complete event together while processing it.
Use this pair from the same message.received event when replying:
| Send-message field | Webhook value |
|---|---|
sessionId | Top-level event.sessionId |
to | event.message.replyTo |
Do not store either value in a shared global variable or select the most recently received sender. Queue each event with its own sessionId and replyTo.
Reply example
async function replyToMessage(event) {
const response = await fetch(`${process.env.CHATKAZI_BASE_URL}/messages/text`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.CHATKAZI_API_KEY
},
body: JSON.stringify({
sessionId: event.sessionId,
to: event.message.replyTo,
text: 'Thanks, we received your message.'
})
})
if (!response.ok) {
throw new Error(`Reply failed with HTTP ${response.status}`)
}
}
Never hardcode sessionId when replying to webhook events unless your account only has one session and it is guaranteed to remain that way.
Media fields
The media object is present for image, video, audio, document, and sticker messages.
| Field | Type | Description |
|---|---|---|
type | string | Simplified media type: image, video, audio, document, or sticker. |
mimetype | string or null | Media MIME type reported by WhatsApp. |
fileName | string or null | File name reported for the media, commonly used for documents. |
caption | string or null | Caption attached to an image, video, or document. |
seconds | number or null | Duration reported for audio or video media. |
webhook.test
Sent when you request a test event. Use it to confirm that your destination is reachable and signature verification is configured correctly.
{
"event": "webhook.test",
"timestamp": "2026-06-05T12:10:00.000Z",
"customerId": "customer_id",
"sessionId": "default",
"data": {
"message": "This is a test event from ChatKazi."
}
}
Test event fields
| Field | Type | Description |
|---|---|---|
data.message | string | Human-readable text identifying the payload as a ChatKazi test event. |
Send a test event
POST /webhook/test
curl -X POST "$CHATKAZI_BASE_URL/webhook/test" \
-H "x-api-key: $CHATKAZI_API_KEY"
The test sends a signed webhook.test event to your enabled webhook URL.
Rotate the signing secret
Rotate the secret if it has been exposed or as part of your regular security process:
curl -X PATCH "$CHATKAZI_BASE_URL/webhook" \
-H "x-api-key: $CHATKAZI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/chatkazi",
"enabled": true,
"rotateSecret": true
}'
The response returns the new secret once. Update your webhook endpoint before sending another test event.
Delivery behavior
- ChatKazi considers any
2xxresponse successful. - Requests time out after 10 seconds.
- Failed deliveries are recorded for inspection but are not automatically retried.
GET /webhookreturns your configuration and the 20 most recent deliveries.DELETE /webhookremoves the configured destination.