# Guide to HiBob Webhooks: Features and Best Practices

HiBob webhooks tell your systems when something changes in Bob, HiBob's HR platform: an employee is hired, updated, starts or leaves, a time off request is submitted or approved, a task is assigned, a document is signed, or a position is opened in workforce planning. If you sync people data into identity providers, payroll, IT provisioning or your own data warehouse, webhooks let those systems react to changes without polling Bob's API.

This guide covers HiBob's Webhooks v2: how they are configured, the Ping test, how to verify the `Bob-Signature` header, the events and payload, the retry and deactivation rules, and the best practices for running a HiBob receiver in production.

## What are HiBob webhooks?

Bob webhooks "let you subscribe to system events and receive programmatic notifications of changes as they occur". You add a webhook in the Bob UI with your URL and the events you want, and Bob sends a JSON `POST` to that URL whenever one of those events happens.

There are two generations. Webhooks v2 became available on January 21, 2025, and HiBob recommends it for all new work because it "will eventually replace Webhooks v1". Compared with v1, v2 sends less data, says it delivers events "in the right order", and handles retries better. This guide describes v2. A v2 body carries `"version": "v2"`; a v1 body has no `version` field. HiBob Marketplace partner apps have a different webhook setup that is not covered here.

The v2 payload carries no record data. It names the event and the ID of the thing that changed, and for updates the IDs of the fields that changed. You then read the current data from Bob's API.

## HiBob webhook features

| Feature | Details |
| --- | --- |
| Configuration | The Bob UI, with a URL (up to 2048 characters) and a choice of events |
| Connection test | A `POST` with "Ping test" when the webhook is added. Must get `200` |
| Signature header | `Bob-Signature` |
| Scheme | HMAC-SHA512, base64 digest |
| Signed content | The raw request body, nothing else |
| Secret | Per webhook, shown in the Bob UI |
| Replay window | None. No timestamp is signed |
| IP allowlisting | Supported. HiBob publishes its IPs in a help centre article |
| Response window | `200` within 10 seconds |
| Retries | Exponential backoff for up to 3 days |
| Deactivation | After 72 hours with no successful delivery on 3 consecutive days |
| Event retention | At least two working days |
| Ordering | v2 says events are sent in the right order |

## Common events

HiBob documents 29 v2 event types across five modules:

| Module | Event types | Fires when |
| --- | --- | --- |
| Employee data | `employee.created`, `employee.updated`, `employee.deleted` | An employee record is created, changed or deleted |
| Employee lifecycle | `employee.joined`, `employee.left`, `employee.activated`, `employee.inactivated`, `employee.temporaryLeave`, `employee.access.revoked.immediately` | A lifecycle entry becomes effective: access granted or enabled, termination, leave or temporary leave, or a termination with access revoked immediately |
| Custom tables | `table.entry.created`, `table.entry.updated` | A table entry is added or changed |
| Time off | `timeoff.request.requested`, `timeoff.request.approved`, `timeoff.request.declined`, `timeoff.request.cancelled`, `timeoff.request.deleted`, `timeoff.request.updated`, `timeoff.request.imported`, `timeoff.request.setEndDate` | A time off request moves through its lifecycle |
| Tasks | `todo.assign`, `todo.changedStatus` | A task is assigned or changes status |
| Docs | `documents.eSign.completed` | Every signer has finished signing a document |
| Workforce planning | `workforcePlanning.position.created`, `workforcePlanning.position.updated`, `workforcePlanning.positionOpening.created`, `workforcePlanning.positionOpening.updated`, `workforcePlanning.positionOpening.deleted`, `workforcePlanning.positionBudget.created`, `workforcePlanning.positionBudget.updated` | A position, opening or budget is created, changed or deleted |

Event types are case-sensitive and use camelCase segments, such as `timeoff.request.setEndDate` and `workforcePlanning.positionOpening.deleted`. Match them exactly.

Every v2 body has the same envelope. Here is HiBob's `employee.updated` example:

```json
{
  "companyId": 637323,
  "type": "employee.updated",
  "triggeredBy": "3332883804594373380",
  "triggeredAt": "2024-12-26T11:29:14.399997",
  "version": "v2",
  "data": {
    "employeeId": "3332883894235038486",
    "fieldUpdates": [
      { "id": "personal.birthDate" }
    ]
  }
}

```

`type` is the event, `triggeredBy` is the backend ID of the employee who made the change or `system` for automatic events, and `triggeredAt` is when it happened. `data` holds identifiers only: `employeeId` here, or `requestId`, `positionId` and similar for other modules, plus the changed field IDs on updates. For custom table events, HiBob also sends a `Get-Table` header with the API URL to read the entry.

## Setting up HiBob webhooks

Start your endpoint first, because Bob tests it when you add the webhook.

