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 { verify } from '@kirimdev/sdk/webhooks'
const event = verify({
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)
})
// event.id → string
// event.type → 'message.created' | 'message.status_updated' | ...
// event.created_at → string (ISO 8601)
// event.data → narrows on event.type (see "Typed events" below)

Returns a parsed, signature-verified KirimWebhookEvent. 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 | nullyesThe 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). Set to 0 to disable.

The signature is computed over the exact bytes kirim.dev 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 { verify, KirimWebhookError } from '@kirimdev/sdk/webhooks'
import type { KirimWebhookEvent } 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
let event: KirimWebhookEvent
try {
event = verify({ rawBody, signatureHeader, secrets: SECRETS })
} catch (err) {
if (err instanceof KirimWebhookError) {
// Bad signature, replay window expired, or malformed payload.
// Return 401 so kirim.dev marks the delivery as failed and retries.
return c.text('invalid signature', 401)
}
throw err
}
// Acknowledge fast (<5s). Push heavy work to a queue.
await handle(event)
return c.text('ok')
})
async function handle(event: KirimWebhookEvent) {
switch (event.type) {
case 'message.created':
// event.data is typed as the message-created payload here
console.log(event.data.id, 'queued for', event.data.to)
break
case 'message.status_updated':
if (event.data.status === 'failed') {
await alertOnFailure(event.data.id, event.data.error)
}
break
default:
// Forward-compatible: log + accept unknown event types
console.warn('unknown event type', event.type)
}
}
export default app
src/webhooks.ts
import express from 'express'
import { verify, KirimWebhookError } 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 event = verify({ rawBody, signatureHeader, secrets: SECRETS })
await handle(event)
res.status(200).send('ok')
} catch (err) {
if (err instanceof KirimWebhookError) {
return res.status(401).send('invalid signature')
}
throw err
}
},
)

KirimWebhookEvent is a discriminated union on type. TypeScript narrows event.data for you inside each case:

import type { KirimWebhookEvent } from '@kirimdev/sdk/webhooks'
function handle(event: KirimWebhookEvent) {
switch (event.type) {
case 'message.created':
// event.data: { id, to, type, ... }
return enqueueOutboundLog(event.data.id)
case 'message.status_updated':
// event.data: { id, status, error?, ... }
if (event.data.status === 'failed') {
return alertOnFailure(event.data.id, event.data.error)
}
return updateMessageStatus(event.data.id, event.data.status)
case 'conversation.created':
case 'conversation.updated':
return refreshConversation(event.data.id)
default:
// Forward-compatible — new event types do not crash old handlers.
console.warn({ type: (event as { type: string }).type }, 'unknown event')
}
}

See the Event Catalogue for the full list and payload schema for each event.

verify() 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:
const event = verify({
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)

verify() 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
verify({ rawBody, signatureHeader, secrets, toleranceSeconds: 60 })
// Disable entirely (NOT recommended — only for local replay tools)
verify({ rawBody, signatureHeader, secrets, toleranceSeconds: 0 })

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.created', 'message.status_updated'],
})
// sub.signing_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, { enabled: false })
// 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.error_code, d.last_attempt_at)
}
// Replay one
await kirim.webhookDeliveries.replay(deliveryId)

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