Create a narrow public endpoint
Accept only HTTPS POST requests, cap the request size and keep the raw body available for signature verification. Do not expose diagnostic details to callers.
Verify before parsing
Compute the signature over the exact raw bytes received and compare it in constant time. Reject missing, malformed, stale or invalid signatures before reading event fields.
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).end();
}Acknowledge, then do the work
Return a success response after authentication and durable enqueueing. Database joins, AI calls and outbound requests belong in a bounded worker queue, not in the request path.
Slow handlers encourage retries and make healthy traffic look like duplicate traffic.
Make processing idempotent
Insert the event ID into a table with a unique constraint before applying side effects. If the insert conflicts, acknowledge the replay and skip the work.
INSERT INTO webhook_events (workspace_id, event_id, received_at)
VALUES ($1, $2, now())
ON CONFLICT (workspace_id, event_id) DO NOTHING;Route by event type
Handle inbound messages, delivery changes and connection changes independently. Unknown event types should be recorded safely and ignored rather than crashing the consumer.
Exercise replay and recovery
Replay a signed fixture, send events out of order, interrupt the worker and rotate the secret. Confirm that no event is lost and no customer action is repeated.
- Invalid signature rejected
- Replay has no second side effect
- Queue failure is observable
- Secret rotation is tested