# Receive webhooks in Node

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

## Setup

```bash
npm install express
```

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

```bash
export HOLLYHR_WEBHOOK_SECRET="whsec_..."
```

## Receiver

```js
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:

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

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

```bash
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.
