Skip to main content

Overview

ChurnKit signs every webhook payload with HMAC-SHA256 using your webhook secret. Always verify the signature before processing the payload. The SDK exports verifyWebhookSignature as a standalone function — no ChurnKit instance required.

Signature

import { verifyWebhookSignature } from '@vgpprasad91/churnkit-sdk'

verifyWebhookSignature(
  rawBody: string,
  signature: string,
  secret: string
): Promise<boolean>

Parameters

rawBody
string
required
The raw, unparsed request body as a string. Do not parse it as JSON before passing it here — the signature is computed over the raw bytes.
signature
string
required
The value of the X-ChurnKit-Signature header. Format: sha256=<hex>.
secret
string
required
Your webhook signing secret from the FlowsBuilt Console.

Examples

Next.js App Router

// app/api/webhooks/churnkit/route.ts
import { verifyWebhookSignature } from '@vgpprasad91/churnkit-sdk'

export async function POST(req: Request) {
  // Read body as raw text BEFORE parsing
  const rawBody = await req.text()
  const signature = req.headers.get('x-churnkit-signature') ?? ''

  const valid = await verifyWebhookSignature(
    rawBody,
    signature,
    process.env.CHURNKIT_WEBHOOK_SECRET!
  )

  if (!valid) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 })
  }

  const payload = JSON.parse(rawBody)

  switch (payload.event) {
    case 'churn_risk_alert':
      await handleChurnAlert(payload)
      break
  }

  return Response.json({ ok: true })
}

Express

import express from 'express'
import { verifyWebhookSignature } from '@vgpprasad91/churnkit-sdk'

const app = express()

// Use raw body parser for webhook routes
app.post('/hooks/churnkit', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body.toString('utf8')
  const signature = req.headers['x-churnkit-signature'] as string ?? ''

  const valid = await verifyWebhookSignature(
    rawBody,
    signature,
    process.env.CHURNKIT_WEBHOOK_SECRET!
  )

  if (!valid) return res.status(401).json({ error: 'Invalid signature' })

  const payload = JSON.parse(rawBody)
  // handle payload...

  res.json({ ok: true })
})

Hono (Cloudflare Workers)

import { Hono } from 'hono'
import { verifyWebhookSignature } from '@vgpprasad91/churnkit-sdk'

const app = new Hono<{ Bindings: { CHURNKIT_WEBHOOK_SECRET: string } }>()

app.post('/hooks/churnkit', async (c) => {
  const rawBody = await c.req.text()
  const signature = c.req.header('x-churnkit-signature') ?? ''

  const valid = await verifyWebhookSignature(rawBody, signature, c.env.CHURNKIT_WEBHOOK_SECRET)
  if (!valid) return c.json({ error: 'Invalid signature' }, 401)

  const payload = JSON.parse(rawBody)
  // handle...

  return c.json({ ok: true })
})

How the signature is computed

ChurnKit computes:
HMAC-SHA256(key=webhookSecret, data=rawBody)
And sends the hex digest as:
X-ChurnKit-Signature: sha256=<hex_digest>
The SDK’s verifyWebhookSignature performs the same computation and compares with a constant-time comparison to prevent timing attacks.

Common mistakes

The signature is over the raw bytes. If you call JSON.parse() before verifying, the comparison will fail because JSON serialization can change whitespace and key order.
// ❌ Wrong
const payload = await req.json()
await verifyWebhookSignature(JSON.stringify(payload), sig, secret)

// ✅ Correct
const rawBody = await req.text()
await verifyWebhookSignature(rawBody, sig, secret)
const payload = JSON.parse(rawBody)
Express’s express.json() middleware consumes and parses the body. Use express.raw() for webhook routes instead.
// ❌ Wrong — body already parsed by middleware
app.use(express.json())
app.post('/hooks/churnkit', handler)

// ✅ Correct — raw body for this route
app.post('/hooks/churnkit', express.raw({ type: '*/*' }), handler)
If process.env.CHURNKIT_WEBHOOK_SECRET is undefined, verification always returns false. Check your environment variables and ensure the secret is set.