ClientCasa
Integrations

Webhook receiver with HMAC verification

Build a webhook receiver that verifies HMAC-SHA256 signatures, rejects replays, dedupes by event ID, and handles retries. Works on Node, Edge runtimes, and Cloudflare Workers.

ClientCasa fires HTTP POST requests at endpoints you configure when events happen — invoices get paid, contracts get signed, inquiries arrive. This guide shows how to receive those deliveries correctly.

The five things every webhook receiver must do

  1. Verify the HMAC signature — prove the request came from ClientCasa
  2. Check the timestamp — reject replays older than ~5 minutes
  3. Respond fast (under 5s) — ClientCasa retries on timeout
  4. Idempotently process — the same event may arrive twice
  5. Return 2xx on success, 5xx to trigger retry — semantic status codes

Get these five right and you have a reliable webhook integration. Below is copy-pasteable code that does all of them.

Setup

Create the webhook subscription

In your dashboard at Settings → Webhooks, click Add webhook. Fill in:

  • URL: Your endpoint, HTTPS-only — for example https://your-app.com/api/clientcasa/webhook
  • Events: Pick what you care about — invoice_paid, proposal_accepted, contract_signed, etc. (full list)
  • Signing secret: Generate a strong random secret (e.g., openssl rand -hex 32) and paste it here. This is shown only once; store it as CLIENTCASA_WEBHOOK_SECRET in your app's environment.

Save. Future deliveries to that URL will include X-Signature and X-Webhook-Timestamp headers.

Implement the receiver

// app/api/clientcasa/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createHmac, timingSafeEqual } from 'node:crypto'

const SECRET = process.env.CLIENTCASA_WEBHOOK_SECRET!
const MAX_AGE_SECONDS = 300 // reject deliveries older than 5 minutes

export async function POST(req: NextRequest) {
  const body = await req.text() // read RAW body for HMAC
  const sig = req.headers.get('x-signature') ?? ''
  const ts = req.headers.get('x-webhook-timestamp') ?? ''

  // 1. Timestamp freshness
  const tsNum = Number(ts)
  if (!tsNum || Math.abs(Date.now() / 1000 - tsNum) > MAX_AGE_SECONDS) {
    return NextResponse.json({ error: 'stale or invalid timestamp' }, { status: 401 })
  }

  // 2. HMAC verification — use timing-safe compare
  const expected = createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex')
  const provided = sig.replace(/^sha256=/, '')
  const expectedBuf = Buffer.from(expected, 'hex')
  const providedBuf = Buffer.from(provided, 'hex')
  if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) {
    return NextResponse.json({ error: 'invalid signature' }, { status: 401 })
  }

  // 3. Parse + dispatch
  const payload = JSON.parse(body) as {
    event: string
    timestamp: string
    data: Record<string, unknown>
  }
  await dispatch(payload)

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

async function dispatch(payload: { event: string; data: Record<string, unknown> }) {
  switch (payload.event) {
    case 'invoice_paid':       return onInvoicePaid(payload.data)
    case 'proposal_accepted':  return onProposalAccepted(payload.data)
    case 'contract_signed':    return onContractSigned(payload.data)
    default:                   return // unknown event — return 200 so we don't retry forever
  }
}

async function onInvoicePaid(data: Record<string, unknown>) { /* … */ }
async function onProposalAccepted(data: Record<string, unknown>) { /* … */ }
async function onContractSigned(data: Record<string, unknown>) { /* … */ }
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'

const app = express()
const SECRET = process.env.CLIENTCASA_WEBHOOK_SECRET!
const MAX_AGE_SECONDS = 300

