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.
verify()
Section titled “verify()”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.
| Argument | Type | Required | Notes |
|---|---|---|---|
rawBody | string | yes | Must be the unparsed body bytes as a UTF-8 string. |
signatureHeader | string | null | 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). Set to 0 to disable. |
Raw body is mandatory
Section titled “Raw body is mandatory”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})// 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 { 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 appWorked example: Express
Section titled “Worked example: Express”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 } },)Typed events
Section titled “Typed events”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.
Secret rotation
Section titled “Secret rotation”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)Replay protection
Section titled “Replay protection”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 60sverify({ 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.
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.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 / resumeawait kirim.webhookSubscriptions.update(sub.id, { enabled: false })
// 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.error_code, d.last_attempt_at)}
// Replay oneawait kirim.webhookDeliveries.replay(deliveryId)See Webhooks → Retries & Auto-Disable for how kirim.dev 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).