# Guide to Workable Webhooks: Features and Best Practices

Workable webhooks tell your systems when something changes in your hiring pipeline or HR records: a candidate applies or moves to a new stage, an employee record is created, updated or published, onboarding is completed, or a time-off request is approved. They are how Workable data reaches your ATS integrations, HRIS syncs and provisioning scripts without polling the API.

This guide covers how Workable webhook subscriptions are created, which key signs them and what the docs leave out, the candidate and employee events and their payloads, and the best practices for running a Workable receiver in production.

## What are Workable webhooks?

Workable calls its webhooks subscriptions. You register one through the Workable SPI (its API) by posting a target URL and the event you want. When that event happens, Workable sends a `POST` to the URL with the associated resource: a candidate object for candidate events, or employee, onboarding or time-off data for HR events.

Every request is signed with an `X-Workable-Signature` header holding an HMAC-SHA256 of the request body. Workable has no separate webhook secret. The key is the account API token that you used to create the subscription, or the OAuth application secret if your integration uses OAuth.

## Workable webhook features

| Feature | Details |
| --- | --- |
| Configuration | The API: `POST https://{subdomain}.workable.com/spi/v3/subscriptions` |
| Required scope | `r_candidates` or `r_employees` |
| Subscription model | One event per subscription |
| Filters | `job_shortcode` and `stage_slug` in `args`, for candidate events only |
| Target URLs | Must be unique. A duplicate returns `409 Conflict` |
| Event name | `event_type` in the body |
| Signature header | `X-Workable-Signature` |
| Scheme | HMAC-SHA256 of the request body. Encoding not documented (hex in practice) |
| Key | The account API token, or the OAuth application secret |
| Replay window | None. No timestamp is signed |
| Success | `200`. Anything else is treated as an error and the request is replayed |

Workable doesn't document a response timeout, the retry schedule or source IP addresses for webhooks.

## Common events

Workable lists nine events:

| Event | Resource | Notes |
| --- | --- | --- |
| `candidate_created` | Candidate | A candidate is added |
| `candidate_moved` | Candidate | A candidate moves to another pipeline stage |
| `candidate_deleted` | Candidate | A candidate is deleted |
| `job_deleted` | Job | A job is deleted |
| `employee_created` | Employee | Minimal employee information |
| `employee_updated` | Employee | The fields that were updated |
| `employee_published` | Employee | Only the employee ID |
| `onboarding_completed` | Employee | The employee finishes the onboarding workflow, with onboarding content |
| `timeoff_updated` | Employee | A time-off request changes |

Two details don't line up in Workable's docs. The `/subscriptions` endpoint's description of `event` lists seven values and leaves out `candidate_deleted` and `job_deleted`. And you subscribe to `timeoff_updated`, but Workable's time-off examples arrive with an `event_type` of `timeoff_request_approved` or `timeoff_request_reverted`. Route time-off events on those values.

Every body has the same envelope. Here is Workable's `employee_created` example, trimmed:

```json
{
  "id": "4d3",
  "resource_type": "employee",
  "event_type": "employee_created",
  "fired_at": "2024-01-08T14:01:06.217Z",
  "data": {
    "id": "4d3",
    "status": "status_draft",
    "firstname": "Obediah",
    "lastname": "Kydde",
    "job_title": "Software Engineer",
    "start_date": "2022-09-13T16:56:33.000Z",
    "employee_number": "1"
  }
}

```

`id` is the event identifier, `resource_type` is `candidate` or `employee`, and `data` is the resource. For candidate events, `data` is a full candidate object similar to the candidate details endpoint, including the `job` (`shortcode`, `title`), `stage`, education, experience and contact details.

The candidate payload reflects the resource at the time Workable sends the request, not when the event fired. Workable says these are usually the same, but race conditions can make them differ. For employees, Workable's own flow is to wait for `employee_published` and then fetch the complete record with `GET /employees/:id`.

## Setting up Workable webhooks

Subscriptions are created through the API with an access token that has the `r_candidates` or `r_employees` scope.

```bash
curl -X POST "https://$WORKABLE_SUBDOMAIN.workable.com/spi/v3/subscriptions" \
  -H "Authorization: Bearer $WORKABLE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "target": "https://example.com/webhooks/workable",
    "event": "candidate_moved",
    "args": {
      "account_id": "your-subdomain",
      "job_shortcode": "",
      "stage_slug": "hired"
    }
  }'

```

* `target` and `event` are required. The response is the subscription `id`.
* For candidate events, `args` filters by job and stage. If you include `args`, `account_id`, `job_shortcode` and `stage_slug` are all required. Use an empty string for all jobs or all stages.
* Leave `args` out for employee events, which don't use it.
* List subscriptions with `GET /subscriptions` and remove one with `DELETE /subscriptions/:id`.

Because target URLs must be unique, you can't point two subscriptions at exactly the same URL. Give each one its own path or query string, for example `/webhooks/workable?event=candidate_moved`.

Store the key that will sign the requests: the API token you used to create the subscription, or your OAuth application's secret. If you use Hookdeck, create a Workable source and paste that same value into its signing key field. Each subscription needs a unique target, so add a distinct path to the Hookdeck source URL for each event.

