SDK Webhooks
The SDK exposes a dedicated webhooks entry point —
@kirimdev/sdk/webhooks — separate from the REST client. You can
import it without ever constructing a Kirim instance, which keeps
your webhook handler lightweight and avoids accidentally bundling an
apiKey into a public worker.
verifyWebhookSignature()
Section titled “verifyWebhookSignature()”import { verifyWebhookSignature, InvalidSignatureError, SignatureExpiredError, MalformedPayloadError,} from '@kirimdev/sdk/webhooks'
const event = await verifyWebhookSignature({ rawBody, // string — the EXACT bytes you received signatureHeader, // value of the X-Kirim-Signature header secrets: ['whsec_...'], // active signing secrets (array — supports rotation) toleranceSeconds: 300, // optional, default 300s (replay-protection window)})Returns the parsed JSON payload on success. The shape depends on
the event source — Kirimdev-native envelope for conversation.* and
contact.*, Meta’s { object, entry } body for message.received
and message.status. Branch on the X-Kirim-Event request header to
decide which shape to expect.
Throws if verification fails — InvalidSignatureError,
SignatureExpiredError, or MalformedPayloadError (all subclasses of
KirimWebhookError). Use a single try/catch and return 401.
| Argument | Type | Required | Notes |
|---|---|---|---|
rawBody | string | yes | Must be the unparsed body bytes as a UTF-8 string. |
signatureHeader | string | null | undefined | yes | The X-Kirim-Signature header value (e.g. t=1700000000,v1=abc...). |
secrets | string[] | yes | All currently active signing secrets — verifier tries each, succeeds on first match. |
toleranceSeconds | number | no | Reject deliveries whose timestamp is older than this (default 300). |
Raw body is mandatory
Section titled “Raw body is mandatory”The signature is computed over the exact bytes Kirimdev sent. If your framework parses JSON before you see it, the re-serialized payload will have different whitespace and the signature will not match. Read the raw body first, verify, then parse.
app.post('/webhooks/kirim', async (c) => { const rawBody = await c.req.text() // ✓ raw bytes as string // ...verify, then JSON.parse})// Mount express.raw() ONLY on the webhook routeapp.post( '/webhooks/kirim', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = (req.body as Buffer).toString('utf8') // ✓ // ... },)export async function POST(req: Request) { const rawBody = await req.text() // ✓ // ...}Bun.serve({ async fetch(req) { const rawBody = await req.text() // ✓ // ... },})Worked example: Hono
Section titled “Worked example: Hono”import { Hono } from 'hono'import { verifyWebhookSignature, InvalidSignatureError, SignatureExpiredError, MalformedPayloadError,} from '@kirimdev/sdk/webhooks'
const app = new Hono()
const SECRETS = [ process.env.KIRIM_WEBHOOK_SECRET_CURRENT!, process.env.KIRIM_WEBHOOK_SECRET_PREVIOUS,].filter(Boolean) as string[]
app.post('/webhooks/kirim', async (c) => { const rawBody = await c.req.text() const signatureHeader = c.req.header('x-kirim-signature') ?? null const eventName = c.req.header('x-kirim-event') ?? ''
try { const body = await verifyWebhookSignature({ rawBody, signatureHeader, secrets: SECRETS, }) // Acknowledge fast (<5s). Push heavy work to a queue. await handle(eventName, body) return c.text('ok') } catch (err) { if (err instanceof SignatureExpiredError) return c.text('stale', 400) if (err instanceof InvalidSignatureError) return c.text('bad sig', 401) if (err instanceof MalformedPayloadError) return c.text('bad body', 400) throw err }})
async function handle(eventName: string, body: unknown) { // Meta passthrough events arrive as { object, entry: [...] }. if (eventName === 'message.received' || eventName === 'message.status') { const meta = body as { entry: Array<{ changes: Array<{ value: unknown }> }> } const value = meta.entry[0]?.changes[0]?.value // walk Meta's shape here return }
// Kirimdev-native envelope: { id, type, created_at, data }. const event = body as { id: string; type: string; data: unknown } switch (event.type) { case 'conversation.assigned': case 'conversation.closed': return refreshConversation(event.data) case 'contact.created': case 'contact.updated': return upsertContact(event.data) default: console.warn('unknown event type', event.type) }}
export default appWorked example: Express
Section titled “Worked example: Express”import express from 'express'import { verifyWebhookSignature, InvalidSignatureError, SignatureExpiredError, MalformedPayloadError,} from '@kirimdev/sdk/webhooks'
const app = express()const SECRETS = [process.env.KIRIM_WEBHOOK_SECRET!]
// Mount express.raw() on this route only — leave express.json()// configured for the rest of your routes if you need it.app.post( '/webhooks/kirim', express.raw({ type: 'application/json' }), async (req, res) => { const rawBody = (req.body as Buffer).toString('utf8') const signatureHeader = req.header('x-kirim-signature') ?? null
try { const body = await verifyWebhookSignature({ rawBody, signatureHeader, secrets: SECRETS, }) await handle(req.header('x-kirim-event') ?? '', body) res.status(200).send('ok') } catch (err) { if (err instanceof SignatureExpiredError) return res.status(400).send('stale') if (err instanceof InvalidSignatureError) return res.status(401).send('bad sig') if (err instanceof MalformedPayloadError) return res.status(400).send('bad body') throw err } },)Typed events
Section titled “Typed events”The SDK exports a typed union covering both shapes:
import type { KirimWebhookEvent, // full union: native events + Meta passthrough KirimNativeWebhookEvent, // just the four native event types MetaPassthroughBody, // Meta's { object, entry } body ConversationAssignedEvent, ConversationClosedEvent, ContactCreatedEvent, ContactUpdatedEvent,} from '@kirimdev/sdk/webhooks'Branch on the X-Kirim-Event request header (not on a discriminator
field) to decide whether the body is a Kirimdev-native envelope or a
Meta passthrough — Meta bodies do not carry a type field at the top
level:
function route(eventName: string, body: KirimWebhookEvent) { if (eventName === 'message.received' || eventName === 'message.status') { const meta = body as MetaPassthroughBody const value = meta.entry[0]?.changes[0]?.value return handleMeta(eventName, value) }
const native = body as KirimNativeWebhookEvent switch (native.type) { case 'conversation.assigned': case 'conversation.closed': return refreshConversation(native.data) case 'contact.created': case 'contact.updated': return upsertContact(native.data) }}See the Event Catalogue for the full list and payload schema for each event.
Secret rotation
Section titled “Secret rotation”verifyWebhookSignature() accepts an array of secrets and succeeds on
the first that matches. Rotation is therefore a three-step process
with zero downtime:
// 1. Mint a new secret in the dashboard or via the API.const next = await kirim.webhookSubscriptions.addSecret(subscriptionId)// next.secret is plaintext — store it now, you won't see it again.
// 2. Deploy code that accepts BOTH the old and new secret:await verifyWebhookSignature({ rawBody, signatureHeader, secrets: [process.env.NEW_SECRET!, process.env.OLD_SECRET!],})
// 3. Once every running instance has the new secret, revoke the old.await kirim.webhookSubscriptions.revokeSecret(subscriptionId, oldSecretId)Replay protection
Section titled “Replay protection”The verifier rejects deliveries whose signed timestamp is older than
toleranceSeconds (default 300). This blocks attackers from
re-sending a captured payload hours later.
// Stricter — reject anything older than 60sawait verifyWebhookSignature({ rawBody, signatureHeader, secrets, toleranceSeconds: 60 })If your handler is slow or behind a queue, raise the tolerance rather than disabling it.
Managing subscriptions
Section titled “Managing subscriptions”Create / list / update / delete subscriptions through the REST client:
import { Kirim } from '@kirimdev/sdk'const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })
// Createconst sub = await kirim.webhookSubscriptions.create({ url: 'https://your-app.example/webhooks/kirim', events: ['message.received', 'message.status'],})// sub.initial_secret is shown ONCE — persist it now.
// List (async iterable)for await (const s of kirim.webhookSubscriptions.list()) { console.log(s.id, s.url, s.status)}
// Pause / resumeawait kirim.webhookSubscriptions.update(sub.id, { status: 'paused' })await kirim.webhookSubscriptions.update(sub.id, { status: 'active' })
// Deleteawait kirim.webhookSubscriptions.del(sub.id)Replaying failed deliveries
Section titled “Replaying failed deliveries”// Browse the dead-letter queuefor await (const d of kirim.webhookDeliveries.list({ status: 'failed', limit: 50 })) { console.log(d.id, d.last_response_status, d.attempt_count)}
// Replay oneawait kirim.webhookDeliveries.replay(deliveryId)See Webhooks → Retries & Auto-Disable for how Kirimdev auto-disables endpoints that stay broken too long.
Related
Section titled “Related”- Webhooks → Overview — events, delivery model, retries.
- Webhooks → Signing — the underlying signature format the verifier checks.
- SDK Errors — error classes thrown by
the REST client (separate from
KirimWebhookError).