Skip to content
Webhooks

Event Catalogue

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.

EventX-Kirim-SourceWhen it fires
message.receivedmetaA customer sent a WhatsApp message to any of your connected numbers.
message.statusmetaMeta emitted an outbound delivery callback: sent, delivered, read, or failed.
message.createdkirimAn outbound message was accepted by kirim.dev and queued for send.
conversation.assignedkirimA conversation’s assigned_to user changed.
conversation.closedkirimA conversation’s status flipped to resolved.
contact.createdkirimA new contact row was inserted (inbound auto-create or POST /v1/{phone_number_id}/contacts).
contact.updatedkirimAn 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.

Inbound 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.

Outbound delivery status. The status field cycles sentdeliveredread. 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": { "...": "..." }
}

Fires 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"
}
}
}
{
"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).

{
"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_…"
}
}
}
{
"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"
}
}
{
"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)",
"email": "[email protected]",
"...": "..."
},
"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:

Terminal window
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.

You 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 →