Gareth Wilson Gareth Wilson

Guide to Breezy HR Webhooks: Features and Best Practices

Published


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

FeatureDetails
AvailabilityPro plans
Configurationv3 API: POST /v3/company/{company_id}/webhook_endpoints, plus update and delete
Limits10 endpoints per customer, and a separate 10 per partner
URL rulesHTTPS with a valid certificate, publicly reachable, no localhost or private IPs
Signature headerX-Hook-Signature
Other headersX-Breezy-Webhook-Version, Content-Type: application/json
SchemeHMAC-SHA256, hex
Signed contentThe raw JSON body
SecretOne per endpoint, whsec_..., returned when the endpoint is created
Event typetype in the body
Timeout30 seconds per attempt
SuccessAny 2xx
Retries10, at 1, 2, 4, 8, 16 and 32 seconds, then 60 seconds for attempts 7 to 10
After the last retryThe delivery is abandoned
Delivery statsSuccessful 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.

AreaEventFires when
CandidatescandidateAddedA candidate enters the system by applying, being sourced, recruited or referred
CandidatescandidateStatusUpdatedA candidate moves from one pipeline stage to another
CandidatescandidateDeletedA candidate is deleted in the web application
PositionscompanyPositionAddedA position is created
PositionscompanyPositionUpdatedA position's details change
PositionscompanyPositionStateUpdatedA position's state changes, such as draft to published
PositionscompanyPositionDeletedA position is deleted
Team notescompanyNotePosted, companyNoteUpdated, companyNoteDeletedA 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-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.

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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.