1. In Bob, add a webhook with your HTTPS URL and choose the events you want. HiBob's help centre article on managing webhooks has the click path for your account.
2. Bob sends a `POST` with the text "Ping test", or the JSON `{"text":"Ping test"}`, and expects a `200`. Your endpoint must answer it straight away.
3. Copy the webhook's secret from the Bob UI and store it server-side as `HIBOB_WEBHOOK_SECRET`.
4. Optionally, allowlist Bob's IP addresses as an extra check. HiBob lists them in its help centre.

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 hibob --path /webhooks/hibob`) gives you a public HTTPS URL that forwards to your local server, with no account required. Use it as the webhook URL, then change an employee record to trigger `employee.updated`.

## Securing HiBob webhooks

Read `Bob-Signature`, compute an HMAC-SHA512 of the raw request body with your webhook secret as the key, encode the 64-byte digest as standard base64 (88 characters ending in `==`), and compare in constant time. HiBob's Scala, PHP, PowerShell and Node samples all produce single-line base64. The Ruby sample uses `Base64.encode64`, which inserts line breaks, so use `Base64.strict_encode64` if you write Ruby.

HiBob does not say whether the Ping test is signed. Answer any body that is exactly a Ping test with a `200` before running verification, and verify everything else.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyBobSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha512', secret).update(rawBody).digest('base64');
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

function isPingTest(rawBody) {
  const text = rawBody.toString('utf8').trim();
  if (text === 'Ping test') return true;
  try {
    const parsed = JSON.parse(text);
    return parsed !== null && typeof parsed === 'object' && Object.keys(parsed).length === 1 && parsed.text === 'Ping test';
  } catch {
    return false;
  }
}

const app = express();

app.post('/webhooks/hibob', express.raw({ type: '*/*' }), (req, res) => {
  // The Ping test carries no event, so answering it reveals nothing.
  if (isPingTest(req.body)) return res.sendStatus(200);

  if (!verifyBobSignature(req.body, req.get('bob-signature'), process.env.HIBOB_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Queue a fetch of event.data.employeeId (or requestId, positionId), then answer.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_bob_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha512).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_bob_signature(raw_body, request.headers.get("bob-signature"),
#                      os.environ["HIBOB_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `hibob-test-secret` and this exact body:

```
{"companyId":637323,"type":"employee.created","triggeredBy":"system","triggeredAt":"2024-12-26T11:29:14.399997","version":"v2","data":{"employeeId":"3332883894235038486"}}

```

The signature should be:

```
njxzYmiyP+YcmqjZAB+34FA1yC2XvHu0Y9sraL5GIaTSBeQnZLSOTtOIeWrq5naDBpLVsVX27fCAnbmvmNSGng==

```

> **Make HiBob webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `Bob-Signature` with HMAC-SHA512, answers Bob within its 10-second window, and durably queues every employee and time off event before your service sees it.

## HiBob webhook limitations and pain points

### The Ping test fails the setup

**The Problem:** You add the webhook in Bob and it reports a failed connection.

**Why It Happens:** Bob's connection test is a `POST` whose body is plain text or a one-field JSON object, not an event. A handler that rejects unsigned requests, insists on a `type` field, or isn't running yet fails it.

**Workarounds:**

* Recognise the Ping test and answer `200` before verification, as above.
* Start your endpoint and any tunnel before adding the webhook.

**How Hookdeck Can Help:** A Hookdeck source answers Bob's requests with a `200` straight away, so the test passes even while your service is offline, and the request appears in the log for you to inspect.

### The payload doesn't contain the data

**The Problem:** `employee.updated` arrives, but it only lists `personal.birthDate` as changed, not the new value.

**Why It Happens:** v2 deliberately sends only IDs and changed field IDs. HiBob says "to pull the data itself, please use our API".

**Workarounds:**

* Treat each webhook as a prompt to fetch the employee, request or position from Bob's API.
* Use `fieldUpdates` to decide whether the change matters to you before making the API call.
* Run the fetch from a queue worker after the webhook has been answered.

**How Hookdeck Can Help:** Hookdeck delivers events to your worker at a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) you set, which keeps the follow-up API calls steady during a bulk import or reorganisation.

### Three days of failures deactivates the webhook

**The Problem:** Events stop, and the webhook shows as Inactive in Bob.

**Why It Happens:** Bob waits 10 seconds for a `200` and retries with exponential backoff for up to 3 days. It emails Bob admins after 1, 24 and 48 hours (status "Delivery error"), and at 72 hours deactivates the webhook (status "Inactive") if there were no successful deliveries on 3 consecutive days. Bob keeps events for at least two working days, with Friday to Sunday counted as non-working days.

**Workarounds:**

* Answer within 10 seconds, and do the API fetch in a worker.
* Add the people who own the integration to Bob's webhook failure emails.
* If a webhook goes Inactive, fix the endpoint and reactivate it promptly, inside the retention window.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, so your downtime doesn't count against Bob's retry budget. Events are delivered to your service with [retries](/docs/retries) when it is ready, and can be replayed.

### No event ID

**The Problem:** A retried delivery is processed twice.

**Why It Happens:** v2 bodies have no event ID field, and retries resend the same event.

**Workarounds:**

* Build a key from `type`, the main ID in `data` and `triggeredAt`, and store it before processing.
* Make the downstream work idempotent. Fetching the latest record and writing it again is safe to repeat.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated deliveries based on the fields you choose, such as `type`, `data.employeeId` and `triggeredAt`.

## Best practices

### Verify against the raw body

Mount `express.raw()` on the route, read `await request.body()` in FastAPI, or use `Buffer.from(await request.arrayBuffer())` in a Next.js route handler. Parse only after verifying.

### Route on `type`, exactly

Match the full, case-sensitive `type` string, and keep a default branch that logs and acknowledges anything new.

### Check the version

If you might receive v1 webhooks from older configurations, branch on the presence of `version: "v2"` and handle v1 bodies separately or migrate them.

### Acknowledge fast and process asynchronously

Return `200` within 10 seconds and fetch the record from Bob's API afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Deduplicate deliveries

See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) for patterns that work when the payload has no event ID.

## Conclusion

HiBob's v2 webhooks are signed with a base64 HMAC-SHA512 of the raw body in `Bob-Signature`. Answer the Ping test, verify every event against the raw bytes, route on the exact `type`, fetch the record from Bob's API because the payload only carries IDs, and answer within 10 seconds so the three-day retry window never runs out.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each signature, answers Bob immediately and holds every event until your service has processed it, so an outage on your side doesn't deactivate the webhook.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle HiBob webhooks reliably in minutes.