HollyHR Developer Docs
  • HollyHR
  • Sign in
  • Manage API keys
  • Start Here
  • Core API
  • AI and MCP
  • API Reference
  • Recipes
  • Resources
Recipe indexGitHub Actions recipesMCP smoke testSafe MCP leave bookingPeople syncTest SDK from sourceSlack who's awayGoogle Sheets exportReceive webhooks in NodeCreate person + webhookWebhook + payroll referencesPayroll readiness export
Recipes

Receive webhooks in Node

This recipe shows a minimal Express receiver that verifies HollyHR webhook signatures before processing the event.

Setup

TerminalCode
npm install express

Store the signing secret returned when you create or rotate the webhook endpoint:

TerminalCode
export HOLLYHR_WEBHOOK_SECRET="whsec_..."

Receiver

Code
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:

Code
https://your-public-tunnel.example.com/hollyhr/webhooks

Then queue a test event from the API reference or with:

TerminalCode
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 June 24, 2026
Google Sheets exportCreate person + webhook
On this page
  • Setup
  • Receiver
  • Test it
  • Production checklist
Javascript