Verifying signatures
Verify the HMAC on every delivery before trusting the payload. Read →
Webhook subscriptions are organisation-scoped. One subscription receives the events you select, across every WhatsApp account in your organisation. Choose the events you want when you create the subscription, and update the list at any time.
| Event | X-Kirim-Source | When it fires |
|---|---|---|
message.received | meta | A customer sent a WhatsApp message to any of your connected numbers. |
message.status | meta | Meta emitted an outbound delivery callback: sent, delivered, read, or failed. |
message.created | kirim | An outbound message was accepted by kirim.dev and queued for send. |
conversation.assigned | kirim | A conversation’s assigned_to user changed. |
conversation.closed | kirim | A conversation’s status flipped to resolved. |
contact.created | kirim | A new contact row was inserted (inbound auto-create or POST /v1/{phone_number_id}/contacts). |
contact.updated | kirim | An existing contact’s name, email, labels, or metadata changed. |
For message.received and message.status, kirim.dev forwards the
exact JSON Meta sent. Refer to Meta’s
WhatsApp Cloud API webhook reference
for the schema — it never changes shape going through kirim.dev. The
X-Kirim-Event-Id header carries Meta’s wamid, suitable for
dedupe.
See Payload examples for fully realised fixtures.
message.receivedInbound message from a customer. Body type is whatever the customer
sent: text, image, document, video, audio, location,
contacts, interactive (button/list reply), reaction, or
button (template reply).
The relevant phone_number_id to use for follow-up sends or media
fetches is at entry[].changes[].value.metadata.phone_number_id.
message.statusOutbound delivery status. The status field cycles
sent → delivered → read. Failed sends emit a single failed
status with an errors[] array. Statuses may arrive out of order;
treat them as a state machine, not a sequence.
Kirim-native events use a standard envelope:
{ "id": "evt_01HXYZABCDEFGHJKMNPQRSTVWX", "type": "contact.created", "created_at": "2026-05-23T10:00:00Z", "livemode": true, "data": { "...": "..." }}message.createdFires the moment kirim.dev accepts an outbound message and enqueues it. Use this to update UI optimistically — you don’t have to wait for Meta.
{ "id": "evt_…", "type": "message.created", "created_at": "2026-05-23T10:00:00Z", "livemode": true, "data": { "message": { "id": "msg_01HXYZABCDEFGHJKMNPQRSTVWX", "object": "message", "whatsapp_account_id": "wba_01HXYZ…", "phone_number_id": "106540352242922", "to": "628111111111", "type": "text", "status": "queued", "conversation_id": "cnv_…", "created_at": "2026-05-23T10:00:00Z" } }}conversation.assigned{ "id": "evt_…", "type": "conversation.assigned", "created_at": "2026-05-23T10:00:00Z", "livemode": true, "data": { "conversation": { "id": "cnv_01HXYZABCDEFGHJKMNPQRSTVWX", "object": "conversation", "status": "open" }, "assignee": { "user_id": "usr_01HXYZ…", "team_id": "tem_01HXYZ…", "previous_user_id": null } }}previous_user_id is the user the conversation was assigned to
before the change (null on first assignment).
conversation.closed{ "id": "evt_…", "type": "conversation.closed", "created_at": "2026-05-23T10:00:00Z", "livemode": true, "data": { "conversation": { "id": "cnv_…", "object": "conversation", "status": "resolved", "closed_by_user_id": "usr_…" } }}contact.created{ "id": "evt_…", "type": "contact.created", "created_at": "2026-05-23T10:00:00Z", "livemode": true, "data": { "contact": { "id": "ctc_01HXYZABCDEFGHJKMNPQRSTVWX", "object": "contact", "phone_number": "+628111111111", "name": "John Doe", "email": null, "metadata": null, "whatsapp_account_id": "wba_01HXYZ…", "phone_number_id": "106540352242922", "created_at": "2026-05-23T10:00:00Z", "updated_at": "2026-05-23T10:00:00Z" }, "acquisition_source": "organic" }}contact.updated{ "id": "evt_…", "type": "contact.updated", "created_at": "2026-05-23T10:00:00Z", "livemode": true, "data": { "contact": { "id": "ctc_…", "object": "contact", "phone_number": "+628111111111", "name": "John Doe (updated)", "...": "..." }, "changed_fields": ["name", "email"] }}changed_fields lists only field names that actually differ from the
prior version — useful for routing minimal handlers. The event is
suppressed when no fields actually changed.
Subscribe via the dashboard (Developers → Webhooks → Create) or
the API. Subscriptions are organisation-scoped and live at the
root of /v1 — not under a phone number:
curl -X POST 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", "message.created", "conversation.assigned", "conversation.closed", "contact.created", "contact.updated" ], "description": "production listener" }'Response:
{ "data": { "id": "wbs_01HXYZABCDEFGHJKMNPQRSTVWX", "object": "webhook_subscription", "url": "https://your-app.example.com/webhooks/kirim", "events": ["message.received", "..."], "status": "active", "initial_secret": "whsec_…", "created_at": "2026-05-23T10:00:00Z" }}initial_secret is returned once. Store it now — you can mint
additional secrets later via /secrets, but you can’t re-read the
original.
import { Kirim } from '@kirimdev/sdk'
const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })
const subscription = await kirim.webhookSubscriptions.create({ url: 'https://your-app.example.com/webhooks/kirim', events: [ 'message.received', 'message.status', 'message.created', 'conversation.assigned', 'conversation.closed', 'contact.created', 'contact.updated', ], description: 'production listener',})
console.log(subscription.id) // wbs_01HXYZ…console.log(subscription.initial_secret) // whsec_… — persist nowimport osimport httpx
resp = 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", "message.created", "conversation.assigned", "conversation.closed", "contact.created", "contact.updated", ], "description": "production listener", },)resp.raise_for_status()sub = resp.json()["data"]print(sub["id"], sub["initial_secret"]) # store the secretYou may subscribe to a subset — passing ["message.received"] only
fans out that event type. Edit the subscribed list at any time:
await kirim.webhookSubscriptions.update('wbs_…', { events: ['message.received', 'message.status'],})Verifying signatures
Verify the HMAC on every delivery before trusting the payload. Read →
Retries & auto-disable
What happens when your endpoint returns a non-2xx, and how to re-enable. Read →
Payload examples
Full body fixtures for every event in this catalogue. Read →
Subscribe to webhooks (guide)
Hands-on walk-through from subscription create to first verified delivery. Read →