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
- Verify the HMAC signature — prove the request came from ClientCasa
- Check the timestamp — reject replays older than ~5 minutes
- Respond fast (under 5s) — ClientCasa retries on timeout
- Idempotently process — the same event may arrive twice
- 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 asCLIENTCASA_WEBHOOK_SECRETin 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 eventIf 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
- Use
ngrokortailscale funnelto exposelocalhost:3000to the internet - Register the temporary URL as a webhook endpoint with a real signing secret
- Trigger an event in your dashboard (e.g., mark an invoice as paid)
- 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
- Webhook events reference — full list of events + payload shapes
- Webhooks API resource — manage subscriptions programmatically
- Idempotent writes — the inverse problem (you sending to us)
OAuth quick-start with PKCE
End-to-end authorization-code-with-PKCE flow against the ClientCasa OAuth provider. Copy-pasteable Node.js example, ~50 lines.
Idempotent writes for safe retries
Every POST to the ClientCasa API should send an Idempotency-Key header. Here's why, how it works under the hood, and how it interacts with payments and refunds.