# Guide to Factorial Webhooks: Features and Best Practices

Factorial webhooks tell your systems what is happening in your HR data as it happens. An employee is hired or terminated, a leave request is submitted or approved, a shift is clocked in, a candidate applies for a job, a contract changes. If your company runs HR, time tracking or recruiting on Factorial, webhooks are how those changes reach your directory, payroll, provisioning and analytics systems without polling the API.

This guide covers how Factorial webhook subscriptions are created and authenticated, the subscription types and the shape of their payloads, Factorial's retry and auto-disable rules, and the practices that make a Factorial receiver dependable.

## What are Factorial webhooks?

A Factorial webhook subscription connects one event type, the `subscription_type`, to one endpoint, the `target_url`, within one company. There is no dashboard for them: you create, list, update and delete subscriptions through the API. When the event happens, Factorial sends an HTTP `POST` with a JSON body describing the affected resource.

The body is the resource itself: the employee, the leave request, the shift. There is no envelope and no field that names the event. Factorial suggests either one URL for everything, in which case your handler has to tell events apart from the payload, or one URL per subscription type, which makes routing explicit. The second is usually simpler.

Factorial does not sign deliveries. When you create a subscription you can set a `challenge`, a secret of your choosing, and Factorial sends it back unchanged in the `x-factorial-wh-challenge` header of every delivery. If you set no challenge, Factorial sends nothing to verify.

## Factorial webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: `POST /api/2026-10-01/resources/api_public/webhook_subscriptions` |
| Subscription model | One `subscription_type` per subscription |
| Endpoint | Public HTTPS. No plain HTTP, `localhost` or private addresses |
| Method and format | `POST`, `application/json` |
| Body | The affected resource's JSON, with no envelope and no event field |
| Authentication | Optional `challenge`, echoed in `x-factorial-wh-challenge` |
| Other headers | `x-factorial-author-id` and `x-factorial-author-type` (`employee` or `company`), when the author is visible to you |
| Signature and timestamp | None |
| Payload version | Pinned per subscription with `api_version` |
| Acknowledgement | Any `2xx` |
| Retries | Up to 20 attempts within 48 hours of the event |
| Auto-disable | After the attempts or the 48-hour window run out, with a final email |
| Delivery ID | None |
| Permissions | A subscription only receives what its creating token can see |

## Common events

Factorial lists every subscription type in the Webhooks section of its API reference, around 137 at the time of writing, in `domain/resource/action` form. These are among the most used:

| Subscription type | Sent when |
| --- | --- |
| `employees/employee/create_with_contract` | An employee is created together with a contract |
| `employees/employee/update` | An employee record is modified |
| `employees/employee/terminate`, `employees/employee/unterminate` | An employee is marked as terminated, or the termination is reverted |
| `contracts/contract_version/create`, `contracts/contract_version/update` | A contract version is created or modified |
| `timeoff/leave/create`, `timeoff/leave/approve`, `timeoff/leave/reject` | A leave request is submitted, approved or rejected |
| `attendance/shift/clock_in` | An employee clocks in a shift |
| `ats/candidate/create`, `ats/application/create` | A candidate or a job application is created |
| `bookkeepers_management/incidence/update` | A compensation incidence is updated |

Other areas include ATS job postings, attendance breaks and clock-outs, documents, expenses, finance, locations, payroll, performance reviews, projects, shift management, tasks, teams, trainings and work schedules.

A delivery looks like this, trimmed from Factorial's `employees/employee/update` example:

```
POST https://yourapp.com/webhooks/factorial/employees
content-type: application/json
x-factorial-wh-challenge: 2zal4e6d

{
  "id": 2,
  "access_id": 1,
  "first_name": "Ana",
  "last_name": "Blanco Perez",
  "full_name": "Ana Blanco Perez",
  "email": "ana@factorial.com",
  "nationality": "es",
  ...
}

```

`id` is the resource's ID, not a delivery ID, and Factorial is explicit that there is no unique delivery identifier. Payloads can include personal and financial data such as national IDs and bank details, so keep them out of general-purpose logs.