// IMPORTANT: use raw body for /webhook, JSON parser elsewhere
app.post(
  '/api/clientcasa/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const body = req.body.toString('utf8')
    const sig = (req.header('x-signature') ?? '').replace(/^sha256=/, '')
    const ts = req.header('x-webhook-timestamp') ?? ''

    const tsNum = Number(ts)
    if (!tsNum || Math.abs(Date.now() / 1000 - tsNum) > MAX_AGE_SECONDS) {
      return res.status(401).json({ error: 'stale timestamp' })
    }

    const expected = createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex')
    const a = Buffer.from(expected, 'hex')
    const b = Buffer.from(sig, 'hex')
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).json({ error: 'invalid signature' })
    }

    const payload = JSON.parse(body)
    handleEvent(payload).catch(console.error)
    res.json({ ok: true })
  },
)

async function handleEvent(payload: { event: string; data: unknown }) {
  // ... dispatch by event type
}
// Workers don't have node:crypto — use the Web Crypto API instead.
const MAX_AGE_SECONDS = 300

export default {
  async fetch(req: Request, env: { CLIENTCASA_WEBHOOK_SECRET: string }) {
    if (req.method !== 'POST') return new Response('method not allowed', { status: 405 })

    const body = await req.text()
    const sig = (req.headers.get('x-signature') ?? '').replace(/^sha256=/, '')
    const ts = req.headers.get('x-webhook-timestamp') ?? ''

    const tsNum = Number(ts)
    if (!tsNum || Math.abs(Date.now() / 1000 - tsNum) > MAX_AGE_SECONDS) {
      return new Response('stale timestamp', { status: 401 })
    }

    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(env.CLIENTCASA_WEBHOOK_SECRET),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['verify'],
    )
    const sigBuf = Uint8Array.from(sig.match(/../g)!.map((b) => parseInt(b, 16)))
    const ok = await crypto.subtle.verify(
      'HMAC',
      key,
      sigBuf,
      new TextEncoder().encode(`${ts}.${body}`),
    )
    if (!ok) return new Response('invalid signature', { status: 401 })

    const payload = JSON.parse(body)
    // ... handle event
    return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } })
  },
}

Dedupe across retries

If your handler takes longer than 5 seconds (or temporarily errors), ClientCasa retries. The same event may arrive multiple times. Store the event's unique identifier and skip re-processing:

const key = `clientcasa:event:${payload.documentId}:${payload.event}`
const existed = await redis.set(key, '1', { NX: true, EX: 60 * 60 * 24 })
if (!existed) return // already processed this event

If you don't have Redis, use any storage with a TTL — Vercel KV, Cloudflare KV, Postgres with a UNIQUE constraint, or an in-memory LRU for low volume.

Why HMAC over ${timestamp}.${body}

The signature covers BOTH the timestamp and body. An attacker who intercepts a delivery cannot:

  • Replay it later — the timestamp falls outside the freshness window
  • Modify the body — the signature won't match
  • Replay with a fresh timestamp — they don't know the secret to sign the new combo

This pattern is what Stripe, GitHub, Slack, and others use. It's the gold standard.

Retry behavior

ClientCasa retries on:

  • HTTP 5xx responses
  • Connection errors / timeouts (>10s)
  • 4xx responses except 410 Gone (which we treat as "stop trying")

After 5 consecutive failures, your webhook is auto-disabled and an email is sent to your org admins. Re-enable from Settings → Webhooks after fixing the issue.

Best practice: always 2xx fast, process async

The most reliable pattern is to immediately enqueue the event for background processing and return 200 — within ~50ms. This decouples your processing latency from the webhook delivery SLA.

await queue.publish('clientcasa-events', payload)
return new Response('ok', { status: 200 })

Then a separate worker drains the queue with whatever timeouts and retries make sense for your business logic.

Testing locally

  1. Use ngrok or tailscale funnel to expose localhost:3000 to the internet
  2. Register the temporary URL as a webhook endpoint with a real signing secret
  3. Trigger an event in your dashboard (e.g., mark an invoice as paid)
  4. Watch the delivery hit your local handler

For unit tests, you can stub the signature header — just createHmac the same way the server does, against a known body and timestamp.

See also

On this page