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-Signatureheader? - Why does my BambooHR webhook signature never match?
- How do I handle Event-Based
employee.created,employee.updatedandemployee.deletedevents? - How do I handle Field-Based
Created,UpdatedandDeletedemployee actions? - How do I read the batched
employeesarray 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_ (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.
Event-Based (recommended)
{
"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 |
{
"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
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
- references/overview.md - BambooHR webhook types, Event-Based and Field-Based payloads, retries
- references/setup.md - Creating webhooks in Settings or the API, getting the private key
- references/verification.md - Signature verification details and gotchas