Skip to content
Guides

Subscribe to Webhooks

Webhooks are how Kirim pushes things at you in real time — inbound WhatsApp messages, delivery status callbacks, conversation assignments, contact changes. By the end of this guide you will have a working endpoint that subscribes to the events you care about, verifies their signatures, and deduplicates retries.

  • A Kirim API key (kdv_live_...).

  • A publicly reachable HTTPS endpoint for Kirim to POST to. Plain HTTP is rejected at subscription time. For local development, expose your dev server via ngrok, cloudflared tunnel, or similar:

    Terminal window
    # Pick one:
    ngrok http 3000
    cloudflared tunnel --url http://localhost:3000

    Then use the generated https://*.ngrok-free.app URL.

  • An endpoint that:

    • Accepts POST application/json.
    • Responds within 10 seconds (per-attempt timeout).
    • Returns any 2xx for success. Anything else (3xx, 4xx except 408 / 429, or 5xx) marks the delivery as failed and triggers a retry.
  1. Pick your events.

    Subscribe only to what you actually handle — every extra event is a request you have to ack. The most common starter set:

    EventUse case
    message.receivedInbound WhatsApp messages from customers.
    message.statusOutbound delivery callbacks (sentdeliveredread) and failures.
    conversation.assignedSync conversation ownership into your CRM / inbox.
    conversation.closedTrigger post-resolution flows (NPS, CSAT, ticket close).
    contact.createdNew WhatsApp contact appeared — sync to CRM.
    contact.updatedExisting contact’s name, email, or metadata changed.

    Browse the full list in the Event Catalog.

  2. Create the subscription.

    Webhook subscriptions are organization-level — one subscription receives events across all your connected phone numbers. There is no phone_number_id in the path.

    Terminal window
    curl -sS 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",
    "conversation.assigned",
    "conversation.closed",
    "contact.created",
    "contact.updated"
    ],
    "description": "Production handler"
    }'

    Response:

    {
    "data": {
    "id": "wbs_01HXYZABCDEFGHJKMNPQRSTVWX",
    "object": "webhook_subscription",
    "url": "https://your-app.example.com/webhooks/kirim",
    "status": "active",
    "events": ["message.received", "message.status", "..."],
    "secrets": [
    {
    "id": "sec_01HXYZABCDEFGHJKMNPQRSTVWX",
    "created_at": "2026-05-23T10:00:00Z",
    "expires_at": null
    }
    ],
    "initial_secret": "whsec_…"
    },
    "request_id": "req_…"
    }
  3. Store the initial_secret immediately.

    Kirim returns initial_secret once, on creation, and never again. Drop it straight into your secrets manager (AWS Secrets Manager, Doppler, 1Password, .env.local for dev — wherever your app reads runtime config from).

  4. Verify the signature on every request.

    Kirim signs every webhook delivery with HMAC-SHA256 over the raw request body. Reject any request where the signature doesn’t match — that’s how you know it actually came from Kirim and not a spoofer who guessed your URL.

    // Express example — note the raw body middleware.
    import crypto from 'node:crypto'
    import express from 'express'
    const app = express()
    const secret = process.env.KIRIM_WEBHOOK_SECRET!
    function verify(rawBody: string, signature: string): boolean {
    const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
    return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(signature, 'hex'),
    )
    }
    app.post(
    '/webhooks/kirim',
    express.raw({ type: 'application/json' }),
    (req, res) => {
    const sig = req.header('x-kirim-signature') ?? ''
    if (!verify(req.body.toString('utf8'), sig)) {
    return res.status(401).send('invalid signature')
    }
    // ... proceed to step 5 ...
    },
    )

    The TypeScript SDK ships a helper:

    import { verifyWebhookSignature } from '@kirimdev/sdk/webhooks'
    const ok = verifyWebhookSignature({
    payload: rawBody,
    signature: req.headers['x-kirim-signature'] as string,
    secret: process.env.KIRIM_WEBHOOK_SECRET!,
    })

    For full recipes (Python, Go, Ruby, multiple active secrets, timestamp validation), see Verifying Signatures.

  5. Deduplicate on X-Kirim-Event-Id.

    Kirim delivers at-least-once. The same event may arrive twice if your handler processed it but the response didn’t reach Kirim before the 10-second timeout. Use the header as your dedup key:

    const eventId = req.header('x-kirim-event-id')!
    const fresh = await redis.set(
    `webhook:event:${eventId}`,
    '1',
    'EX', 604800, // 7 days — covers Kirim's full retry budget
    'NX',
    )
    if (!fresh) {
    return res.status(200).send('duplicate-ack')
    }

    If your stack doesn’t have Redis handy, a unique index on an events_seen(event_id) table works equally well.

  6. Handle the event.

    Every Kirim webhook payload includes a type field. Branch on it:

    app.post(
    '/webhooks/kirim',
    express.raw({ type: 'application/json' }),
    async (req, res) => {
    // signature verification + dedup, as above ...
    const event = JSON.parse(req.body.toString('utf8'))
    switch (event.type) {
    case 'message.received':
    await onInboundMessage(event.data)
    break
    case 'message.status':
    await onStatusUpdate(event.data)
    break
    case 'conversation.assigned':
    await onConversationAssigned(event.data)
    break
    case 'conversation.closed':
    await onConversationClosed(event.data)
    break
    case 'contact.created':
    case 'contact.updated':
    await syncContactToCrm(event.data)
    break
    default:
    console.warn('Unhandled event type:', event.type)
    }
    res.status(200).send('ok')
    },
    )

    For event payload shapes, see the Event Catalog and Payload Examples.

  7. Test the round-trip locally.

    With ngrok / cloudflared running, send a test message to your connected WhatsApp number from your phone. You should see your endpoint receive a message.received event within a second or two.

    No event? Check the Developers → Webhook Deliveries page in the Kirim dashboard, or list recent deliveries via the API:

    Terminal window
    curl -sS "https://api.kirim.chat/v1/webhook_deliveries?subscription_id=wbs_…&limit=10" \
    -H "Authorization: Bearer $KIRIM_KEY"

    The response_status_code and response_body on each delivery tell you exactly how your endpoint replied.

A leaked secret should be rotatable in minutes without downtime:

  1. Create a new secret. POST /v1/webhook_subscriptions/{id}/secrets returns a fresh plaintext. Kirim now signs every delivery with both secrets (legacy and new) for the overlap window.

  2. Add the new secret to your verifier. Update your secrets manager, redeploy. Your verifier now accepts either signature.

  3. Confirm new signatures verify. Watch a few deliveries through the dashboard.

  4. Delete the old secret. DELETE /v1/webhook_subscriptions/{id}/secrets/{old_id}. Kirim stops signing with it on the next event.

Verify signatures

Language-specific HMAC recipes, multi-secret verification, timing attacks. Read the concept →