> **Inspect Factorial webhooks as they arrive.** Use a [Hookdeck Inspect](https://inspect.hookdeck.com) URL as a subscription's `target_url` to see the exact resource body and headers, with no account or setup required.

## Setting up Factorial webhooks

You need an API key or an OAuth2 access token for the company, and the company's ID. Use an API key or a company token rather than a user token: a subscription only receives payloads its creating token could see, so a user token can silently miss other employees' events.

Generate a long random challenge, for example with `openssl rand -hex 24`, and create one subscription per event:

```bash
curl --request POST \
  --url https://api.factorialhr.com/api/2026-10-01/resources/api_public/webhook_subscriptions \
  --header 'x-api-key: YOUR_API_KEY' \
  --header 'content-type: application/json' \
  --data '{
    "company_id": 157412,
    "subscription_type": "timeoff/leave/create",
    "target_url": "https://yourapp.com/webhooks/factorial/leave-create",
    "name": "Leave requests",
    "challenge": "YOUR_LONG_RANDOM_CHALLENGE",
    "enabled": true,
    "api_version": "2026-10-01"
  }'

```

Keep the returned subscription `id`: you need it to enable, disable or delete the subscription later. Set a technical support contact under **Settings** > **API Notifications** so failure emails reach the team that owns the integration.

To route through Hookdeck, create a Hookdeck source with the **Factorial** type and use its URL as the `target_url`. The source has one field, **Challenge**: enter the same challenge you set on the subscription. Hookdeck compares it with the whole `x-factorial-wh-challenge` value. Set a challenge on every subscription that points at the source, because without one Factorial sends nothing to verify. The source accepts `POST`. If you route several subscription types through one source, add a different path to each `target_url` so you can tell them apart.

## Securing Factorial webhooks

Compare the whole `x-factorial-wh-challenge` header with the challenge you stored, in constant time. There is no prefix to strip and nothing to decode.

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

const CHALLENGE = process.env.FACTORIAL_WH_CHALLENGE;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// One route per subscription type, so the path says what the body is.
app.post('/webhooks/factorial/:type', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('x-factorial-wh-challenge');
  if (!CHALLENGE || typeof received !== 'string' || !safeEqual(received, CHALLENGE)) {
    return res.sendStatus(401);
  }

  let resource;
  try {
    resource = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Store { type: req.params.type, resource }, return 200, process afterwards.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

CHALLENGE = os.environ.get("FACTORIAL_WH_CHALLENGE", "")

def verify_factorial_request(headers) -> bool:
    if not CHALLENGE:
        return False  # never accept when unconfigured
    received = headers.get("x-factorial-wh-challenge")
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), CHALLENGE.encode("utf-8"))

```

Factorial's own example compares the header with the stored challenge in the same way. A static challenge authenticates the sender, not the body: it cannot detect a modified request, and with no signature or timestamp a captured request can be replayed. Factorial already requires HTTPS. Use a long random challenge, and to rotate it, create a new subscription with a new challenge and delete the old one.

> **Make Factorial webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the challenge on every Factorial delivery, acknowledges straight away so retries never pile up, and stores each HR event durably so a failing endpoint never gets your subscription disabled.

## Factorial webhook limitations and pain points

### Exhausted retries disable the subscription

**The Problem:** After an outage, events stop arriving altogether, even once your service is healthy again.

**Why It Happens:** Factorial retries a failed delivery up to 20 times within 48 hours of the event. When either limit is reached, it sends a final email and disables the subscription. No further events are delivered until someone re-enables it.

**Workarounds:**

* Set a technical support contact under **Settings** > **API Notifications** so the failure emails reach someone who will act on them.
* Re-enable the same subscription with `PUT` and `{"enabled": true}` once your service is healthy. You do not need to recreate it.
* List subscriptions on a schedule and alert on any that are disabled.

**How Hookdeck Can Help:** Hookdeck acknowledges each delivery as it arrives and stores it, so Factorial never sees your downtime as failures. Hookdeck then delivers to your service with [retries](/docs/retries) on your schedule, and you can replay events after a fix.

### No event type and no delivery ID

**The Problem:** One endpoint cannot tell a new employee from an updated one, and retried deliveries cannot be told apart from new ones.

**Why It Happens:** The body is the resource alone. The `subscription_type` lives on the subscription, not on the delivery, and there is no per-delivery identifier.

**Workarounds:**

* Give each subscription its own URL path, as above, and treat the path as the event type.
* Make handlers converge on the resource's state: upsert on `id`, or deduplicate on the path, `id` and `updated_at`.

**How Hookdeck Can Help:** Hookdeck keeps the path of every request, so you can route on it with [filters](/docs/filters), and [deduplication](/docs/deduplication) can drop repeats based on the fields you choose.

### No challenge, nothing to verify

**The Problem:** Requests arrive with no `x-factorial-wh-challenge` header, and anyone who finds the URL can send look-alike requests.

**Why It Happens:** The `challenge` field is optional when you create a subscription, and Factorial only sends the header when one was set.

**Workarounds:**

* Always set a challenge, and reject requests without the header.
* If a subscription was created without one, create a replacement with a challenge and delete the old one.

**How Hookdeck Can Help:** Hookdeck's Factorial source requires the challenge. Hookdeck answers Factorial with a `200` either way, but a request with a missing or wrong challenge is recorded as failed verification and never reaches your service.

### Events missing for some employees

**The Problem:** Deliveries arrive for some people but not others.

**Why It Happens:** A subscription never receives payloads its creating token could not see. A subscription created with a user token is limited to what that user can see in Factorial.

**Workarounds:**

* Create subscriptions with an API key or a company token.
* Check the creating token's permissions first when events seem to be missing.

**How Hookdeck Can Help:** Every request Hookdeck receives is logged, so you can confirm quickly whether Factorial sent an event at all before you debug your own code.

## Best practices

### One URL per subscription type

Because nothing in the body names the event, the URL is the most reliable routing key. Give each subscription its own path and route on it.

### Pin api_version

Set `api_version` explicitly when you create a subscription, so the payload shape stays stable even as Factorial releases newer API versions.

### Converge on resource state

Deliveries can repeat, and Factorial does not promise an order. Upsert on the resource `id` and compare `updated_at` before overwriting newer data. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

A slow response counts as a failure, and failures lead to a disabled subscription. Verify the challenge, store the event, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Factorial sends each subscribed HR event as an unsigned JSON `POST` of the affected resource, with no event field and no delivery ID, and authenticates it only with the challenge you set on the subscription. Always set a challenge, compare it in constant time over HTTPS, give each subscription its own URL, upsert on the resource, and respond quickly so 20 failed attempts never disable the subscription.

[Hookdeck Event Gateway](https://hookdeck.com) checks the challenge, acknowledges Factorial immediately, and keeps every event until your service has processed it, so an outage on your side does not switch your integration off.

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