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, failed, or played (voice notes).
message.sentkirimKirimdev accepted an outbound send (API, dashboard, broadcast, or phone-app echo) and Meta returned a wamid. Full message body included.
message.revokedkirimA customer deleted (“delete for everyone”) a previously sent message on their phone. Coexistence-only.
message.editedkirimA customer edited a previous message AND Meta forwarded the new body. Coexistence-only. Currently dormant — see Meta advisory below.
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.
contact.identity_updatedkirimMeta rotated a contact’s Business-Scoped User ID (BSUID) — typically after the user changed their phone number.
customer.createdkirimA new end-customer (multi-tenant platform mode) row was inserted.
customer.updatedkirimAn end-customer’s name, email, metadata, or status changed.
customer.archivedkirimAn end-customer was soft-archived.
customer.onboardedkirimAn end-customer connected their first WhatsApp number via a setup link.
customer.setup_link.createdkirimAn operator generated a setup link for an end-customer.
customer.setup_link.consumedkirimAn end-customer completed onboarding through a setup link (paired with customer.onboarded).

For message.received and message.status, Kirimdev forwards the exact JSON Meta sent. Refer to Meta’s WhatsApp Cloud API webhook reference for the schema — it never changes shape going through Kirimdev. The X-Kirim-Event-Id header carries Meta’s wamid, suitable for dedupe.

The top-level body is Meta’s envelope:

{
"object": "whatsapp_business_account",
"entry": [
{
"id": "<WABA id>",
"changes": [
{ "field": "messages", "value": { /* Meta value object */ } }
]
}
]
}

Branch on the X-Kirim-Event request header (message.received vs message.status) to decide how to parse entry[0].changes[0].value.

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[0].changes[0].value.metadata.phone_number_id.

kirim enrichment (optional). Since June 2026, message.received bodies may include a top-level kirim object with Kirimdev resource ids, conversation labels, assignee, and — for media messages — a hosted media_url on media.kirimdev.com. Parsers that only walk entry[] can ignore kirim. Text messages fire immediately; inbound media waits until the file is stored (~1–3 s) so kirim.media_url is ready on the first delivery.

Outbound delivery status. The status field cycles sentdeliveredread. Failed sends emit a single failed status with an errors[] array. Outbound voice notes may also emit played when the recipient first plays the audio — independent of read receipts. Kirimdev persists the first played moment as played_at on the message (Public API) using Meta’s webhook timestamp. Statuses may arrive out of order; treat them as a state machine, not a sequence.

Fires when Kirimdev successfully sends an outbound message and Meta returns a wamid. Covers sends from the Public API, dashboard composer, broadcast campaigns, and coexistence phone-app echoes (source: app).

Headers: X-Kirim-Source: kirim, X-Kirim-Event: message.sent

Body — Kirimdev-native envelope with a normalized outbound payload in data. Use data.session (alias for meta.phone_number_id) to route multi-number CRM integrations on one webhook URL.

{
"id": "msg_01HXYZABCDEFGHJKMNPQRSTVWX:sent",
"type": "message.sent",
"created_at": "2026-06-16T10:00:00.000Z",
"data": {
"session": "106540352242922",
"direction": "outbound",
"from_me": true,
"timestamp": "2026-06-16T10:00:00.000Z",
"message": {
"id": "msg_01HXYZABCDEFGHJKMNPQRSTVWX",
"provider_id": "wamid.HBgN…",
"type": "text",
"body": "Baik kak, ini nomor rekeningnya ya.",
"from": "+6288770000000",
"to": "+6282297983399",
"status": "sent",
"source": "api",
"reply_to_provider_id": null
},
"conversation": {
"id": "cnv_01HXYZABCDEFGHJKMNPQRSTVWX",
"status": "open"
},
"contact": {
"id": "ctc_01HXYZABCDEFGHJKMNPQRSTVWX",
"phone_number": "+6282297983399",
"name": "Customer Name"
},
"meta": {
"phone_number_id": "106540352242922",
"display_phone_number": "6288770000000",
"provider": "kirimdev"
}
}
}

Dedupe on X-Kirim-Event-Id — stable per message as {msg_external_id}:sent. Broadcast recipients use broadcast_msg:{internal_id}:sent and set message.source to broadcast (no msg_* row exists for those sends).

Pair with message.status for delivery/read receipts — message.sent carries the body; message.status carries Meta’s status transitions.

