Skip to content
Webhooks

Webhooks Overview

kirim.dev 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 kirim.dev 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: kirim.dev 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 Kirim-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 is the exact JSON Meta sent, so any existing WhatsApp Cloud API parser keeps working unchanged.

X-Kirim-Source: kirim

Events that originated inside kirim.dev — a contact was created, a conversation got assigned, an outbound message was queued. These use the Kirim envelope: { id, type, created_at, livemode, 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 — one subscription receives events from every WhatsApp account in your org. You don’t need one subscription per 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", "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.

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 — kirim.dev 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. Status callbacks may arrive out of order (you can see delivered before sent). Treat status as a state machine, not a sequence.

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 kirim.dev 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, kirim.dev 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 → kirim.dev → 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 kirim.dev 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 Kirim-native. Read →

Subscribe to webhooks (guide)

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