## Securing Workable webhooks

Compute an HMAC-SHA256 of the raw request body with the API token or OAuth app secret, and compare it with `X-Workable-Signature` in constant time.

Workable's docs name the header and the algorithm but not the encoding. Lowercase hex is what the maintained n8n and Botpress Workable integrations use, and it's what Hookdeck's Workable source expects. Capture one real delivery and confirm the format before you rely on it.

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

function verifyWorkableSignature(rawBody, signatureHeader, key) {
  if (!signatureHeader) return false;

  const expected = crypto.createHmac('sha256', key).update(rawBody).digest('hex');

  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/workable', express.raw({ type: '*/*' }), (req, res) => {
  // The account API token that created the subscription, or the OAuth app secret.
  const key = process.env.WORKABLE_SIGNING_KEY;
  if (!key) return res.sendStatus(500); // fail closed

  if (!verifyWorkableSignature(req.body, req.get('x-workable-signature'), key)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.event_type, deduplicate on event.id, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_workable_signature(raw_body: bytes, signature_header, key: str) -> bool:
    if not signature_header:
        return False
    expected = hmac.new(key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_workable_signature(raw_body, request.headers.get("x-workable-signature"),
#                           os.environ["WORKABLE_SIGNING_KEY"])

```

> **Make Workable webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Workable-Signature` header, so your services don't each need a copy of your Workable API token, and durably queues every candidate and employee event before your service sees it.

## Workable webhook limitations and pain points

### The signing key is a full API credential

**The Problem:** To verify webhooks, every service that receives them needs your Workable API token or OAuth app secret.

**Why It Happens:** Workable has no dedicated webhook secret. It signs with the credential that created the subscription, which can also read and change data through the API.

**Workarounds:**

* Verify in one place and pass verified events on, so only that component holds the token.
* Create subscriptions with a token scoped to `r_candidates` or `r_employees` only, if your setup allows.
* If you replace the token, expect the signing key to change with it, and update the verifier at the same time.

**How Hookdeck Can Help:** Hookdeck verifies the signature with the key stored on the source, so your services receive already-verified events and never hold the Workable credential.

### The encoding isn't documented

**The Problem:** Your verifier rejects every delivery, and Workable's docs don't tell you what format to expect.

**Why It Happens:** Workable says only "HMAC SHA256 on the payload". It doesn't say whether the digest is hex or base64, or give a code sample. Third-party integrations agree on lowercase hex.

**Workarounds:**

* Start with lowercase hex, as above.
* If nothing matches, log the header from one delivery. A 64-character value made only of hex digits is hex, and a 44-character value ending in `=` is base64.
* Check you're using the right key: the token that created that subscription, or the OAuth app secret for OAuth integrations.

**How Hookdeck Can Help:** Hookdeck shows each request's headers and its verification result, so you can see what Workable sent and whether it verified before writing any code.

### Payloads can describe a later state

**The Problem:** A `candidate_moved` event shows a stage the candidate has already left, or details that changed after the event.

**Why It Happens:** Workable builds the candidate payload when it sends the request, not when the event fired. A retry sent later carries even newer data.

**Workarounds:**

* Use `fired_at` for ordering, and treat `data` as the latest known state rather than a historical snapshot.
* For decisions that matter, fetch the candidate or employee from the API.

**How Hookdeck Can Help:** Hookdeck stores every event exactly as it arrived, so you can see what Workable sent and when, even if the resource has changed since.

### Retries without a schedule

**The Problem:** You don't know how long Workable keeps retrying, or when it stops.

**Why It Happens:** Workable says only that a non-`200` response is an error and the request is replayed. There's no documented timeout, attempt count or retry window.

**Workarounds:**

* Return `200` exactly, as soon as the event is verified and stored.
* Deduplicate on the event `id`, since a retry can follow a timeout your server didn't notice.

**How Hookdeck Can Help:** Hookdeck acknowledges Workable as soon as each request is stored, then retries delivery to your service on a schedule you control and lets you replay anything that failed.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Workable sent. In Express, mount `express.raw()` on the webhook route and parse after verification. In a Next.js route handler, read `await request.text()`. In FastAPI, read `await request.body()`.

### Filter candidate events at the source

Use `job_shortcode` and `stage_slug` so you only receive what you act on. A subscription to `candidate_moved` with `stage_slug` set to `hired`, for example, is a clean trigger for onboarding.

### Route on `event_type`, not the subscription

Route on the body's `event_type`, and handle the time-off values `timeoff_request_approved` and `timeoff_request_reverted` as well as any you don't recognise yet.

### Deduplicate on `id`

The top-level `id` is the event identifier. Store it before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Return `200` as soon as the request is verified and stored. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Workable signs each webhook with an HMAC-SHA256 of the raw body in `X-Workable-Signature`, keyed with the account API token or OAuth app secret rather than a webhook secret. Use lowercase hex and confirm it against a real delivery, give each subscription a unique URL, route on `event_type` (time-off events arrive under their own names), treat `data` as the latest state, and answer with `200` quickly.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Workable signature, and holds each event until your service has processed it.

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