# 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:

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

```javascript
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:

```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](/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Recruitee webhooks reliably in minutes.