Guide to Bindbee Webhooks: Features and Best Practices
Bindbee gives you one API for dozens of HR, applicant-tracking and learning systems, such as BambooHR. Each customer connection is a connector, and Bindbee syncs its data into a common model. Webhooks tell you what those syncs did: a connector linked, a sync started, finished or failed, credentials stopped working, or a sync created or updated employees, candidates, courses and other records. They replace polling Bindbee for changes.
What are Bindbee webhooks?
You create webhooks in the Bindbee dashboard. Each one is pinned to a version, which fixes the events it can subscribe to and the shape of every payload, and to an environment: production, development or both. Events are chosen by exact name.
This guide covers version 2 (2026.10.06), the current version. Version 1 (2025.01.01) is deprecated on December 31, 2026, uses different event names and sends no webhook-* headers, so the verification below doesn't apply to it.
Version 2 signs every delivery to the Standard Webhooks construction, with one difference that matters: the key is the dashboard secret's UTF-8 bytes, not a base64-decoded whsec_ value. Every delivery also carries the legacy X-BINDBEE-WEBHOOK-SIGNATURE header, an HMAC of the body alone, which is the only signature version 1 webhooks have.
Bindbee webhook features
| Feature | Details |
|---|---|
| Configuration | Dashboard, Webhooks > Add webhook |
| Versions | 2 (2026.10.06, current), 1 (2025.01.01, deprecated December 31, 2026) |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature, with webhook-id and webhook-timestamp |
| Secret | One per organisation, raw UTF-8 key with no prefix |
| Legacy headers | X-BINDBEE-WEBHOOK-SIGNATURE (body only), X-BINDBEE-WEBHOOK-VERSION (unsigned) |
| Body | JSON |
| Event routing | Body field event |
| Timeout | 10 seconds |
| Retries | Up to 4 attempts, 1, 2 and 4 seconds apart |
| Ordering | In order within one sync. Use sequence across syncs and connectors |
| Source IPs | Not published |
Events and payloads
Version 2 has six connector events:
| Event | Fires when |
|---|---|
connector.linked | A new connector finishes linking and its credentials validate |
connector.sync.started | A sync job begins |
connector.sync.completed | A sync job finishes successfully, after every data event from that sync |
connector.sync.failed | A sync job fails |
connector.relink_needed | A connector's credentials stop working and it moves to RELINK_NEEDED |
connector.relinked | A connector in RELINK_NEEDED is reconnected and its credentials validate |
Data events are named <category>.<model>.created or <category>.<model>.updated, for example hris.employee.created, across these models:
| Category | Models |
|---|---|
hris | company, location, group, pay_group, payroll_code, payroll_run_calendar, employee, employment, compensation, dependent, bank_info, employer_benefit, benefit, dependent_benefit, benefit_coverage, time_off, time_off_balance, timesheet_entry, payroll_run, employee_payroll_run |
ats | department, office, remote_user, job, job_interview_stage, reject_reason, tag, screening_question, candidate, application, activity, attachment, eeoc, scheduled_interview, scorecard, offer |
lms | category, skill, user, course, content, enrollment, completion |
A data event doesn't carry the records. Its data is an array of record IDs, which you read back through the API with the ids filter. The full envelope looks like this:
| Field | Notes |
|---|---|
id | Equals the webhook-id header; identical on every retry. Your dedupe key |
sequence | Increases per connector; gaps are normal |
model | Data events only (employee, bank_info, ...) |
data | Data events: array of record IDs (read them with the ids filter). Connector events: an object with sync state |
sync | Absent on connector.linked and connector.relinked; null when unknown |
error | Only on connector.sync.failed and connector.relink_needed (failed_at, message, detail) |
X-BINDBEE-WEBHOOK-VERSION is not signed, so use it for routing only. Ignore fields you don't recognise, since later versions can add them.
This is the connector.linked sample from the webhook samples library:
{
"id": "01a0aa24-69b4-7b07-8071-284464571a7e",
"event": "connector.linked",
"version": "2026.10.06",
"sequence": 1,
"occurred_at": "2026-09-16T12:15:08.211961+00:00",
"webhook": {
"webhook_id": "01a0a417-ce39-7799-9565-993c95eec951",
"url": "https://example.com/webhooks/bindbee",
"event": "connector.linked"
},
"connector": {
"id": "01a0a4c6-9807-7c4b-9705-e8359ac15030",
"integration": {
"name": "BambooHR",
"slug": "bamboohr"
},
"category": "HRIS",
"origin_id": "prod_acme-inc_x1y2z3",
"connector_status": "COMPLETE",
"name": "Acme Inc",
"environment": "production"
},
"data": {
"id": "01a0a4c7-48de-76e9-a7be-3a10e07787b5",
"integration_name": "bamboohr",
"category": "HRIS",
"display_status": "Syncing",
"last_sync_start_time": null
}
}
Bindbee sends one sync's events one at a time: connector.sync.started, then every created event, then every updated event with parent models first, then connector.sync.completed. A failed sync sends connector.sync.started, connector.sync.failed, and connector.relink_needed if the connection broke.
Setting up Bindbee webhooks
Admins and Members can both manage webhooks. Start with the signing secret:
- Open Webhooks in the Bindbee dashboard.
- On the Security signature card, click the eye icon and copy the secret.
- Store it as
BINDBEE_WEBHOOK_SECRETexactly as shown. Don't addwhsec_and don't base64-encode it.
Then add the webhook:
- Click Add webhook and enter the destination URL after the fixed
https://. - Leave API Versions on Version 2 (2026.10.06). You can't change it once the webhook exists.
- Give it a unique Webhook Name, and tick Production, Development or both under Webhook Type.
- Select events. A sensible start is
connector.linked,connector.sync.completed,connector.sync.failed,connector.relink_needed, and the created and updated events for the models you use. - Click Create webhook.
Send test event sends a single HEAD request with no body. It only reports "Invalid URL" if your endpoint answers 5xx or not at all, so it tests reachability, not your handler.
Bindbee refuses URLs from public request-inspection services such as webhook.site, URL shorteners, and addresses that resolve to private networks. To receive events locally, create a Hookdeck source with the Bindbee type, paste the security signature secret as-is, register the source URL, and forward it with the Hookdeck CLI:
npx hookdeck-cli listen 3000 bindbee --path /webhooks/bindbee
Securing Bindbee webhooks
Build <webhook-id>.<webhook-timestamp>.<raw body>, compute HMAC-SHA256 keyed with the secret's UTF-8 bytes, base64-encode it, and compare with each v1, entry in webhook-signature. Today there is one entry. This function uses the raw secret as Bindbee intends, and still accepts a whsec_ value by decoding it:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = secret.startsWith('whsec_')
? decodeBase64(secret.slice(6)) : Buffer.from(secret, 'utf8');
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Bindbee's docs show the Standard Webhooks library too, by wrapping the raw secret as whsec_ plus its base64 encoding, which decodes back to the same bytes. That library rejects timestamps more than five minutes from your clock. Bindbee's own retries finish within seconds, but a request that Hookdeck retries or replays later keeps its original webhook-timestamp, and Hookdeck applies no timestamp tolerance, so the function above leaves the timestamp check out.
Because a data event only lists IDs, the receiver stores the event and lets a worker read the records:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// saveEvent(event) stores the event under event.id and resolves once it is durable.
function createBindbeeReceiver(saveEvent) {
const app = express();
app.post('/webhooks/bindbee', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.BINDBEE_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
try {
await saveEvent(event); // a worker reads event.data IDs with the `ids` filter
} catch {
return res.sendStatus(503); // retried up to 3 more times within seconds
}
return res.sendStatus(200);
});
return app;
}
A Hookdeck Bindbee source supports version 2 webhooks only. It answers Bindbee with 200 whatever the signature, and records a mismatch as failed verification instead of forwarding the request.
Make Bindbee webhooks production-ready. Hookdeck Event Gateway verifies every version 2 delivery with your raw organisation secret, stores each connector and record event, and keeps retrying your service long after Bindbee's last attempt.
Bindbee webhook limitations and pain points
Retries end within seconds
The Problem: A deploy or a short outage loses events for good.
Why It Happens: Bindbee makes up to four attempts, 1, 2 and 4 seconds apart, with a 10-second timeout each. After that there is no redelivery or replay, and the endpoint stays enabled, so dropped events don't show up as a disabled webhook either.
Workarounds:
- Keep the endpoint highly available and answer as soon as the event is stored.
- After an outage, read changed records through the API with
modified_after.
How Hookdeck Can Help: Hookdeck accepts Bindbee's first attempt and stores it, then retries delivery to your service for as long as you configure, so a few seconds of downtime no longer means lost data.
Some failures are final
The Problem: A redirect or a validation error from your stack means the event is never retried.
Why It Happens: Bindbee retries only 408, 429, 5xx, timeouts and network errors. Any other 4xx and every 3xx is final, and redirects aren't followed.
Workarounds:
- Register the final URL, not one that redirects (for example from HTTP to HTTPS or to a trailing slash).
- Don't return
400for payloads you don't understand. Accept and ignore them.
How Hookdeck Can Help: Hookdeck's source always answers Bindbee with 200, and your own service's responses only affect Hookdeck's retries, which you control.
One secret for every webhook
The Problem: Regenerating the secret breaks every webhook at once.
Why It Happens: A single organisation-wide secret signs all webhooks on both versions, and regenerating it invalidates the old one immediately.
Workarounds:
- Regenerate only when you can deploy the new value to every receiver straight away.
- Keep the secret in one secrets store that all receivers read.
How Hookdeck Can Help: With Hookdeck in front, the secret lives on one Hookdeck source, so a rotation is a single update.
Version 1 can't be verified this way
The Problem: An older webhook fails verification in your new receiver and in Hookdeck.
Why It Happens: Version 1 deliveries carry only the body-only X-BINDBEE-WEBHOOK-SIGNATURE, with no webhook-id or webhook-timestamp. A webhook's version can't be changed after creation.
Workarounds:
- Create a new version 2 webhook with the events you need, move traffic to it, then delete the version 1 webhook before the December 31, 2026 deprecation.
How Hookdeck Can Help: Hookdeck's request log shows which deliveries lack webhook-* headers, which identifies any version 1 webhooks still pointing at your source.
Best practices
Deduplicate on id
The body's id equals the webhook-id header and is the same on every retry. Store it before you process. Our idempotency guide shows how to make that check durable.
Order with sequence
sequence increases per connector and can have gaps. Use it to ignore an update older than one you've already applied, since events from different syncs can interleave.
Treat connector.sync.completed as the commit point
All data events from a sync are sent before connector.sync.completed. Batch record fetches and only mark a sync as done when that event arrives.
React to connector.relink_needed
Bindbee calls this the one event that means your customer has to act: the connector's credentials have stopped working. Ask the customer to relink, and treat connector.relinked as the sign that data will flow again.
Conclusion
Bindbee's version 2 webhooks are carefully specified, with ordering within a sync, a stable id and a sequence number. Delivery is the weak point: four attempts over about seven seconds, and no replay.
Hookdeck Event Gateway closes that gap by storing every event on arrival and retrying your service on its own schedule. Create a free account to set up a Bindbee source.