Gareth Wilson Gareth Wilson

Guide to Factorial Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI only: POST /api/2026-10-01/resources/api_public/webhook_subscriptions
Subscription modelOne subscription_type per subscription
EndpointPublic HTTPS. No plain HTTP, localhost or private addresses
Method and formatPOST, application/json
BodyThe affected resource's JSON, with no envelope and no event field
AuthenticationOptional challenge, echoed in x-factorial-wh-challenge
Other headersx-factorial-author-id and x-factorial-author-type (employee or company), when the author is visible to you
Signature and timestampNone
Payload versionPinned per subscription with api_version
AcknowledgementAny 2xx
RetriesUp to 20 attempts within 48 hours of the event
Auto-disableAfter the attempts or the 48-hour window run out, with a final email
Delivery IDNone
PermissionsA 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 typeSent when
employees/employee/create_with_contractAn employee is created together with a contract
employees/employee/updateAn employee record is modified
employees/employee/terminate, employees/employee/unterminateAn employee is marked as terminated, or the termination is reverted
contracts/contract_version/create, contracts/contract_version/updateA contract version is created or modified
timeoff/leave/create, timeoff/leave/approve, timeoff/leave/rejectA leave request is submitted, approved or rejected
attendance/shift/clock_inAn employee clocks in a shift
ats/candidate/create, ats/application/createA candidate or a job application is created
bookkeepers_management/incidence/updateA 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 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:

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.

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:

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

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.

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 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 for free and handle Factorial 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.