Back to cookbook

Webhook delivery and retries

What you'll build

A production-grade Node/Express webhook receiver. It verifies the X-FC-Signature HMAC in constant time, rejects stale timestamps, runs the handler inside an idempotency guard keyed on event.id (so a retried delivery is a no-op), and returns 2xx so Ringside stops retrying. The same pattern works for all 29 event types.

What you need

  • A webhook endpoint registered via POST /v1/webhooks, save the secret that's returned ONCE
  • Node 20+, npm install express
  • A small KV store for idempotency (Redis, Postgres, or in-memory for dev)

Full code

js
// webhook_receiver.js const express = require('express'); const crypto = require('crypto'); const app = express(); // Raw body is required for HMAC verification. app.use('/hook/ringside', express.raw({ type: 'application/json', limit: '1mb' })); const SECRET = process.env.FC_WEBHOOK_SECRET; const seen = new Map(); // production: swap for Redis with SETNX + TTL. function verifySignature(header, rawBody) { if (!header) return null; const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const { t, v1 } = parts; if (!t || !v1) return null; const age = Math.abs(Date.now() / 1000 - Number(t)); if (age > 300) return null; // 5-min receiver tolerance const expected = crypto .createHmac('sha256', SECRET) .update(`${t}.${rawBody.toString()}`) .digest('hex'); const ok = crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex')); return ok ? JSON.parse(rawBody.toString()) : null; } app.post('/hook/ringside', (req, res) => { const event = verifySignature(req.headers['x-fc-signature'], req.body); if (!event) return res.status(401).send('bad signature'); // Idempotency guard. If we've seen event.id before, return 200 but don't act. if (seen.has(event.id)) return res.status(200).json({ replayed: true }); seen.set(event.id, Date.now()); // Handle by type. switch (event.type) { case 'run.completed': return handleRunCompleted(event.data, res); case 'run.failed': return handleRunFailed(event.data, res); case 'run.cancelled': return res.status(200).json({ ack: true }); case 'webhook.test': console.log('[webhook.test] ok'); return res.status(200).json({ ack: true }); default: console.log('unknown event', event.type); return res.status(200).json({ ack: true }); } }); function handleRunCompleted(data, res) { const { run_id, thread_id, assistant_id, usage } = data; console.log(`[run.completed] ${run_id} thread=${thread_id} tokens=${usage?.total_tokens}`); // Your business logic: notify the user, update DB, etc. return res.status(200).json({ ok: true }); } function handleRunFailed(data, res) { console.warn(`[run.failed] ${data.run_id}: ${data.error?.code}`); return res.status(200).json({ ok: true }); } app.listen(3000, () => console.log('listening on :3000/hook/ringside'));

Walkthrough

express.raw() (not express.json()), you need the raw byte buffer to compute the HMAC. If you let Express re-serialize the JSON, whitespace differences will break the signature check.

crypto.timingSafeEqual prevents a remote attacker from using response-time differences to recover the secret. It requires equal-length buffers, so both sides must be hex-decoded to 32 bytes.

The seen map is a dev-only stub. In production use Redis SET <event.id> 1 NX EX 86400, the atomic NX gives you a two-line idempotency primitive. Ringside may retry deliveries up to 5 times (1m, 5m, 30m, 2h, 6h); returning the same 2xx on a replay is the correct behavior.

To test replays manually: copy the X-FC-Signature header and raw body from your logs, then curl -X POST the same payload back at yourself. You should get {"replayed": true}.

Run it

bash
export FC_WEBHOOK_SECRET=whsec_xxx node webhook_receiver.js # then in Ringside, hit "Send test event" in /app/webhooks to fire a webhook.test.

What's next