Skip to content
TypeScript SDK

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.

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.

ArgumentTypeRequiredNotes
rawBodystringyesMust be the unparsed body bytes as a UTF-8 string.
signatureHeaderstring | null | undefinedyesThe X-Kirim-Signature header value (e.g. t=1700000000,v1=abc...).
secretsstring[]yesAll currently active signing secrets — verifier tries each, succeeds on first match.
toleranceSecondsnumbernoReject deliveries whose timestamp is older than this (default 300).

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
})
src/webhooks.ts
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 app
src/webhooks.ts
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
}
},
)

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.

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)

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 60s
await verifyWebhookSignature({ rawBody, signatureHeader, secrets, toleranceSeconds: 60 })

If your handler is slow or behind a queue, raise the tolerance rather than disabling it.

Create / list / update / delete subscriptions through the REST client:

import { Kirim } from '@kirimdev/sdk'
const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })
// Create
const 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 / resume
await kirim.webhookSubscriptions.update(sub.id, { status: 'paused' })
await kirim.webhookSubscriptions.update(sub.id, { status: 'active' })
// Delete
await kirim.webhookSubscriptions.del(sub.id)
// Browse the dead-letter queue
for await (const d of kirim.webhookDeliveries.list({ status: 'failed', limit: 50 })) {
console.log(d.id, d.last_response_status, d.attempt_count)
}
// Replay one
await kirim.webhookDeliveries.replay(deliveryId)

See Webhooks → Retries & Auto-Disable for how Kirimdev auto-disables endpoints that stay broken too long.