Verify signatures
Language-specific HMAC recipes, multi-secret verification, timing attacks. Read the concept →
Webhooks are how Kirim pushes things at you in real time — inbound WhatsApp messages, delivery status callbacks, conversation assignments, contact changes. By the end of this guide you will have a working endpoint that subscribes to the events you care about, verifies their signatures, and deduplicates retries.
A Kirim API key (kdv_live_...).
A publicly reachable HTTPS endpoint for Kirim to POST to. Plain
HTTP is rejected at subscription time. For local development,
expose your dev server via ngrok, cloudflared tunnel, or
similar:
# Pick one:ngrok http 3000cloudflared tunnel --url http://localhost:3000Then use the generated https://*.ngrok-free.app URL.
An endpoint that:
POST application/json.2xx for success. Anything else (3xx, 4xx except
408 / 429, or 5xx) marks the delivery as failed and triggers
a retry.Pick your events.
Subscribe only to what you actually handle — every extra event is a request you have to ack. The most common starter set:
| Event | Use case |
|---|---|
message.received | Inbound WhatsApp messages from customers. |
message.status | Outbound delivery callbacks (sent → delivered → read) and failures. |
conversation.assigned | Sync conversation ownership into your CRM / inbox. |
conversation.closed | Trigger post-resolution flows (NPS, CSAT, ticket close). |
contact.created | New WhatsApp contact appeared — sync to CRM. |
contact.updated | Existing contact’s name, email, or metadata changed. |
Browse the full list in the Event Catalog.
Create the subscription.
Webhook subscriptions are organization-level — one
subscription receives events across all your connected phone
numbers. There is no phone_number_id in the path.
curl -sS https://api.kirim.chat/v1/webhook_subscriptions \ -H "Authorization: Bearer $KIRIM_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/kirim", "events": [ "message.received", "message.status", "conversation.assigned", "conversation.closed", "contact.created", "contact.updated" ], "description": "Production handler" }'import { Kirim } from '@kirimdev/sdk'
const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })
const sub = await kirim.webhookSubscriptions.create({ url: 'https://your-app.example.com/webhooks/kirim', events: [ 'message.received', 'message.status', 'conversation.assigned', 'conversation.closed', 'contact.created', 'contact.updated', ], description: 'Production handler',})
console.log(sub.id, sub.initial_secret)import os, httpx
r = httpx.post( "https://api.kirim.chat/v1/webhook_subscriptions", headers={"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"}, json={ "url": "https://your-app.example.com/webhooks/kirim", "events": [ "message.received", "message.status", "conversation.assigned", "conversation.closed", "contact.created", "contact.updated", ], "description": "Production handler", },)r.raise_for_status()print(r.json()["data"]["id"], r.json()["data"]["initial_secret"])Response:
{ "data": { "id": "wbs_01HXYZABCDEFGHJKMNPQRSTVWX", "object": "webhook_subscription", "url": "https://your-app.example.com/webhooks/kirim", "status": "active", "events": ["message.received", "message.status", "..."], "secrets": [ { "id": "sec_01HXYZABCDEFGHJKMNPQRSTVWX", "created_at": "2026-05-23T10:00:00Z", "expires_at": null } ], "initial_secret": "whsec_…" }, "request_id": "req_…"}Store the initial_secret immediately.
Kirim returns initial_secret once, on creation, and never
again. Drop it straight into your secrets manager (AWS Secrets
Manager, Doppler, 1Password, .env.local for dev — wherever your
app reads runtime config from).
Verify the signature on every request.
Kirim signs every webhook delivery with HMAC-SHA256 over the raw request body. Reject any request where the signature doesn’t match — that’s how you know it actually came from Kirim and not a spoofer who guessed your URL.
// Express example — note the raw body middleware.import crypto from 'node:crypto'import express from 'express'
const app = express()const secret = process.env.KIRIM_WEBHOOK_SECRET!
function verify(rawBody: string, signature: string): boolean { const expected = crypto .createHmac('sha256', secret) .update(rawBody) .digest('hex') return crypto.timingSafeEqual( Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'), )}
app.post( '/webhooks/kirim', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.header('x-kirim-signature') ?? '' if (!verify(req.body.toString('utf8'), sig)) { return res.status(401).send('invalid signature') } // ... proceed to step 5 ... },)The TypeScript SDK ships a helper:
import { verifyWebhookSignature } from '@kirimdev/sdk/webhooks'
const ok = verifyWebhookSignature({ payload: rawBody, signature: req.headers['x-kirim-signature'] as string, secret: process.env.KIRIM_WEBHOOK_SECRET!,})For full recipes (Python, Go, Ruby, multiple active secrets, timestamp validation), see Verifying Signatures.
Deduplicate on X-Kirim-Event-Id.
Kirim delivers at-least-once. The same event may arrive twice if your handler processed it but the response didn’t reach Kirim before the 10-second timeout. Use the header as your dedup key:
const eventId = req.header('x-kirim-event-id')!const fresh = await redis.set( `webhook:event:${eventId}`, '1', 'EX', 604800, // 7 days — covers Kirim's full retry budget 'NX',)if (!fresh) { return res.status(200).send('duplicate-ack')}If your stack doesn’t have Redis handy, a unique index on an
events_seen(event_id) table works equally well.
Handle the event.
Every Kirim webhook payload includes a type field. Branch on it:
app.post( '/webhooks/kirim', express.raw({ type: 'application/json' }), async (req, res) => { // signature verification + dedup, as above ...
const event = JSON.parse(req.body.toString('utf8'))
switch (event.type) { case 'message.received': await onInboundMessage(event.data) break case 'message.status': await onStatusUpdate(event.data) break case 'conversation.assigned': await onConversationAssigned(event.data) break case 'conversation.closed': await onConversationClosed(event.data) break case 'contact.created': case 'contact.updated': await syncContactToCrm(event.data) break default: console.warn('Unhandled event type:', event.type) }
res.status(200).send('ok') },)For event payload shapes, see the Event Catalog and Payload Examples.
Test the round-trip locally.
With ngrok / cloudflared running, send a test message to your
connected WhatsApp number from your phone. You should see your
endpoint receive a message.received event within a second or
two.
No event? Check the Developers → Webhook Deliveries page in the Kirim dashboard, or list recent deliveries via the API:
curl -sS "https://api.kirim.chat/v1/webhook_deliveries?subscription_id=wbs_…&limit=10" \ -H "Authorization: Bearer $KIRIM_KEY"The response_status_code and response_body on each delivery
tell you exactly how your endpoint replied.
A leaked secret should be rotatable in minutes without downtime:
Create a new secret. POST /v1/webhook_subscriptions/{id}/secrets
returns a fresh plaintext. Kirim now signs every delivery with
both secrets (legacy and new) for the overlap window.
Add the new secret to your verifier. Update your secrets manager, redeploy. Your verifier now accepts either signature.
Confirm new signatures verify. Watch a few deliveries through the dashboard.
Delete the old secret. DELETE /v1/webhook_subscriptions/{id}/secrets/{old_id}.
Kirim stops signing with it on the next event.
Verify signatures
Language-specific HMAC recipes, multi-secret verification, timing attacks. Read the concept →
Event catalog
Every event type, when it fires, and its payload shape. Read the reference →
Payload examples
Copy-pasteable fixtures for every event — useful for tests. Read the reference →
Retries & auto-disable
Backoff schedule, dead-letter queue, replay APIs. Read the concept →