Guide to Recruitee Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | Settings > Apps and Plugins > Webhooks, by members with the "Manage webhooks" ability |
| Endpoint | HTTPS POST URL |
| Test request | {"test":true}. Must get a 200 before the webhook can be created |
| Event identity | event_type and event_subtype in the body |
| Signature header | X-Recruitee-Signature |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The raw request body |
| Secret | One webhook secret, shown on the webhooks settings page |
| Replay window | None. No timestamp is sent or signed |
| Success | HTTP 200 only |
| Response window | 10 seconds |
| Retries | Up to nine, from 1 minute to 48 hours apart |
| Request log | 30 days, with cancel and retry-now controls |
| Rate limit | 100 requests per minute, 5 for trial companies |
| User agent | Recruitee-Webhooks |
Recruitee publishes no source IP list for webhooks.
Common events
Recruitee documents seven events. event_subtype tells you more about each one:
event_type | event_subtype | Fires when |
|---|---|---|
new_candidate | email, career_site, manual, imported | A candidate applies or is created |
candidate_assigned | email, career_site, manual, imported | A new or existing candidate is assigned to a job or talent pool |
candidate_moved | stage_changed, disqualified, requalified | A candidate moves to another pipeline stage, or is disqualified or requalified |
candidate_deleted | manual | A candidate is deleted |
offer_published | manual | A job is published |
offer_unpublished | manual | A job is unpublished or archived |
offer_updated | offer_changed, status_changed, tags_changed | A 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.
- Make sure your hiring role has the Manage webhooks ability, under Add-ons.
- Go to Settings > Apps and Plugins > Webhooks and click + New webhook.
- Enter a name, your HTTPS endpoint as the POST URL, and the events you want. You can change the events later.
- Click Test to send
{"test":true}, or a Test payload button to send an example event withtestadded. - Click Verify and create. Recruitee sends the test request again and creates the webhook once your endpoint answers
200. - 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-Signatureheader, answers Recruitee with the200it 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
200from 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: truebefore event dispatch and answer it with200. - 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.