Media (image, video, audio, document, sticker): the message block includes media_url (permanent https://media.kirimdev.com/... URL), media_status (ready when the URL is available), and a Meta-style nested block (image, video, document, etc.) with link + caption so parsers that expect the inbound wire shape work without remapping. For API and dashboard sends the URL is present when the webhook fires. Phone-app echoes (source: app) with media defer the message.sent webhook until Kirimdev finishes storing the asset — same discipline as inbound message.received. Text echoes still fire immediately.

"message": {
"id": "msg_01HXYZABCDEFGHJKMNPQRSTVWX",
"provider_id": "wamid.HBgN…",
"type": "image",
"body": "Bukti transfer",
"media_url": "https://media.kirimdev.com/whatsapp/<accountId>/<messageId>/bukti-transfer.jpg",
"media_status": "ready",
"image": {
"link": "https://media.kirimdev.com/whatsapp/<accountId>/<messageId>/bukti-transfer.jpg",
"caption": "Bukti transfer"
},
"from": "+6288770000000",
"to": "+6282297983399",
"status": "sent",
"source": "api",
"reply_to_provider_id": null
}

Kirimdev-native events (message.sent, conversation.*, contact.*, and customer.*) use a standard envelope:

{
"id": "evt_01HXYZABCDEFGHJKMNPQRSTVWX",
"type": "contact.created",
"created_at": "2026-05-23T10:00:00Z",
"data": { "...": "..." }
}

The envelope has exactly four fields: id, type, created_at, data. There is no livemode field — Kirimdev does not split test/live environments today.

A WhatsApp contact deleted (“delete for everyone”) a previously sent message on their phone. Meta only emits revoke webhooks for Coexistence numbers (users onboarded via the WhatsApp Business app with Cloud API coexistence) — pure Cloud API numbers never receive revoke events regardless of your subscription.

{
"id": "evt_01HXYZABCDEFGHJKMNPQRSTVWX",
"type": "message.revoked",
"created_at": "2026-07-11T15:34:10.000Z",
"data": {
"message_id": "msg_01HXYZABCDEFGHJKMNPQRSTVXY",
"provider_id": "wamid.HBgLMTY1MDM4Nzk0MzkV...",
"conversation_id": "cnv_01HXYZABCDEFGHJKMNPQRSTVWZ",
"contact_id": "ctc_01HXYZABCDEFGHJKMNPQRSTVW0",
"contact_phone_number": "+6285887130408",
"phone_number_id": "890836697444150",
"display_phone_number": "6281295648580",
"revoked_at": "2026-07-11T15:34:10.000Z"
}
}

The message row is NOT deleted from Kirimdev — it is flagged is_revoked: true so the audit trail is preserved. Subsequent GET /v1/{phone_number_id}/messages/{id} calls still return the row with the original body; add your own read-side filter if you want to hide revoked messages from a downstream UI.

A customer edited a previous message AND Meta forwarded the new body. Coexistence-only.

{
"id": "evt_...",
"type": "message.edited",
"created_at": "2026-07-11T15:33:22.000Z",
"data": {
"message_id": "msg_01HXYZABCDEFGHJKMNPQRSTVXY",
"provider_id": "wamid.HBgLMTY1MDM4Nzk0MzkV...",
"conversation_id": "cnv_...",
"contact_id": "ctc_...",
"contact_phone_number": "+6285887130408",
"phone_number_id": "890836697444150",
"display_phone_number": "6281295648580",
"new_content": "Halooee",
"original_content": "Halo",
"edited_at": "2026-07-11T15:33:22.000Z",
"edit_history": [
{ "content": "Halo", "edited_at": "2026-07-11T15:33:15.000Z" }
]
}
}

original_content is the FIRST version of the message and never overwrites through subsequent edits. edit_history is the chronological revision log (Meta only allows edits within 15 minutes of the original send).

For every inbound message where type === "unsupported", the Meta passthrough body now carries an unsupported.type field with Meta’s own sub-kind hint. This helps you differentiate common cases:

  • "edit" — the contact edited a previous message. Meta refused to forward the new body (see the advisory above).
  • "unknown" — a message type Meta itself cannot classify (typically a new WhatsApp product not yet in the Cloud API contract).

Kirimdev does not restrict this to a fixed set — Meta may introduce further sub-kinds (view_once, poll, …) at any time.

"messages": [{
"from": "6285887130408",
"id": "wamid...",
"timestamp": "1783760270",
"type": "unsupported",
"unsupported": { "type": "edit" }
}]

The same hint is surfaced on the REST read endpoints — GET /v1/{phone_number_id}/messages and GET /v1/{phone_number_id}/messages/{id} now emit an unsupported: { type } object on unsupported rows:

{
"id": "msg_...",
"object": "message",
"type": "unsupported",
"content": null,
"unsupported": { "type": "edit" },
"status": "delivered",
"...": "..."
}

The unsupported field is only present when populated; non-unsupported rows and older rows ingested before this feature omit it.

{
"id": "evt_…",
"type": "conversation.assigned",
"created_at": "2026-05-23T10:00:00Z",
"data": {
"conversation": {
"id": "cnv_01HXYZABCDEFGHJKMNPQRSTVWX",
"object": "conversation",
"status": "open"
},
"assignee": {
"user_id": "U7d2k9mq",
"team_id": "tem_01HXYZ…",
"previous_user_id": null
}
}
}

user_id / previous_user_id are raw internal user identifiers (nanoid strings), not kirim-prefixed resource ids. 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",
"data": {
"conversation": {
"id": "cnv_…",
"object": "conversation",
"status": "resolved",
"closed_by_user_id": "U7d2k9mq"
}
}
}
{
"id": "evt_…",
"type": "contact.created",
"created_at": "2026-05-23T10:00:00Z",
"data": {
"contact": {
"id": "ctc_01HXYZABCDEFGHJKMNPQRSTVWX",
"object": "contact",
"phone_number": "+628111111111",
"name": "John Doe",
"email": null,
"metadata": null,
"phone_number_id": "106540352242922",
"created_at": "2026-05-23T10:00:00Z",
"updated_at": "2026-05-23T10:00:00Z"
}
}
}
{
"id": "evt_…",
"type": "contact.updated",
"created_at": "2026-05-23T10:00:00Z",
"data": {
"contact": {
"id": "ctc_…",
"object": "contact",
"phone_number": "+628111111111",
"name": "John Doe (updated)",
"email": "john@example.com",
"...": "..."
},
"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.

Fires when Meta rotates a contact’s Business-Scoped User ID (BSUID). Meta regenerates BSUIDs when a WhatsApp user changes their phone number — existing conversations still work (the BSUID is stable across username changes), but every downstream cache keyed on BSUID must be refreshed.

{
"id": "evt_…",
"type": "contact.identity_updated",
"created_at": "2026-07-01T02:00:00Z",
"data": {
"contact_id": "ctc_…",
"old_bsuid": "US.13491208655302741918",
"new_bsuid": "US.20393104582938104571",
"old_parent_bsuid": null,
"new_parent_bsuid": null,
"phone_number": "628123456789"
}
}
  • old_bsuid / new_bsuid — always present. The mapping is transparent to the underlying contact_id; existing contacts.retrieve() calls keep working.
  • old_parent_bsuid / new_parent_bsuid — populated only for managed businesses that enrolled in the parent BSUID program.
  • phone_number — the phone Meta gave us alongside the rotation, if any (may be null).

Consumers that cache BSUID → user mappings should invalidate old_bsuid and refetch by contact_id. Kirimdev’s own contact row is auto-migrated to the new BSUID before this event fires.

Six customer.* events fire across an end-customer’s onboarding lifecycle. They share the standard Kirimdev-native envelope but only matter if you’re running multi-tenant platform mode.

EventTriggered by
customer.createdPOST /v1/customers
customer.updatedPATCH /v1/customers/{id}
customer.archivedDELETE /v1/customers/{id}
customer.onboardedEnd-customer completes Embedded Signup via a setup link.
customer.setup_link.createdPOST /v1/customers/{id}/setup_links
customer.setup_link.consumedA setup link is consumed (paired with customer.onboarded).

Full payload reference and field tables live on the platform docs: Platform → Webhooks.

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.kirimdev.com/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",
"contact.identity_updated",
"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",
"description": "production listener",
"disabled_reason": null,
"consecutive_failures": 0,
"secrets": [
{ "id": "sec_01HXYZ…", "created_at": "2026-05-23T10:00:00Z" }
],
"initial_secret": "whsec_…",
"created_at": "2026-05-23T10:00:00Z",
"updated_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 →