Skip to content
Webhooks

Webhooks Overview

Kirimdev pushes events to your server via signed HTTP POSTs. You subscribe a URL once at the organisation level, pick which events you care about, and Kirimdev takes care of retries, dead-letter queueing, and signing-secret rotation.

Polling GET /v1/{phone_number_id}/messages on a timer is the wrong answer for almost every integration:

  • Latency. Polling at 1 Hz adds an average 500 ms before you notice an inbound message — long enough for your bot to feel sluggish. Webhooks land in under 200 ms from the customer hitting send.
  • Cost. A 1 Hz poll burns 86,400 requests per day per number against your rate limit, almost all of them empty.
  • Race conditions. Status transitions (sentdeliveredread) happen faster than you can poll; you’ll miss intermediate states.

Webhooks invert the relationship: Kirimdev tells you when something happened, you stay idle until it does.

Every webhook POST carries an X-Kirim-Source header. Its value tells you whether the body is Meta passthrough or a Kirimdev-native envelope:

X-Kirim-Source: meta

Events that originated inside Meta — a customer messaged you (message.received), or Meta confirmed a delivery status (message.status). The body uses Meta’s WhatsApp webhook envelope, so existing WhatsApp Cloud API parsers can handle it. Kirimdev preserves the documented status identifiers, errors, pricing, and conversation metadata when Meta supplies them.

X-Kirim-Source: kirim

Events that originated inside Kirimdev — a contact was created or updated, a conversation got assigned or closed. These use the Kirimdev envelope: { id, type, created_at, data }.

The two shapes coexist on the same subscription. Your handler branches on X-Kirim-Source (or just on type after parsing) and routes accordingly.

A webhook subscription is the binding between a URL and a list of event types you care about. Subscriptions are organisation-scoped — by default, one subscription receives events from every WhatsApp account in your org, so you don’t need one subscription per 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.created"],
"description": "production listener"
}'

The response includes an initial_secret shown once. Store it — it’s the signing key for every future delivery to this subscription.

Scoping a subscription to specific phone numbers

Section titled “Scoping a subscription to specific phone numbers”

If you run multiple WhatsApp numbers on the same organisation (e.g. a “Sales” number and a “Support” number) and want to route their events to different endpoints, add a phone_number_ids whitelist:

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/sales",
"events": ["message.received", "message.status"],
"phone_number_ids": ["106540352242922"]
}'
  • Discover the ids via GET /v1/accountsphone_number_id on each row is the Meta business_phone_number_id you pass here.
  • Up to 50 ids per subscription; each must belong to your organisation.
  • On PATCH, send phone_number_ids: null to clear the filter and go back to “all accounts”, or send an array to replace the whitelist.
  • Only events tied to a phone number are filtered: message.*, conversation.*, and contact.*. Organisation-level events (customer.*) always fire regardless of the filter.

Response objects always carry phone_number_idsnull means the subscription is unrestricted, an array is the active whitelist.

Content-Type: application/json
User-Agent: Kirim-Webhook/1.0
X-Kirim-Source: meta # "meta" or "kirim"
X-Kirim-Event: message.received
X-Kirim-Event-Id: wamid.HBgN… # Meta wamid when source=meta, evt_<ulid> when source=kirim
X-Kirim-Delivery-Id: wbd_… # Unique per delivery attempt
X-Kirim-Attempt: 1 # 1-based attempt counter (1-8)
X-Kirim-Signature: t=1716480000,v1=<hex>[,v1=<hex>...]
  • At-least-once. Every event reaches a healthy subscription at least once. Dedupe on your side using X-Kirim-Event-Id.
  • 2xx response = success. Any 2xx (200, 201, 204, …) marks the delivery succeeded. 3xx is treated as failure — Kirimdev does not follow redirects.
  • 10-second per-attempt timeout. Your endpoint must respond in under 10 seconds or the attempt is recorded as a timeout failure.
  • 8 retries with exponential backoff before dead-letter. Total window is roughly 24 hours. See Retries & Auto-Disable for the full schedule.
  • Order is not guaranteed. Events may arrive in a different order than the messages were created — this applies across events of the same type (e.g. two message.sent in one conversation) as well as between types (you can see a status before its message.sent). Do not rely on arrival order or on the event id. To reconstruct message order, sort by the message timestamp in the payload. Treat message.status as a state machine, not a sequence — correlate each status to its message by provider_id (wamid).

Webhook arrival order does not reflect the order messages were created. Sort events by the message timestamp field in the payload to reconstruct the sequence — never by arrival time or by the event id.

Two limitations to design around:

  • Timestamps are second-precision. Message timestamps follow WhatsApp, which reports time to the second. Two messages in the same second cannot be distinguished by timestamp — there is no finer-grained ordering marker available. Display same-second messages as a group, or accept an approximate order within that second.
  • The event id is not time-ordered. It is a random identifier; never sort by it.

If you buffer and reorder on your side, sorting by timestamp is enough for arrival-order scrambling — you only need extra tolerance for messages that share the same second.

Because delivery is at-least-once, your endpoint will occasionally receive the same event twice — typically when a retry fires after your server processed the original but its 200 didn’t reach Kirimdev before the 10 s timeout.

The dedupe key is X-Kirim-Event-Id. Same id = same logical event, regardless of attempt number or whether the delivery is a manual replay.

import { redis } from './lib/redis.js'
app.post('/webhooks/kirim', async (req, res) => {
const eventId = req.headers['x-kirim-event-id'] as string
// Atomic claim — SET NX with a 7-day TTL covers every retry window.
const fresh = await redis.set(`kirim:evt:${eventId}`, '1', {
EX: 60 * 60 * 24 * 7,
NX: true,
})
if (!fresh) {
return res.status(200).send('duplicate-ack')
}
await handleEvent(req.body)
res.status(200).send('ok')
})

Meta itself retries inbound webhooks. To avoid fanning the same Meta retry out to your subscription several times, Kirimdev dedupes source=meta events at the publisher layer using a 24-hour claim on the wamid. By the time an event reaches your endpoint, Meta-driven duplicates are already filtered out.

The only dedupe scenario you need to worry about is the retry-vs-original race described above, not Meta’s own at-least-once retry pattern.

Customer → Meta → Kirimdev → signed POST → your endpoint
│ │
▼ ▼
publisher 2xx ─► succeeded
dedupe + sign non-2xx / timeout ─► retry
(8 attempts, ~24 h)
dead-letter
(replayable)

Verifying signatures

Constant-time HMAC verification recipes for Node, Python, Ruby, Go — plus secret rotation. Read →

Event catalogue

Every event Kirimdev publishes, the source, and what triggers it. Read →

Retries & auto-disable

Backoff schedule, dead-letter queue, replay API, the 24-failure auto-disable policy. Read →

Payload examples

Copy-paste fixtures for every event, both Meta passthrough and Kirimdev-native. Read →

Subscribe to webhooks (guide)

End-to-end recipe — create a subscription, verify the first delivery, ship to production. Read →