# Guide to Humaans Webhooks: Features and Best Practices

Humaans webhooks tell your systems when people, time away, compensation, job roles and equipment change in your HR information system. If Humaans is where your company keeps employee records, webhooks are how a new starter, a leaver or an approved holiday reaches payroll, IT provisioning and your internal directory without polling the API.

This guide covers how Humaans webhooks are created, published and signed, the events and the event wrapper, how failures lead to a disabled endpoint, and the practices that keep a Humaans receiver reliable.

## What are Humaans webhooks?

A Humaans webhook has a name, an endpoint URL and a list of subscribed events. Only an Owner can create one in the app, and it must be published before Humaans sends anything to it. When a subscribed event happens, Humaans sends a JSON event to the endpoint.

The event wrapper is the same for every action. It says what happened, to whom, who did it, and which fields changed, with both the new and previous values. That is often enough to act on without calling the API.

Humaans delivers webhooks through Svix and signs them with the Standard Webhooks scheme: `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, and a `whsec_` secret per webhook.

## Humaans webhook features

| Feature | Details |
| --- | --- |
| Who can create | Owners, on the Humaans webhooks page, or anyone with an API token that has the `webhooks:manage` scope |
| Configuration | The webhooks page, or `POST /api/webhooks` |
| Activation | A webhook must be published (`status: "active"`) to receive events |
| Subscription | `subscribedEvents` per webhook |
| Payload | `id`, `seq`, `ts`, `action`, `subject`, `actor`, `entity` |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Scheme | Standard Webhooks: HMAC-SHA256, base64, entries prefixed with `v1,` |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw body}` |
| Secret | `whsec_...`, returned as `prodEndpointSignature` on the webhook |
| Success response | Any `2xx` |
| Retries | Exponential backoff |
| Auto-disable | When every attempt to the endpoint fails for 5 days |
| Source IPs | `52.215.16.239`, `54.216.8.72`, `63.33.109.123`, `2a05:d028:17:8000::/52` |

Humaans names no timestamp tolerance and publishes no response timeout.

## Common events

Humaans lists 28 actions. The action is `<entity>.<verb>` in camelCase.

| Entity | Actions |
| --- | --- |
| People | `person.created`, `person.updated`, `person.offboarded`, `person.reonboarded`, `person.deleted` |
| Time away | `timeAway.created`, `timeAway.updated`, `timeAway.deleted`, `timeAway.approved`, `timeAway.rejected` |
| Time away adjustments | `timeAwayAdjustment.created`, `timeAwayAdjustment.updated`, `timeAwayAdjustment.deleted` |
| Compensation | `compensation.created`, `compensation.updated`, `compensation.deleted` |
| Bank accounts | `bankAccount.created`, `bankAccount.updated`, `bankAccount.deleted` |
| Job roles | `jobRole.created`, `jobRole.updated`, `jobRole.deleted` |
| Emergency contacts | `emergencyContact.created`, `emergencyContact.updated`, `emergencyContact.deleted` |
| Equipment | `equipment.created`, `equipment.updated`, `equipment.deleted` |

Here is Humaans' example event:

```json
{
  "id": "qCNHkhApepOJf0eFHuPzQbDg",
  "seq": "01J4GSN4XYQ7401FXCYQPWH3V7",
  "ts": "2024-08-05T08:14:24.190Z",
  "action": "emergencyContact.created",
  "subject": { "id": "8Rc7dFxm7OFoiwxVBIFrLRAi", "type": "person" },
  "actor": { "personId": "8Rc7dFxm7OFoiwxVBIFrLRAi", "type": "person" },
  "entity": {
    "id": "OCPMqZtTcgFOuoc56TiKo9OT",
    "fieldsChanged": ["name", "email"],
    "next": { "name": "jane", "email": "jd@humaans.io" },
    "prev": { "name": null, "email": null },
    "customFields": [],
    "metadata": {}
  }
}

```

* `action` is the event type. Route on it.
* `seq` is a sortable sequence string, which lets you put events in order.
* `subject` is the person affected, and `actor` is whoever made the change, which can be an integration.
* `entity.fieldsChanged`, `entity.next` and `entity.prev` describe the change. Custom field IDs can appear in `fieldsChanged`.
* `entity.metadata` adds context for some actions, such as the compensation type on `compensation.updated`.

## Setting up Humaans webhooks

### In the Humaans app

1. As an Owner, open the webhooks page and create a webhook.
2. Name it, enter the endpoint URL, and choose the events to subscribe to.
3. Publish the webhook. Until you do, it receives nothing.
4. Copy the `whsec_` signing secret and store it server-side, for example as `HUMAANS_WEBHOOK_SECRET`.

### Through the API

```bash
curl https://app.humaans.io/api/webhooks \
  -H "Authorization: Bearer $HUMAANS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST \
  -d '{
    "name": "Payroll sync",
    "prodEndpointUrl": "https://example.com/webhooks/humaans",
    "subscribedEvents": ["person.created", "person.offboarded", "timeAway.approved"],
    "status": "active"
  }'

```

The token needs the `webhooks:manage` scope. The response includes `prodEndpointSignature`, which is the `whsec_` signing secret. When `status` is `active`, `prodEndpointUrl` and `subscribedEvents` are required.

