# Guide to Breezy HR Webhooks: Features and Best Practices

Breezy HR webhooks tell your systems what is happening in your hiring pipeline: a candidate applies or is sourced, moves to a new stage, or is deleted, a position is opened, edited or published, or the team posts a note. If you recruit with Breezy HR, webhooks push those changes to your HRIS, analytics or messaging tools as they happen.

This guide covers how Breezy HR webhook endpoints are created and signed, the events and their payloads, a contradiction in Breezy's verification samples, and the practices that keep a Breezy receiver reliable.

## What are Breezy HR webhooks?

Breezy HR webhook endpoints are created through the v3 API, each with a URL and a list of events. When a subscribed event happens, Breezy sends a JSON `POST` to the URL. Webhooks are available on Pro plans.

Every delivery is signed. The `X-Hook-Signature` header is a hex HMAC-SHA256 of the request body, keyed with the endpoint's secret. The secret looks like `whsec_...`, but this is not Standard Webhooks: the whole string is the key, and no id or timestamp is signed.

Every payload uses the same envelope: an `_id`, a `type` naming the event, an `object` with the data, and a `creation_date`.

## Breezy HR webhook features

| Feature | Details |
| --- | --- |
| Availability | Pro plans |
| Configuration | v3 API: `POST /v3/company/{company_id}/webhook_endpoints`, plus update and delete |
| Limits | 10 endpoints per customer, and a separate 10 per partner |
| URL rules | HTTPS with a valid certificate, publicly reachable, no localhost or private IPs |
| Signature header | `X-Hook-Signature` |
| Other headers | `X-Breezy-Webhook-Version`, `Content-Type: application/json` |
| Scheme | HMAC-SHA256, hex |
| Signed content | The raw JSON body |
| Secret | One per endpoint, `whsec_...`, returned when the endpoint is created |
| Event type | `type` in the body |
| Timeout | 30 seconds per attempt |
| Success | Any `2xx` |
| Retries | 10, at 1, 2, 4, 8, 16 and 32 seconds, then 60 seconds for attempts 7 to 10 |
| After the last retry | The delivery is abandoned |
| Delivery stats | Successful and failed delivery counts per endpoint |

Breezy publishes no source IPs and no value for `X-Breezy-Webhook-Version`.

## Common events

Breezy documents 10 events.

| Area | Event | Fires when |
| --- | --- | --- |
| Candidates | `candidateAdded` | A candidate enters the system by applying, being sourced, recruited or referred |
| Candidates | `candidateStatusUpdated` | A candidate moves from one pipeline stage to another |
| Candidates | `candidateDeleted` | A candidate is deleted in the web application |
| Positions | `companyPositionAdded` | A position is created |
| Positions | `companyPositionUpdated` | A position's details change |
| Positions | `companyPositionStateUpdated` | A position's state changes, such as draft to published |
| Positions | `companyPositionDeleted` | A position is deleted |
| Team notes | `companyNotePosted`, `companyNoteUpdated`, `companyNoteDeleted` | A team discussion note is posted, edited or deleted |

Some boundaries matter. `candidateStatusUpdated` does not fire when a candidate first enters the system, so a new applicant produces only `candidateAdded`. `candidateDeleted` does not fire when a candidate is purged by GDPR auto-delete. `companyPositionUpdated` does not fire for state changes, which have their own event.

Breezy publishes each payload as a type sketch rather than real data. A `candidateStatusUpdated` delivery has this shape, trimmed:

```json
{
  "_id": "String",
  "type": "candidateStatusUpdated",
  "object": {
    "company": { "_id": "String", "name": "String", "friendly_id": "String" },
    "position": { "_id": "String", "name": "String", "pipeline_id": "String" },
    "stage_was": { "id": "String", "name": "String" },
    "stage": { "id": "String", "name": "String" },
    "entered_stage": "Date",
    "time_in_previous_stage": "Number",
    "candidate": { "_id": "String", "name": "String", "email_address": "String" },
    "acting_user": { "_id": "String", "email_address": "String", "name": "String" }
  },
  "creation_date": "Date"
}

```

Candidate events include `company`, `position`, `candidate`, `acting_user` and, where relevant, `stage`, `source` and `external_source`. Position events include the position's `state`, `type`, `location` and `department`. Note events carry a bare company id rather than a `company` object.

## Setting up Breezy HR webhooks

Create an endpoint with your API token:

```bash
curl -X POST "https://api.breezy.hr/v3/company/$BREEZY_COMPANY_ID/webhook_endpoints" \
  -H "Authorization: $BREEZY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/breezy",
    "description": "Hiring pipeline sync",
    "events": ["candidateAdded", "candidateStatusUpdated"]
  }'

```

The response includes a `secret`. Save it straight away, for example as `BREEZY_WEBHOOK_SECRET`. Breezy does not say whether it can be read again later.

To check that an endpoint is working, fetch it through the API and confirm its status is `active`, `enabled` is `true`, and `failed_deliveries` is not climbing.

## Securing Breezy HR webhooks

Compute an HMAC-SHA256 of the raw request body with the full `whsec_...` secret, hex-encode it, and compare it with `X-Hook-Signature` in constant time.

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

function verifyBreezySignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(rawBody).digest('hex'), // whole whsec_ string
    'utf8'
  );
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/breezy', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.BREEZY_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

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

  const event = JSON.parse(req.body.toString('utf8'));
  // Dedupe on event._id, route on event.type, process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_breezy_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()
    return hmac.compare_digest(
        signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8")
    )

# In FastAPI: raw_body = await request.body()
# verify_breezy_signature(raw_body, request.headers.get("x-hook-signature"),
#                         os.environ["BREEZY_WEBHOOK_SECRET"])

```

### Breezy's samples re-serialise the body

Breezy's own examples parse the JSON first and then sign a re-serialised copy: `JSON.stringify(body)` in Node, `json.dumps(body, separators=(',', ':'))` in Python, `json_encode` in PHP and `to_json` in Ruby. Its troubleshooting section says the opposite: "The signature is computed on the raw JSON body, not a re-serialized version". Re-serialising only works if your language reproduces Breezy's bytes exactly, including key order, spacing and escaping. Sign the raw body, as above.

> **Make Breezy HR webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Hook-Signature` header against the raw body, answers Breezy with an immediate `200 OK`, and durably queues every candidate, position and note event before your service sees it.

## Breezy HR webhook limitations and pain points

### A retry window measured in minutes

**The Problem:** A short outage loses events.

**Why It Happens:** Breezy's 10 retries are tightly packed. The first six follow within about a minute, and the last four are a minute apart, so the delays add up to about five minutes, plus up to 30 seconds for each attempt that times out. After that, the delivery is abandoned.

**Workarounds:**

* Keep the receiver highly available and quick to answer.
* Watch `failed_deliveries` on your endpoints and reconcile candidates through the API after an incident.

**How Hookdeck Can Help:** Hookdeck accepts every delivery as it arrives and stores it, then retries delivery to your service for as long as you configure and lets you replay anything that failed. An outage on your side then delays events instead of losing them.

### Contradictory verification guidance

**The Problem:** Verification works in one language and fails in another, or fails intermittently.

**Why It Happens:** The samples sign re-serialised JSON while the troubleshooting text says to sign the raw body. Any difference in serialisation, such as Unicode escaping or a framework that reorders keys, changes the signature.

**Workarounds:**

* Always verify the raw request bytes.
* Use the secret as a UTF-8 string, prefix included.

**How Hookdeck Can Help:** Hookdeck's Breezy HR source type verifies the raw body with the secret as issued, so there is one correct implementation in front of all your services.

### A `whsec_` secret that isn't Standard Webhooks

**The Problem:** A Standard Webhooks library rejects every Breezy delivery.

**Why It Happens:** Breezy's secret uses the `whsec_` prefix, but the scheme is a plain HMAC over the body with the whole secret as the key and a hex header. There are no `webhook-id` or `webhook-timestamp` headers.

**Workarounds:**

* Use a plain HMAC-SHA256, as shown above.
* Don't strip the prefix or base64-decode the secret.

**How Hookdeck Can Help:** Paste the secret into the Hookdeck source exactly as Breezy returned it.

### No replay protection

**The Problem:** A captured request still verifies when sent again.

**Why It Happens:** Only the body is signed, with no timestamp.

**Workarounds:**

* Deduplicate on `_id`, as Breezy recommends.
* Keep the endpoint on HTTPS.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated deliveries with the same `_id`.

## Best practices

### Verify against the raw body

Use `express.raw()` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse JSON only after the signature matches.

### Deduplicate on `_id`

Breezy says the same webhook may occasionally be delivered more than once, and recommends `_id` for deduplication. Record it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Handle new candidates with `candidateAdded`

`candidateStatusUpdated` does not fire when a candidate first appears, so subscribe to `candidateAdded` too if you track every candidate.

### Use `stage_was` and `stage` for pipeline analytics

`candidateStatusUpdated` includes the previous stage, the new stage and `time_in_previous_stage`, which is enough for time-in-stage reporting without extra API calls.

### Log verification failures without leaking detail

Breezy recommends logging failed verifications for debugging but returning nothing that helps an attacker. A plain `401` is enough.

### Acknowledge fast and process asynchronously

Breezy allows 30 seconds, but its retries are fast, so respond as soon as the event is stored. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Breezy HR signs each webhook with `X-Hook-Signature`, a hex HMAC-SHA256 of the raw body keyed with the whole `whsec_` secret. Sign the raw bytes rather than re-serialised JSON, deduplicate on `_id`, subscribe to `candidateAdded` alongside status changes, and keep your receiver available, because Breezy's retries are over within minutes.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Breezy signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Breezy HR webhooks reliably in minutes.