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, failed, or played (voice notes). |
message.sent | kirim | Kirimdev accepted an outbound send (API, dashboard, broadcast, or phone-app echo) and Meta returned a wamid. Full message body included. |
message.revoked | kirim | A customer deleted (“delete for everyone”) a previously sent message on their phone. Coexistence-only. |
message.edited | kirim | A customer edited a previous message AND Meta forwarded the new body. Coexistence-only. Currently dormant — see Meta advisory below. |
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. |
contact.identity_updated | kirim | Meta rotated a contact’s Business-Scoped User ID (BSUID) — typically after the user changed their phone number. |
customer.created | kirim | A new end-customer (multi-tenant platform mode) row was inserted. |
customer.updated | kirim | An end-customer’s name, email, metadata, or status changed. |
customer.archived | kirim | An end-customer was soft-archived. |
customer.onboarded | kirim | An end-customer connected their first WhatsApp number via a setup link. |
customer.setup_link.created | kirim | An operator generated a setup link for an end-customer. |
customer.setup_link.consumed | kirim | An 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.
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[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.
message.statusOutbound delivery status. The status field cycles
sent → delivered → read. 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.
message.sentFires 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.
message.revokedA 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.
message.editedA 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).
unsupported.type hint on message.receivedFor 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.
conversation.assigned{ "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).
conversation.closed{ "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" } }}contact.created{ "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" } }}contact.updated{ "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.
contact.identity_updatedFires 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.
| Event | Triggered by |
|---|---|
customer.created | POST /v1/customers |
customer.updated | PATCH /v1/customers/{id} |
customer.archived | DELETE /v1/customers/{id} |
customer.onboarded | End-customer completes Embedded Signup via a setup link. |
customer.setup_link.created | POST /v1/customers/{id}/setup_links |
customer.setup_link.consumed | A 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:
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.
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', '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.kirimdev.com/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", "contact.identity_updated", "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 →