Gareth Wilson Gareth Wilson

Guide to Humaans Webhooks: Features and Best Practices

Published


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

FeatureDetails
Who can createOwners, on the Humaans webhooks page, or anyone with an API token that has the webhooks:manage scope
ConfigurationThe webhooks page, or POST /api/webhooks
ActivationA webhook must be published (status: "active") to receive events
SubscriptionsubscribedEvents per webhook
Payloadid, seq, ts, action, subject, actor, entity
Signature headerswebhook-id, webhook-timestamp, webhook-signature
SchemeStandard Webhooks: HMAC-SHA256, base64, entries prefixed with v1,
Signed content{webhook-id}.{webhook-timestamp}.{raw body}
Secretwhsec_..., returned as prodEndpointSignature on the webhook
Success responseAny 2xx
RetriesExponential backoff
Auto-disableWhen every attempt to the endpoint fails for 5 days
Source IPs52.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.

EntityActions
Peopleperson.created, person.updated, person.offboarded, person.reonboarded, person.deleted
Time awaytimeAway.created, timeAway.updated, timeAway.deleted, timeAway.approved, timeAway.rejected
Time away adjustmentstimeAwayAdjustment.created, timeAwayAdjustment.updated, timeAwayAdjustment.deleted
Compensationcompensation.created, compensation.updated, compensation.deleted
Bank accountsbankAccount.created, bankAccount.updated, bankAccount.deleted
Job rolesjobRole.created, jobRole.updated, jobRole.deleted
Emergency contactsemergencyContact.created, emergencyContact.updated, emergencyContact.deleted
Equipmentequipment.created, equipment.updated, equipment.deleted

Here is Humaans' example event:

{
  "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

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.

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:

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 includes a manual verifier where you can drop or widen the window.

Make Humaans webhooks production-ready. Hookdeck 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 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 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.

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.

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 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 for free and handle Humaans webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

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