Guide to BambooHR Webhooks: Features and Best Practices
BambooHR webhooks tell your systems when employee records change: a new hire is added, a job title or pay rate is updated, or an employee is deleted. BambooHR is HR software for small and medium-sized businesses, and webhooks are how payroll, identity, IT provisioning and reporting tools stay in step with it without polling the API.
This guide covers how BambooHR webhooks are created and signed, the two payload styles, the retry policy, and the practices that keep a BambooHR receiver reliable.
What are BambooHR webhooks?
A BambooHR webhook sends an HTTPS POST to your URL when an employee record is created, deleted, or changes in a field you chose to monitor. There are two ways to create one:
- In the app. An admin, or a user with webhook permissions, adds it under Account Settings > Webhooks.
- Through the API. Anyone with an API key can call Create Webhook. BambooHR calls these Permissioned webhooks: they can only monitor and send fields the API user can see, and they stop working if that user's account is deactivated.
Each webhook also picks a data structure. Event-Based webhooks, which BambooHR recommends for new integrations, send a small notification with the employee ID and the names of the changed fields. Field-Based webhooks, the older style that BambooHR also calls Global webhooks, send the values of the fields you configure, and can batch several employees into one request.
Every request carries two headers: X-BambooHR-Timestamp and X-BambooHR-Signature. The signature is a hex HMAC-SHA256 of the raw body with the timestamp appended to the end, keyed with the webhook's private key.
BambooHR webhook features
| Feature | Details |
|---|---|
| Configuration | Account Settings > Webhooks, or the Create Webhook API |
| Payload styles | Event-Based (recommended) or Field-Based, chosen per webhook |
| Encodings | JSON (recommended) or application/x-www-form-urlencoded, chosen per webhook |
| Transport | HTTPS only. POST |
| Signature header | X-BambooHR-Signature, lowercase hex, no prefix |
| Timestamp header | X-BambooHR-Timestamp. Format not documented |
| Scheme | HMAC-SHA256 |
| Signed content | <raw body><X-BambooHR-Timestamp>: body first, no separator |
| Secret | A private key per webhook. The API returns it once, at creation |
| Batching | Field-Based payloads can carry several employees in one request |
| Retries | Up to 5, on network errors or 5xx only: immediately, then 5, 10, 20 and 40 minutes, each with 0 to 30 seconds of jitter |
| Timeout | Not documented |
| Source IPs | Not published |
Common events
The Create Webhook API lists these event types. A webhook can't mix the employee and employee_with_fields families.
| Event type | Payload style | Fires when |
|---|---|---|
employee.created | Event-Based | An employee record is created |
employee.updated | Event-Based | One or more monitored fields change. Needs at least one monitor field |
employee.deleted | Event-Based | An employee record is deleted |
employee_, .updated, .deleted | Field-Based | The same three changes, with the configured field values. These are the API's default if you send no events |
company.updated, company.deleted, company-integrations.updated | Event-Based envelope | Listed in the API's event enum, with a data.companyId payload. The webhook guides don't describe them further |
An Event-Based delivery names the event in type:
{
"type": "employee.updated",
"timestamp": "2026-01-31T18:42:00+0000",
"data": {
"companyId": "12345",
"employeeId": "67890",
"changedFields": ["firstName"]
}
}
changedFields appears only on employee.updated. BambooHR's advice for this style is to treat the webhook as a notification and fetch the current record from the API.
A Field-Based delivery has no top-level event name. It carries an employees array, and each entry has its own action of Created, Updated or Deleted:
{
"employees": [
{
"action": "Updated",
"changedFields": ["First Name"],
"fields": { "First Name": "Bryony" },
"id": "12345",
"timestamp": "2026-01-31T18:42:00+0000"
}
]
}
For Created and Deleted, changedFields and fields are empty. For Updated, fields holds the current value of every post field you configured, not only the changed ones. Field names are BambooHR's display names, such as First Name or Employee #, unless you set aliases. In the API's permissioned example each value is wrapped as { "value": ... }. The timestamp inside the body is when the change happened, and is unrelated to the X-BambooHR-Timestamp header.
BambooHR says the structure may grow, so ignore fields you don't recognise.
Setting up BambooHR webhooks
In the app:
- Go to Account Settings > Webhooks and create a webhook.
- Choose Event-Based or Field-Based, the encoding (JSON is recommended), and your HTTPS URL.
- Select the fields to monitor. For Field-Based webhooks, also choose the fields to post and, if you like, the names they use in the payload.
- Save the private key BambooHR generates for the webhook, for example as
BAMBOOHR_WEBHOOK_SECRET.
Through the API, call Create Webhook with name, url (it must start with https://), format (json or form-encoded), events and monitorFields, plus postFields for Field-Based webhooks:
{
"name": "Employee changes",
"url": "https://example.com/webhooks/bamboohr",
"format": "json",
"events": ["employee.created", "employee.updated", "employee.deleted"],
"monitorFields": ["firstName", "lastName", "jobTitle"]
}
The response includes privateKey. BambooHR says this is "the only time you will receive the private key", and that to get a new one you create a new webhook and delete the old one. Use the List Monitor Fields endpoint to see which fields your API user can monitor.
With Hookdeck. BambooHR is a supported source type in Hookdeck. Create a source with the BambooHR type, paste the webhook's private key into it, and use the Hookdeck source URL as the webhook URL in BambooHR. The source accepts POST only. It checks X-BambooHR-Signature against the raw body and the X-BambooHR-Timestamp header, for JSON and form-encoded bodies alike. A request that fails the check still gets a 200 from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.
Securing BambooHR webhooks
Read both headers, append the timestamp header to the raw body bytes, compute an HMAC-SHA256 with the private key as a UTF-8 string, and compare the hex digests in constant time. The order is the unusual part: the body comes first, and nothing separates it from the timestamp.
const crypto = require('crypto');
const express = require('express');
function verifyBambooHRSignature(rawBody, signature, timestamp, privateKey) {
if (!signature || !timestamp || !privateKey) return false;
if (!/^[0-9a-f]{64}$/i.test(signature)) return false; // validate before decoding
// Body FIRST, then the timestamp header value, with nothing in between.
const signed = Buffer.concat([rawBody, Buffer.from(timestamp, 'latin1')]);
const expected = crypto.createHmac('sha256', privateKey).update(signed).digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/bamboohr', express.raw({ type: '*/*' }), (req, res) => {
const privateKey = process.env.BAMBOOHR_WEBHOOK_SECRET;
if (!privateKey) return res.sendStatus(500); // fail closed
const ok = verifyBambooHRSignature(
req.body,
req.get('x-bamboohr-signature'),
req.get('x-bamboohr-timestamp'),
privateKey
);
if (!ok) return res.sendStatus(401);
// JSON webhooks: JSON.parse. Form-encoded webhooks: new URLSearchParams(req.body.toString('utf8')).
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import re
HEX_64 = re.compile(r"[0-9a-fA-F]{64}")
def verify_bamboohr_signature(raw_body: bytes, signature, timestamp, private_key: str) -> bool:
if not signature or not timestamp or not private_key:
return False
if not HEX_64.fullmatch(signature):
return False
signed = raw_body + timestamp.encode("latin-1") # body first, no separator
expected = hmac.new(private_key.encode("utf-8"), signed, hashlib.sha256).digest()
return hmac.compare_digest(bytes.fromhex(signature), expected)
BambooHR's own sample is PHP: hash_ over file_, compared with hash_equals. The code above does the same.
BambooHR states no replay window and doesn't document the format of X-BambooHR-Timestamp, so the samples don't check its age. Hookdeck doesn't apply a timestamp tolerance either, and Hookdeck retries and replays resend the original headers, so a delivery that reaches you hours later still verifies.
Make BambooHR webhooks production-ready. Hookdeck Event Gateway verifies the
X-BambooHR-Signatureheader for every employee event, stores each delivery, and retries it to your service on a schedule you control.
BambooHR webhook limitations and pain points
A signature that never matches
The Problem: Every delivery fails verification with the right private key.
Why It Happens: Most HMAC schemes put the timestamp first, often with a dot. BambooHR appends it to the end of the body with no separator. Verifying a parsed and re-encoded body fails too, and for form-encoded webhooks a framework's form parser discards the raw bytes you need.
Workarounds:
- Sign
raw body + timestamp, in that order, with nothing in between. - Read the raw body before any JSON or form parser runs.
How Hookdeck Can Help: Hookdeck's BambooHR source uses the same body-then-timestamp order, so the scheme lives in one place instead of in each service that receives BambooHR events.
Client errors are never retried
The Problem: One bad response loses an event for good.
Why It Happens: BambooHR retries only on network errors and 5xx responses. A 400, 401 or 404, including one from a deploy that briefly broke routing, ends delivery. Even for 5xx, BambooHR stops after five retries, the last one after a 40-minute delay.
Workarounds:
- Return
5xxfor problems on your side that a retry could fix, and4xxonly for requests you never want again. - Reconcile periodically against the employee API to catch anything missed.
How Hookdeck Can Help: Hookdeck answers BambooHR with a 200 as soon as a request arrives and stores it, then delivers to your service with automatic retries on your own schedule, so a bad deploy on your side doesn't burn BambooHR's attempts.
A lost key means a new webhook
The Problem: You can't look up a Permissioned webhook's private key after creation.
Why It Happens: Create Webhook returns privateKey once. To replace it, BambooHR says to create a new webhook and delete the old one.
Workarounds:
- Store the key in a secrets manager straight away.
- When replacing a webhook, run both briefly and deduplicate, so nothing is lost in the switch.
How Hookdeck Can Help: With Hookdeck in front, the key lives on one source. When you replace the webhook, you update one value rather than every consumer.
Permissioned webhooks depend on one person
The Problem: Deliveries stop, or fields go missing, with no change to your code.
Why It Happens: A Permissioned webhook runs with its creator's access. BambooHR notes that permission changes can invalidate it or hide fields, and that it stops working if the user is deactivated.
Workarounds:
- Create Permissioned webhooks with a dedicated integration user rather than a person's account.
- Alert when no BambooHR event has arrived for longer than usual.
How Hookdeck Can Help: Hookdeck Issues and the request log show when BambooHR traffic stops or starts failing, so a broken webhook doesn't go unnoticed.
Best practices
Prefer Event-Based webhooks for new integrations
The payload is small and stable, the event name is in type, and you fetch the current employee record from the API. Field-Based payloads depend on the post fields and aliases someone configured in settings, which can change without warning.
Loop over the whole employees array
Field-Based deliveries can batch several employees, each with its own action. Process every entry rather than the first one, and route on action.
Treat the webhook as a hint, then read the API
Retries and batching mean changes can arrive late or out of order. Use the employee ID to fetch the latest record before writing to payroll or identity systems.
Deduplicate
BambooHR sends no event ID. Build a key from the employee ID, the action or type, and the timestamp in the body, and skip ones you've already handled. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Verify, store the event, return 200, and do the work afterwards. See why to process webhooks asynchronously.
Conclusion
BambooHR signs each webhook with X-BambooHR-Signature, a hex HMAC-SHA256 of the raw body followed directly by X-BambooHR-Timestamp, keyed with the webhook's private key. Verify against the raw bytes in that order, choose Event-Based payloads where you can, handle batched Field-Based payloads entry by entry, and plan for a retry policy that skips 4xx responses and stops after five retries.
Hookdeck Event Gateway sits in front of your receiver, checks every BambooHR signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle BambooHR webhooks reliably in minutes.