If you use Hookdeck, create a source with the Humaans source type, paste the whole `whsec_` secret into it, and use the Hookdeck source URL as the webhook's endpoint URL.

## Securing Humaans webhooks

To verify a delivery, join `webhook-id`, `webhook-timestamp` and the raw request body with dots. Take the part of the secret after `whsec_`, base64-decode it, and use the bytes as the HMAC-SHA256 key. Encode the result as base64 and compare it in constant time with each `v1,` entry in the space-delimited `webhook-signature` header. A match on any entry is enough.

Humaans' own Node sample doesn't check the timestamp, and Humaans names no window. The check below is optional and off by default. If you turn it on, remember that a delivery replayed later, for example from Hookdeck, keeps its original timestamp.

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

function verifyHumaansSignature(rawBody, headers, secret, toleranceSeconds = 0) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
  if (!/^\d+$/.test(timestamp)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/humaans', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyHumaansSignature(req.body, req.headers, process.env.HUMAANS_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the webhook-id header, then route on event.action.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hashlib
import hmac
import time

def verify_humaans_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
        return False
    if not timestamp.isdigit():
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False

    try:
        key = base64.b64decode(secret[len("whsec_"):])
    except (binascii.Error, ValueError):
        return False
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_humaans_signature(raw_body, request.headers, os.environ["HUMAANS_WEBHOOK_SECRET"])

```

Humaans' sample strips `v1,` and compares a single value. Checking every entry, as above, keeps working if more than one signature is sent, for example while a secret is rotated.

Because the scheme is Standard Webhooks, the `standardwebhooks` and `svix` libraries also verify Humaans deliveries. They always enforce a five-minute timestamp window, so a delivery that Hookdeck retries or replays more than five minutes after Humaans sent it fails the library's check even though its signature is valid. The [Humaans webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/humaans-webhooks) includes a manual verifier where you can drop or widen the window.

> **Make Humaans webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Standard Webhooks signature on every Humaans delivery, stores each people and time-away event, and delivers it to payroll, IT and directory services on your schedule.

## Humaans webhook limitations and pain points

### Five days of failure disables the endpoint

**The Problem:** After a long outage, your integration stops receiving events and stays that way.

**Why It Happens:** If every attempt to an endpoint fails for 5 days, Humaans disables it. The clock starts only once several deliveries have failed within 24 hours, with at least 12 hours between the first and last failure, but a broken receiver over a long weekend can get there.

**Workarounds:**

* Alert on errors from your webhook route, so a failure doesn't go unnoticed for days.
* After fixing the receiver, set the webhook's status back to `active`, and reconcile people and time away through the API for the gap.

**How Hookdeck Can Help:** Hookdeck accepts Humaans' deliveries while your service is down, so the endpoint never looks failed to Humaans. Events wait in Hookdeck and are delivered when your service is back, with [retries](/docs/retries) you configure.

### A created webhook does nothing until it is published

**The Problem:** You create a webhook, change a person in Humaans, and nothing arrives.

**Why It Happens:** New webhooks start unpublished. Through the API, `status` can be `draft`, `active` or `disabled`, and only `active` webhooks receive events.

**Workarounds:**

* Publish the webhook in the app, or create it with `status: "active"`.
* Check the status first whenever events seem to be missing.

**How Hookdeck Can Help:** Hookdeck's request log shows whether anything reached the source, which tells you quickly whether the problem is on the Humaans side.

### Events can arrive out of order

**The Problem:** An older change to a person overwrites a newer one.

**Why It Happens:** Retries with exponential backoff mean a failed event can arrive after later ones.

**Workarounds:**

* Store the last `seq` you processed for each `entity.id`, and skip events with a lower `seq`.
* For important records, fetch the current state from the API before writing it.

**How Hookdeck Can Help:** Hookdeck keeps every event with its body, so you can replay a sequence in order when you need to rebuild a record.

### Sensitive data in the payload

**The Problem:** Webhook bodies carry personal data such as compensation and bank account changes.

**Why It Happens:** `entity.next` and `entity.prev` include the changed values, so a `bankAccount.updated` or `compensation.updated` event carries the details in the body.

**Workarounds:**

* Verify every request, and allowlist Humaans' published IP addresses as an extra check.
* Subscribe only to the entities each integration needs, and avoid logging full bodies.

**How Hookdeck Can Help:** Hookdeck's [transformations](/docs/transformations) can remove fields before an event reaches a destination that doesn't need them.

## Best practices

### Verify against the raw body

Humaans' sample signs the body exactly as received. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in Next.js and `await request.body()` in FastAPI, and parse only after verification.

### Deduplicate on `webhook-id`

Retries reuse the same `webhook-id`. Record it before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use `fieldsChanged` to skip irrelevant updates

A `person.updated` event fires for any profile change. Check `entity.fieldsChanged` and return early when none of the fields you care about changed.

### Acknowledge fast and process asynchronously

Verify, store the event, return `200`, and do the provisioning or payroll work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Humaans delivers webhooks through Svix with Standard Webhooks signatures. Verify them against the raw body with the decoded `whsec_` key, remember to publish each webhook, use `seq` to keep events in order and `webhook-id` to drop duplicates, and keep the endpoint healthy, because five days of failures disables it.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Humaans signature and holds each event until your service has processed it, so an outage on your side doesn't cost you a leaver or a pay change.

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