Gareth Wilson Gareth Wilson

Guide to Recruitee Webhooks: Features and Best Practices

Published


Recruitee webhooks tell your systems what is happening in your hiring pipeline: a candidate applies, is assigned to a job, moves to the next stage, is disqualified, or is deleted, and jobs are published, updated or taken down. If you hire with Recruitee, the applicant tracking system, webhooks are how those changes reach your HRIS, onboarding tools, analytics and team notifications without polling the API.

This guide covers how Recruitee webhooks are created and tested, how they are signed, the events and subtypes they send, the retry schedule and rate limits, and the practices that keep a Recruitee receiver reliable.

What are Recruitee webhooks?

A Recruitee webhook is a configuration with a name, an HTTPS POST URL and a set of events. When one of those events happens, Recruitee sends a JSON POST describing it.

Every event shares one structure: an event id, an attempt_count, a created_at time, an event_type, an event_subtype that refines it, and a payload with the candidates, jobs and company involved.

Every request is signed. Recruitee includes an X-Recruitee-Signature header holding a hex HMAC-SHA256 of the request body, keyed with your company's webhook secret. Recruitee publishes a complete worked example, so you can test your verifier against its own numbers.

Before a webhook is saved, Recruitee sends a test request with the body {"test":true}. Your endpoint has to answer it with 200 for Verify and create to succeed.

Recruitee webhook features

FeatureDetails
ConfigurationSettings > Apps and Plugins > Webhooks, by members with the "Manage webhooks" ability
EndpointHTTPS POST URL
Test request{"test":true}. Must get a 200 before the webhook can be created
Event identityevent_type and event_subtype in the body
Signature headerX-Recruitee-Signature
SchemeHMAC-SHA256, lowercase hex, no prefix
Signed contentThe raw request body
SecretOne webhook secret, shown on the webhooks settings page
Replay windowNone. No timestamp is sent or signed
SuccessHTTP 200 only
Response window10 seconds
RetriesUp to nine, from 1 minute to 48 hours apart
Request log30 days, with cancel and retry-now controls
Rate limit100 requests per minute, 5 for trial companies
User agentRecruitee-Webhooks

Recruitee publishes no source IP list for webhooks.

Common events

Recruitee documents seven events. event_subtype tells you more about each one:

event_typeevent_subtypeFires when
new_candidateemail, career_site, manual, importedA candidate applies or is created
candidate_assignedemail, career_site, manual, importedA new or existing candidate is assigned to a job or talent pool
candidate_movedstage_changed, disqualified, requalifiedA candidate moves to another pipeline stage, or is disqualified or requalified
candidate_deletedmanualA candidate is deleted
offer_publishedmanualA job is published
offer_unpublishedmanualA job is unpublished or archived
offer_updatedoffer_changed, status_changed, tags_changedA job is updated

In Recruitee's API, jobs are called offers, which is why the job events start with offer_. There is no separate disqualification event: a disqualified candidate arrives as candidate_moved with event_subtype set to disqualified.

Here is the candidate_moved event from Recruitee's documentation, trimmed:

{
  "attempt_count": 1,
  "created_at": "2020-12-14T04:47:33.905241Z",
  "event_subtype": "stage_changed",
  "event_type": "candidate_moved",
  "id": 30,
  "payload": {
    "candidate": {
      "id": 975539,
      "name": "test",
      "emails": [],
      "phones": [],
      "source": "manual"
    },
    "company": { "id": 5943, "name": "MP company 2" },
    "details": {
      "from_stage": { "id": 911876, "name": "Phone interview" },
      "to_stage": { "id": 911877, "name": "On-site interview" }
    },
    "offer": { "id": 269122, "kind": "job", "slug": "test-job", "title": "test job" }
  }
}

id uniquely identifies the event and stays the same on retries. attempt_count starts at 1 and goes up with each retry. created_at is when the event happened and doesn't change. The contents of payload.details depend on the subtype: from_stage and to_stage for a stage change, to_stage and a disqualify_reason for a disqualification, and to_stage alone for a requalification.

Setting up Recruitee webhooks

Start your receiver first, because Recruitee tests it before saving the webhook.

  1. Make sure your hiring role has the Manage webhooks ability, under Add-ons.
  2. Go to Settings > Apps and Plugins > Webhooks and click + New webhook.
  3. Enter a name, your HTTPS endpoint as the POST URL, and the events you want. You can change the events later.
  4. Click Test to send {"test":true}, or a Test payload button to send an example event with test added.
  5. Click Verify and create. Recruitee sends the test request again and creates the webhook once your endpoint answers 200.
  6. Copy the webhook secret from the same settings page and store it server-side, for example as RECRUITEE_WEBHOOK_SECRET.

If you use Hookdeck, create a Recruitee source, set the secret as its Webhook Signing Secret, and use the source URL as the POST URL. Recruitee signs every request, so turn verification on. The test request is a plain POST with no challenge to echo, so a Hookdeck source answers it with 200 like any other request.

Securing Recruitee webhooks

