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_, 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:
{
"_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:
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.
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:
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 verifies the
X-Hook-Signatureheader against the raw body, answers Breezy with an immediate200 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_deliverieson 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.
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.
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 sits in front of your receiver, checks every Breezy signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Breezy HR webhooks reliably in minutes.