Agent skill

BambooHR Webhooks Skill

Receive and verify BambooHR webhooks, both Event-Based (employee.created, employee.updated, employee.deleted, company.*) and Field-Based (the batched employees payload with Created, Updated and Deleted actions). Use when setting up BambooHR webhook handlers or debugging X-BambooHR-Signature verification.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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:

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:

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/, examples/nextjs/, 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.

{
  "type": "employee.updated",
  "timestamp": "2026-01-31T18:42:00+0000",
  "data": { "companyId": "12345", "employeeId": "67890", "changedFields": ["firstName"] }
}
typeTriggered Whendata
employee.createdAn employee record was created (an employee.updated follows)companyId, employeeId
employee.updatedA monitored field changedcompanyId, employeeId, changedFields (API field aliases)
employee.deletedAn employee record was deletedcompanyId, employeeId
company.updatedCompany information or status changedcompanyId
company.deletedCompany status changed to DELETED or CANCELLEDcompanyId
company-integrations.updatedCompany integration settings changedcompanyId

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:

actionMeaningchangedFields / fields
CreatedEmployee record createdEmpty
UpdatedMonitored fields changedNames of changed fields / posted field values
DeletedEmployee record deletedEmpty
{
  "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

HeaderDescription
X-BambooHR-SignatureHex HMAC-SHA256 of body + timestamp header value
X-BambooHR-TimestampWhen the request was sent; part of the signed content

Environment Variables

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

# 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →