# Recruitee Webhooks

## When to Use This Skill

* How do I receive Recruitee webhooks?
* How do I verify the X-Recruitee-Signature header?
* How do I handle new_candidate, candidate_moved, or candidate_assigned events?
* How do I detect a disqualified candidate in a Recruitee webhook?
* Why is my Recruitee webhook signature verification failing?

## Verification (core)

Recruitee signs the **raw request body** with HMAC-SHA256 keyed on the webhook
secret and sends the **hex** digest in `X-Recruitee-Signature` (no prefix, no
timestamp). There is no official SDK, so verify manually: compute the HMAC over
the raw body (before JSON parsing) and compare timing-safe.

The event name is **in the body**, not a header. Every payload is
`{ id, attempt_count, created_at, event_type, event_subtype, payload }`.

Node:

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

function verifyRecruiteeWebhook(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  // Compare hex strings as UTF-8 bytes; Buffer.from(x, 'hex') stops at the first non-hex char
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const wanted = Buffer.from(expected, 'utf8');
  return received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
}

```

Python:

```python
import hmac, hashlib

def verify_recruitee_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header.strip().lower().encode(), expected.encode())

```

Recruitee publishes a test vector: secret `DuP4ej5yyJB5TIrIEI/dCtJN7sHj` with a
`candidate_moved` body gives
`3b64e3049cb9e108fbb18a453e909cb4a32e3ae140ea01d84c2b5d316c19162f` (see the
tests in the examples).

Read the body as raw bytes regardless of content type (Express:
`express.raw({ type: '*/*' })`), because any re-serialisation changes the digest.

> **For complete handlers with route wiring, event dispatch, and tests**, see:
> 
> * [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/recruitee-webhooks/examples/express/)
> * [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/recruitee-webhooks/examples/nextjs/)
> * [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/recruitee-webhooks/examples/fastapi/)

## Test Request

When you click **Test** or **Verify and create**, Recruitee POSTs `{"test":true}`
and the endpoint **must answer 200** for the webhook to be created. Handle this
before event dispatch.

## Common Event Types

`event_type` carries the event; `event_subtype` refines it.

| `event_type` | `event_subtype` | Triggered When |
| --- | --- | --- |
| `new_candidate` | `email`, `career_site`, `manual`, `imported` | A new candidate applies or is created |
| `candidate_assigned` | `email`, `career_site`, `manual`, `imported` | A candidate is assigned to a job or talent pool |
| `candidate_moved` | `stage_changed`, `disqualified`, `requalified` | A candidate changes pipeline stage, is disqualified, or is 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 |

> There is no `candidate_disqualified` event type. A disqualification arrives as
> `candidate_moved` with `event_subtype: "disqualified"` and
> `payload.details.disqualify_reason`.

> **For the full event reference**, see [Recruitee webhook docs](https://docs.recruitee.com/docs/webhooks).

## Important Headers

| Header | Description |
| --- | --- |
| `X-Recruitee-Signature` | Hex HMAC-SHA256 of the raw body, keyed with the webhook secret |
| `User-Agent: Recruitee-Webhooks` | Identifies Recruitee requests. **Do not** use for auth |

## Delivery Behaviour

* Respond with exactly **200** within 10 seconds. Any other status is a failure.
* Failures retry up to nine times (1 min, 3 min, 10 min, 45 min, 2 h, 5 h, 10 h, 24 h, 48 h).
* Retries keep the same `id` and increment `attempt_count`: dedupe on `id`.

## Environment Variables

```bash
RECRUITEE_WEBHOOK_SECRET=your_webhook_secret   # Settings > Apps and Plugins > Webhooks

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 recruitee --path /webhooks/recruitee

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/recruitee-webhooks/references/overview.md) - Recruitee webhook concepts and events
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/recruitee-webhooks/references/setup.md) - Dashboard configuration guide
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/recruitee-webhooks/references/verification.md) - Signature verification details