Gareth Wilson Gareth Wilson

Guide to Workable Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe API: POST https://{subdomain}.workable.com/spi/v3/subscriptions
Required scoper_candidates or r_employees
Subscription modelOne event per subscription
Filtersjob_shortcode and stage_slug in args, for candidate events only
Target URLsMust be unique. A duplicate returns 409 Conflict
Event nameevent_type in the body
Signature headerX-Workable-Signature
SchemeHMAC-SHA256 of the request body. Encoding not documented (hex in practice)
KeyThe account API token, or the OAuth application secret
Replay windowNone. No timestamp is signed
Success200. 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:

EventResourceNotes
candidate_createdCandidateA candidate is added
candidate_movedCandidateA candidate moves to another pipeline stage
candidate_deletedCandidateA candidate is deleted
job_deletedJobA job is deleted
employee_createdEmployeeMinimal employee information
employee_updatedEmployeeThe fields that were updated
employee_publishedEmployeeOnly the employee ID
onboarding_completedEmployeeThe employee finishes the onboarding workflow, with onboarding content
timeoff_updatedEmployeeA 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:

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

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.

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:

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

Acknowledge fast and process asynchronously

Return 200 as soon as the request is verified and stored. See why to process webhooks asynchronously.

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 sits in front of your receiver, checks every Workable signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Workable 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.