# BambooHR Webhooks

## When to Use This Skill

* How do I receive BambooHR webhooks?
* How do I verify the `X-BambooHR-Signature` header?
* Why does my BambooHR webhook signature never match?
* How do I handle Event-Based `employee.created`, `employee.updated` and `employee.deleted` events?
* How do I handle Field-Based `Created`, `Updated` and `Deleted` employee actions?
* How do I read the batched `employees` array in a BambooHR webhook?

## How BambooHR Webhooks Work

BambooHR sends an HTTPS `POST` when employee records (or, for Event-Based webhooks, the company) change. Webhooks are set up by an admin in Account Settings > Webhooks (**Global**) or through the API (**Permissioned**, limited to the creating user's access). Each webhook picks a data structure: **Event-Based** (recommended by BambooHR: one event per request, `{ type, timestamp, data }`) or **Field-Based** (the older batched `employees` payload with posted field values). Each webhook has a **private key** used to sign requests; BambooHR documents one signing scheme for all webhooks, and the Create Webhook API returns a `privateKey` for HMAC-SHA256 verification whichever events you pick. Payloads are JSON (recommended) or `application/x-www-form-urlencoded`, chosen per webhook.

## Verification (core)

`X-BambooHR-Signature` is the lowercase **hex** HMAC-SHA256 of the **raw body bytes followed directly by the `X-BambooHR-Timestamp` header value** (body first, no separator), keyed with the webhook's private key as a UTF-8 string. No prefix on the signature.

Node:

```javascript
const crypto = require('crypto');

function verify(rawBody, signature, timestamp, privateKey) {
  if (!signature || !timestamp || !privateKey || !/^[0-9a-fA-F]{64}$/.test(signature)) return false;
  const message = Buffer.concat([rawBody, Buffer.from(timestamp, 'latin1')]);
  const expected = crypto.createHmac('sha256', privateKey).update(message).digest();
  const provided = Buffer.from(signature.toLowerCase(), 'hex'); // validated above
  return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
}

```

Python:

```python
import hashlib, hmac, re

def verify(raw_body: bytes, signature: str, timestamp: str, private_key: str) -> bool:
    if not signature or not timestamp or not private_key or not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    message = b"".join([raw_body, timestamp.encode("latin-1")])
    expected = hmac.new(private_key.encode(), message, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature), expected)

```

Reject with `401` when either header is missing, the signature is malformed, or it does not match. Verify the raw bytes before parsing JSON or form data; never re-serialize the body.

**Timestamp window:** off by default. Hookdeck retries and replays resend the original headers, so a fixed window would reject legitimate retries. If you want one, set `BAMBOOHR_WEBHOOK_TOLERANCE_SECONDS` (unset or `0` = off). BambooHR does not state the header's format, so the examples only parse it when you opt in (epoch seconds or milliseconds).

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bamboohr-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bamboohr-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bamboohr-webhooks/examples/fastapi/).

## Event Payload

There is no event-type header. Tell the two shapes apart by the body: Event-Based payloads have a top-level `type`, Field-Based payloads have an `employees` array. The examples handle both.

### Event-Based (recommended)

```json
{
  "type": "employee.updated",
  "timestamp": "2026-01-31T18:42:00+0000",
  "data": { "companyId": "12345", "employeeId": "67890", "changedFields": ["firstName"] }
}

```

| `type` | Triggered When | `data` |
| --- | --- | --- |
| `employee.created` | An employee record was created (an `employee.updated` follows) | `companyId`, `employeeId` |
| `employee.updated` | A monitored field changed | `companyId`, `employeeId`, `changedFields` (API field aliases) |
| `employee.deleted` | An employee record was deleted | `companyId`, `employeeId` |
| `company.updated` | Company information or status changed | `companyId` |
| `company.deleted` | Company status changed to `DELETED` or `CANCELLED` | `companyId` |
| `company-integrations.updated` | Company integration settings changed | `companyId` |

The payload is a lightweight notification: fetch current data from the BambooHR API. The body `timestamp` is ISO 8601 and is unrelated to the `X-BambooHR-Timestamp` header.

### Field-Based (legacy `employee_with_fields.*` events)

Each entry in the `employees` array carries an `action`:

| `action` | Meaning | `changedFields` / `fields` |
| --- | --- | --- |
| `Created` | Employee record created | Empty |
| `Updated` | Monitored fields changed | Names of changed fields / posted field values |
| `Deleted` | Employee record deleted | Empty |

```json
{
  "employees": [
    { "id": "40351", "action": "Updated", "timestamp": "2025-08-18T21:38:50Z",
      "changedFields": ["Employee #"], "fields": { "Employee #": "9", "First Name": "Robert" } },
    { "id": "40546", "action": "Created", "timestamp": "2025-08-19T21:38:50Z",
      "changedFields": [], "fields": {} }
  ]
}

```

Loop over `employees` and ignore fields you don't recognize. The per-employee `timestamp` is ISO 8601 and is unrelated to the `X-BambooHR-Timestamp` header.

## Important Headers

| Header | Description |
| --- | --- |
| `X-BambooHR-Signature` | Hex HMAC-SHA256 of body + timestamp header value |
| `X-BambooHR-Timestamp` | When the request was sent; part of the signed content |

## Environment Variables

```bash
BAMBOOHR_WEBHOOK_SECRET=your_webhook_private_key   # Settings > Webhooks, or `privateKey` in the Create Webhook response
BAMBOOHR_WEBHOOK_TOLERANCE_SECONDS=0               # optional; 0 or unset = no timestamp window

```

## Retries

BambooHR retries up to 5 times on network errors or 5xx responses (not 4xx), at roughly immediately, 5, 10, 20 and 40 minutes, each with 0-30 seconds of jitter. Return `2xx` once verified; return `401` for bad signatures (a 4xx is not retried).

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 bamboohr --path /webhooks/bamboohr

```

BambooHR only delivers to HTTPS URLs, so use the Hookdeck URL as the webhook URL.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/bamboohr-webhooks/references/overview.md) - BambooHR webhook types, Event-Based and Field-Based payloads, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/bamboohr-webhooks/references/setup.md) - Creating webhooks in Settings or the API, getting the private key
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/bamboohr-webhooks/references/verification.md) - Signature verification details and gotchas