// server.ts — Node 18+, express, standardwebhooks
import express from 'express';
import { Webhook } from 'standardwebhooks';
import { api } from './practicehub'; // the client from the booking example
import { queue, seen } from './infra'; // your job queue + a 24 h key/value store (Redis, DB…)
const wh = new Webhook(process.env.PRACTICEHUB_WEBHOOK_SECRET!); // "whsec_…" from Developers → Webhooks
const app = express();
// 1. Raw body — verification is over the exact bytes PracticeHub signed.
app.post('/practicehub/webhooks', express.raw({ type: 'application/json', limit: '64kb' }), async (req, res) => {
let event: { id: string; type: string; occurred_at: string; data: { entity: string; id: number } };
// 2. Verify — wrong secret, tampered body or a timestamp outside the tolerance window → 401, no retry from us is wanted.
try {
event = wh.verify(req.body, {
'webhook-id': req.header('webhook-id')!,
'webhook-timestamp': req.header('webhook-timestamp')!,
'webhook-signature': req.header('webhook-signature')!,
}) as typeof event;
} catch {
return res.status(401).send('invalid signature');
}
// 3. De-duplicate — a delivery can be retried after a timeout; the event id is stable across retries.
if (await seen.setIfAbsent(`ph-event:${event.id}`, '1', { ttlSeconds: 86_400 }) === false) {
return res.status(200).send('duplicate');
}
// 4. Acknowledge now, work later. Anything slow (our API call, your database) goes to a queue.
await queue.enqueue('practicehub-event', event);
res.status(202).send('queued');
});
app.listen(8080);