Webhook handler (Node.js)
Signature verification, replay protection, event dispatch.
Webhook handler (Node.js)
A production-ready webhook endpoint: verifies HMAC signatures, rejects replays, and dispatches events to your app logic. Tested against Express, but the core is framework-agnostic.
Verification helper
// webhook-verify.ts
import crypto from 'node:crypto';
type VerifyResult =
| { ok: true; timestamp: number }
| { ok: false; reason: string };
export function verifyPostatoSignature(
rawBody: string,
signatureHeader: string | undefined,
secret: string,
maxSkewSeconds = 300
): VerifyResult {
if (!signatureHeader) return { ok: false, reason: 'missing_signature' };
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=') as [string, string])
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return { ok: false, reason: 'malformed_signature' };
const skew = Math.abs(Math.floor(Date.now() / 1000) - t);
if (skew > maxSkewSeconds) return { ok: false, reason: 'timestamp_skew' };
const payload = `${t}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const actualBuf = Buffer.from(v1, 'hex');
if (expectedBuf.length !== actualBuf.length) return { ok: false, reason: 'length_mismatch' };
if (!crypto.timingSafeEqual(expectedBuf, actualBuf)) return { ok: false, reason: 'signature_mismatch' };
return { ok: true, timestamp: t };
}Notes:
timingSafeEqualis non-negotiable; plain===leaks timing.- The 5-minute window protects against replays. Tune if your infra has higher clock skew.
- Raw body, not the parsed JSON. Any JSON middleware mutation breaks the hash.
Express endpoint
// app.ts
import express from 'express';
import { verifyPostatoSignature } from './webhook-verify';
import { handleEvent } from './event-dispatcher';
const app = express();
const WEBHOOK_SECRET = process.env.POSTATO_WEBHOOK_SECRET!;
// IMPORTANT: raw body parser on the webhook route only
app.post(
'/hooks/postato',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body.toString('utf8');
const result = verifyPostatoSignature(
rawBody,
req.header('x-webhook-signature'),
WEBHOOK_SECRET
);
if (!result.ok) {
return res.status(401).json({ error: result.reason });
}
const event = JSON.parse(rawBody) as { type: string; data: unknown; id: string };
// Fire-and-forget: respond 200 fast, process async
res.status(200).send();
try {
await handleEvent(event);
} catch (err) {
// Log but don't throw — we already 200'd. Retries come via the next at-least-once delivery.
console.error('event handler failed', event.id, err);
}
}
);
app.listen(3000);Event dispatcher (pattern match)
// event-dispatcher.ts
type PostatoEvent =
| { type: 'post.published'; data: { postId: string; externalUrl: string } }
| { type: 'post.failed'; data: { postId: string; error: { code: string; message: string } } }
| { type: 'approval.decided'; data: { postId: string; decision: 'approved' | 'rejected'; comment?: string } }
| { type: 'account.disconnected'; data: { accountId: string; reason: string } };
export async function handleEvent(event: PostatoEvent) {
switch (event.type) {
case 'post.published':
await updatePostRecord(event.data.postId, { status: 'published', url: event.data.externalUrl });
break;
case 'post.failed':
await updatePostRecord(event.data.postId, { status: 'failed', error: event.data.error });
await notifyAuthor(event.data.postId, event.data.error.message);
break;
case 'approval.decided':
if (event.data.decision === 'rejected') {
await notifyAuthor(event.data.postId, event.data.comment ?? 'rejected');
}
break;
case 'account.disconnected':
await alertWorkspaceOwner(event.data.accountId, event.data.reason);
break;
}
}Idempotency on your side
Deliveries are at-least-once. If your handler is non-idempotent (e.g., sends a notification email), you'll occasionally send duplicates. Defense: remember recent event IDs:
const seen = new LRUCache<string, boolean>({ max: 10_000 });
async function handleEvent(event: PostatoEvent) {
if (seen.has(event.id)) return;
seen.set(event.id, true);
// ... normal handling
}For stricter guarantees, persist event IDs with a unique index in your DB and let duplicates fail insertion.
Responding fast
Always return 2xx before doing expensive work. Postato times out a delivery at 5 seconds. If your handler synchronously writes to a slow downstream, the delivery is marked failed and retried, potentially creating a retry storm. Queue the work, respond immediately.
Local testing
Use ngrok or Cloudflare Tunnel to expose localhost. Create a webhook pointing at the tunnel URL, publish a test post, watch your handler receive the event.
Fake signature for unit tests:
function signPayload(body: string, secret: string, t = Math.floor(Date.now() / 1000)) {
const payload = `${t}.${body}`;
const v1 = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return { header: `t=${t},v1=${v1}`, t };
}Exercise both success and failure paths (mismatch, stale timestamp, missing header) so regressions don't leak into production.