Webhook Integration Done Right: HMAC-SHA256, At-Least-Once Delivery, and Idempotency Keys
Webhook Integration Done Right
💡 Target Audience: Backend engineers wiring HyperBabel events into their billing, audit log, analytics, or moderation pipelines.
Why webhooks beat polling
If you've been polling `/chat/messages` every minute to feed your analytics or your moderation queue, you already know the problems: missed events when polling intervals slip, wasted DB queries when nothing changed, and an architecture that doesn't scale past a few thousand rooms.
Webhooks invert the flow. HyperBabel POSTs an event to your URL the moment something happens — no polling, no missed updates, no wasted queries.
The 14 event types
Register a webhook for any subset:
* Chat: `chat.message.sent`, `chat.message.deleted`, `chat.channel.created`, `chat.channel.member_joined`, `chat.channel.member_left`
* Video: `video.session.started`, `video.session.ended`, `video.session.participant_joined`, `video.session.participant_left`
* Live Streaming: `live.broadcast.started`, `live.broadcast.ended`, `live.broadcast.viewer_joined`
* Billing: `billing.subscription.changed`, `billing.usage.threshold_crossed`
(Catalogue current as of 2026-05-07. Source of truth: `cf_workers_api/src/lib/webhook.ts`.)
Step 1: Register your endpoint
```bash
curl -X POST https://api.hyperbabel.com/api/v1/auth/webhooks \
-H "Authorization: Bearer hb_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/hooks/hyperbabel",
"events": ["chat.message.sent", "video.session.ended", "billing.subscription.changed"],
"active": true
}'
```
The response includes a signing secret — store this securely (env var, vault). It's never shown again. Rotate via `POST /auth/webhooks/:id/regenerate-secret`.
Step 2: Verify the HMAC-SHA256 signature
Every webhook delivery includes an `X-HyperBabel-Signature-256` header containing `sha256=<hex>`. Verify it before trusting the payload — otherwise an attacker can spoof events.
Express (Node.js):
```javascript
import crypto from 'crypto';
import express from 'express';
const app = express();
const SIGNING_SECRET = process.env.HB_WEBHOOK_SECRET;
// CRITICAL: capture the raw body (not parsed JSON) for signature verification.
app.post('/hooks/hyperbabel', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-HyperBabel-Signature-256') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', SIGNING_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Process event…
res.status(200).send('ok');
});
```
Hono (Cloudflare Workers, Node, Bun):
```typescript
import { Hono } from 'hono';
const app = new Hono();
app.post('/hooks/hyperbabel', async (c) => {
const signature = c.req.header('X-HyperBabel-Signature-256') || '';
const raw = await c.req.text();
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(c.env.HB_WEBHOOK_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(raw));
const expected = 'sha256=' + [...new Uint8Array(sig)]
.map(b => b.toString(16).padStart(2, '0')).join('');
if (signature !== expected) return c.text('Invalid signature', 401);
const event = JSON.parse(raw);
// Process event…
return c.text('ok', 200);
});
```
Two non-negotiables:
1. Use the raw body, not parsed JSON. Parsing changes whitespace and breaks the signature.
2. Use timing-safe comparison (`timingSafeEqual` in Node, simple `===` in Workers — both constant-time at this length).
Step 3: Handle at-least-once delivery (idempotency)
HyperBabel guarantees at-least-once delivery, not exactly-once. If your endpoint times out, returns 5xx, or drops the connection, the event is retried with exponential backoff (up to 3 attempts over ~30 minutes). Sometimes a successful delivery is also retried because our acknowledgment was lost in flight.
This means your handler must be idempotent. Each event has stable identifiers you can dedup against:
| Event family | Idempotency key |
|---|---|
| `chat.message.*` | `data.message_id` |
| `video.session.*` | `data.session_id` |
| `live.broadcast.*` | `data.broadcast_id` |
| `chat.channel.*` | `data.channel_id` + `event` (some channel events fire multiple times) |
| `billing.*` | `data.transaction_id` or `data.subscription_id` + `event` |
Concrete pattern:
```javascript
async function handleEvent(event) {
const idempotencyKey = `${event.event}:${event.data.message_id ?? event.data.session_id ?? event.id}`;
const inserted = await db.dedup.insertIfAbsent(idempotencyKey, { ttl: '7 days' });
if (!inserted) return; // already processed
await processEvent(event);
}
```
A 7-day TTL covers the entire retry window plus operator buffer. Redis SET NX, Postgres unique constraint, or DynamoDB conditional put — pick whichever your stack already uses.
Step 4: Always return 200 fast
Webhook handlers should:
* Return 200 OK in under 5 seconds — anything slower triggers a retry and your queue starts oscillating.
* Do as little as possible inline — verify signature, dedup, push to a queue, return.
* Long work (image processing, ML inference, downstream API calls) goes through your own job runner.
Inspecting deliveries
Every webhook attempt — successful or failed — is logged with status, response code, and latency. Pull them anytime:
```bash
curl https://api.hyperbabel.com/api/v1/auth/webhooks/logs?webhook_id=wh_... \
-H "Authorization: Bearer hb_live_..."
```
Useful when debugging signature mismatches or 5xx storms during a deploy.
What this unlocks
Once webhooks are wired:
* Audit log: every chat/video event timestamped in your warehouse for compliance review
* Real-time moderation: AI moderation pipeline reacts to `chat.message.sent` in milliseconds
* Billing visibility: `billing.usage.threshold_crossed` notifies your finance team before overage hits the invoice
* Custom analytics: dashboards backed by your data, not the vendor's
