# 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/bindbee/2026.10.06/connector.linked.json):

```json
{
  "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:

1. Open **Webhooks** in the Bindbee dashboard.
2. On the **Security signature** card, click the eye icon and copy the secret.
3. Store it as `BINDBEE_WEBHOOK_SECRET` exactly as shown. Don't add `whsec_` and don't base64-encode it.

Then add the webhook:

1. Click **Add webhook** and enter the destination URL after the fixed `https://`.
2. Leave **API Versions** on **Version 2 (2026.10.06)**. You can't change it once the webhook exists.
3. Give it a unique **Webhook Name**, and tick **Production**, **Development** or both under **Webhook Type**.
4. 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.
5. 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](/docs/cli):

```bash
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:

```javascript
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:

```javascript
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](/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 `400` for 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](/webhooks/guides/implement-webhook-idempotency) 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](https://hookdeck.com) closes that gap by storing every event on arrival and retrying your service on its own schedule. [Create a free account](https://dashboard.hookdeck.com/signup) to set up a Bindbee source.