Back to cookbook

Webhook delivery and retries

A receiver that can be handed the same event five times and still only acts once, and that a forged POST can't fool. Ringside retries a failed delivery up to five times, hours apart, so the duplicate isn't hypothetical. Without the idempotency guard below, one flaky deploy sends your customer five emails.

Node and Express here. The same shape works for all 34 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