This recipe shows a minimal Express receiver that verifies HollyHR webhook
signatures before processing the event.
Setup
Store the signing secret returned when you create or rotate the webhook
endpoint:
export HOLLYHR_WEBHOOK_SECRET="whsec_..."
Receiver
import crypto from "node:crypto";
import express from "express";
const app = express();
const port = Number(process.env.PORT || 3000);
const secret = process.env.HOLLYHR_WEBHOOK_SECRET;
if (!secret) {
throw new Error("HOLLYHR_WEBHOOK_SECRET is required");
}
app.post(
"/hollyhr/webhooks",
express.raw({ type: "application/json" }),
async (request, response) => {
const body = request.body.toString("utf8");
const eventId = request.header("HollyHR-Webhook-Id");
const timestamp = request.header("HollyHR-Webhook-Timestamp");
const signatureHeader = request.header("HollyHR-Webhook-Signature");
if (!eventId || !timestamp || !signatureHeader) {
return response.status(400).send("Missing webhook headers");
}
const timestampSeconds = Number(timestamp);
const ageSeconds = Math.abs(Date.now() / 1000 - timestampSeconds);
if (!Number.isFinite(timestampSeconds) || ageSeconds > 300) {
return response.status(400).send("Stale webhook timestamp");
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
const signatures = signatureHeader
.split(",")
.map((value) => value.trim())
.filter((value) => value.startsWith("v1="))
.map((value) => value.slice(3));
const expectedBuffer = Buffer.from(expected, "hex");
const verified = signatures.some((signature) => {
const signatureBuffer = Buffer.from(signature, "hex");
return (
signatureBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
);
});
if (!verified) {
return response.status(401).send("Invalid signature");
}
const event = JSON.parse(body);
// Store eventId before side effects so duplicate deliveries are safe.
// If eventId already exists in your store, return 200 and do nothing.
await processEvent(event);
return response.sendStatus(204);
},
);
async function processEvent(event) {
// Update your downstream system here.
}
app.listen(port);
Test it
Create a public HTTPS tunnel to your local receiver and register the tunnel URL
as a webhook:
https://your-public-tunnel.example.com/hollyhr/webhooks
Then queue a test event from the API reference or with:
curl -X POST "$HOLLYHR_API_BASE_URL/webhooks/{webhookId}/test" \
-H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: idem_test_webhook_001" \
--data '{"event_type":"webhook.test"}'
Production checklist
- Use HTTPS.
- Verify the signature before parsing or trusting the payload.
- Reject stale timestamps according to your tolerance.
- Deduplicate by
HollyHR-Webhook-Id.
- Return 2xx only after safely accepting the event.
- Process expensive work asynchronously after accepting the delivery.
Last modified on