Compute an HMAC-SHA256 of the raw request body with the webhook secret, encode it as hex, and compare it with X-Recruitee-Signature in constant time. The secret is used as it appears on the settings page, not decoded.

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

function verifyRecruiteeSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const expected = crypto.createHmac('sha256', secret).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/recruitee', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.RECRUITEE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyRecruiteeSignature(req.body, req.get('x-recruitee-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  if (event.test === true) {
    return res.sendStatus(200); // Test, Test payload or Verify and create: not a real event
  }
  // Deduplicate on event.id, route on event.event_type + event.event_subtype.
  res.sendStatus(200); // exactly 200: any other status is a failure
});

The same check in Python:

import hashlib
import hmac


def verify_recruitee_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.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_recruitee_signature(raw_body, request.headers.get("x-recruitee-signature"),
#                            os.environ["RECRUITEE_WEBHOOK_SECRET"])

Recruitee's documentation includes a worked example you can use as a test vector. With the secret DuP4ej5yyJB5TIrIEI/dCtJN7sHj and the full candidate_moved body from the Webhook verification section of its docs, sent exactly as shown on a single line, the signature is:

3b64e3049cb9e108fbb18a453e909cb4a32e3ae140ea01d84c2b5d316c19162f

Copy that body from Recruitee's page rather than from the trimmed example above: removing or reformatting any field changes the digest.

Make Recruitee webhooks production-ready. Hookdeck Event Gateway verifies the X-Recruitee-Signature header, answers Recruitee with the 200 it requires, and durably queues every candidate and job event before your service sees it.

Recruitee webhook limitations and pain points

Only 200 counts

The Problem: Events are retried, and end up in the request log as failures, even though your endpoint handled them.

Why It Happens: Recruitee treats any status other than 200 as a failed delivery. A 201, 202 or 204, which most frameworks consider success, still triggers a retry.

Workarounds:

  • Return exactly 200 from the webhook route.
  • Check the Logs tab after deploying changes to the handler.

How Hookdeck Can Help: Hookdeck answers Recruitee with 200 when it accepts a delivery, so your service can return whatever success code its framework prefers.

The test request has to pass first

The Problem: Verify and create fails, and the webhook can't be saved.

Why It Happens: Recruitee sends {"test":true} and needs a 200 back. A handler that rejects unknown bodies, expects an event_type, or isn't running yet fails the test.

Workarounds:

  • Recognise test: true before event dispatch and answer it with 200.
  • Keep signature verification in place. The test request is signed like any other.
  • Start the server, and any tunnel, before you click Verify and create.

How Hookdeck Can Help: A Hookdeck source answers the test request on Recruitee's side, so you can create the webhook before your own service is deployed.

Rate limits delay bursts

The Problem: During a busy period, such as a bulk import, events arrive 5 to 15 minutes late.

Why It Happens: Recruitee sends at most 100 webhook requests a minute per company, or 5 for trial companies. Requests over the limit are delayed by 10 minutes, plus or minus 5.

Workarounds:

  • Don't rely on webhooks for anything that needs to happen within seconds of a bulk operation.
  • Use created_at, not the arrival time, when you order or report on events.

How Hookdeck Can Help: Hookdeck records when each event arrived and lets you see delivery delays across all your Recruitee traffic, so late batches are easy to spot.

No timestamp means no replay protection

The Problem: A captured request still verifies if it is sent again later.

Why It Happens: The signature covers only the body, and the body's created_at is the event time, which stays the same through retries.

Workarounds:

  • Deduplicate on the event id.
  • Keep the endpoint on HTTPS.

How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries based on the event id before they reach your service.

Best practices

Verify against the raw body

The HMAC covers the exact bytes Recruitee sent. In Express, use express.raw({ type: '*/*' }) and parse after verification. In Next.js, read await request.text(). In FastAPI, read await request.body().

Route on type and subtype together

candidate_moved covers stage changes, disqualifications and requalifications, and payload.details differs for each. Switch on event_type first, then event_subtype.

Deduplicate on id

The event id is constant across retries, and attempt_count tells you how many tries it took. Store the id before processing. See our guide to webhook idempotency.

Use the request log

The Logs tab keeps 30 days of requests, with the response your endpoint gave and the next retry time. You can cancel a retry or send a request again immediately, which is useful after fixing a bug.

Acknowledge fast and process asynchronously

Recruitee times out after 10 seconds. Verify, store the event, return 200, and do the work afterwards. See why to process webhooks asynchronously.

Conclusion

Recruitee signs every webhook with a hex HMAC-SHA256 of the raw body in X-Recruitee-Signature, and publishes a worked example to test against. Answer the {"test":true} request so the webhook can be created, verify against the raw bytes, route on event_type and event_subtype, deduplicate on id, and respond with exactly 200 within 10 seconds, because anything else starts the retry schedule.

Hookdeck Event Gateway sits in front of your receiver, checks the Recruitee signature, answers with the 200 Recruitee expects, and holds each event until your service has processed it.

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