Gareth Wilson Gareth Wilson

Guide to Bindbee Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard, Webhooks > Add webhook
Versions2 (2026.10.06, current), 1 (2025.01.01, deprecated December 31, 2026)
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature, with webhook-id and webhook-timestamp
SecretOne per organisation, raw UTF-8 key with no prefix
Legacy headersX-BINDBEE-WEBHOOK-SIGNATURE (body only), X-BINDBEE-WEBHOOK-VERSION (unsigned)
BodyJSON
Event routingBody field event
Timeout10 seconds
RetriesUp to 4 attempts, 1, 2 and 4 seconds apart
OrderingIn order within one sync. Use sequence across syncs and connectors
Source IPsNot published

Events and payloads

Version 2 has six connector events:

EventFires when
connector.linkedA new connector finishes linking and its credentials validate
connector.sync.startedA sync job begins
connector.sync.completedA sync job finishes successfully, after every data event from that sync
connector.sync.failedA sync job fails
connector.relink_neededA connector's credentials stop working and it moves to RELINK_NEEDED
connector.relinkedA 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:

CategoryModels
hriscompany, 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
atsdepartment, office, remote_user, job, job_interview_stage, reject_reason, tag, screening_question, candidate, application, activity, attachment, eeoc, scheduled_interview, scorecard, offer
lmscategory, 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:

FieldNotes
idEquals the webhook-id header; identical on every retry. Your dedupe key
sequenceIncreases per connector; gaps are normal
modelData events only (employee, bank_info, ...)
dataData events: array of record IDs (read them with the ids filter). Connector events: an object with sync state
syncAbsent on connector.linked and connector.relinked; null when unknown
errorOnly 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:

  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:

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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.