Guide to JazzHR Webhooks: Features and Best Practices
JazzHR webhooks send candidates out of JazzHR and into another system: a background check provider, an onboarding tool, an HRIS or your own database. JazzHR is applicant tracking software for small businesses, and its Candidate Export Webhook lets a recruiter push a candidate to a partner with one click, or have a hiring workflow do it automatically when the candidate reaches a certain step.
This guide covers how the Candidate Export Webhook is set up, verified and signed, what the candidate payload contains, and the practices that make a JazzHR receiver reliable.
What are JazzHR webhooks?
JazzHR has one webhook surface: candidate export. A JazzHR user adds a Custom Integration with your URL. When a candidate is exported to it, JazzHR sends a JSON POST with the candidate in the HR Open Standards format.
There are two event types, named in the X-JazzHR-Event header:
VERIFY, sent once when the integration is saved, to check your endpoint.CANDIDATE-EXPORT, sent for each exported candidate.
If the integration has a Secret Key, every request carries X-JazzHR-Signature: the bare hex HMAC-SHA256 of the raw request body, keyed with that Secret Key.
JazzHR webhook features
| Feature | Details |
|---|---|
| Configuration | Settings > Integrations > Add Custom Integration |
| Triggers | Manual export from a candidate profile, or a Workflow Helper on a workflow step |
| Event header | X-JazzHR-Event: CANDIDATE-EXPORT or VERIFY |
| Setup check | A VERIFY request that must get a 200 |
| Signature header | X-JazzHR-Signature |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | The Secret Key you set on the integration. Optional, but without one you have no key to verify with |
| Request ID | X-JazzHR-UUID |
| Other headers | Link (candidate profile URL), User-Agent: JazzHR, X-JazzHR-Export-Context (the job, if any) |
| Success response | Any 2xx. Anything else, including 3xx, shows the user an error |
| Retries | Not documented |
| Document links | Public for 2 hours 30 minutes after the export |
The candidate payload
A CANDIDATE-EXPORT body has a single top-level key:
{
"candidate": {
"alternateIds": [
{ "value": "string", "schemeId": "JazzHR Legacy Candidate", "schemeAgencyId": "JazzHR" }
],
"person": {
"id": { "value": "string", "schemeId": "JazzHR Candidate", "schemeAgencyId": "JazzHR" },
"name": { "formattedName": "string", "given": "string", "family": "string" },
"communication": {
"email": [{ "address": "string" }],
"phone": [{ "formattedNumber": "string" }]
},
"customFields": []
},
"profiles": [
{
"profileId": { "value": "string", "schemeId": "JazzHR Job Application", "schemeAgencyId": "JazzHR" },
"associatedPositionOpenings": [
{
"positionOpeningId": { "value": "string", "schemeId": "JazzHR Job", "schemeAgencyId": "JazzHR" },
"positionTitle": "string",
"candidateStatus": { "name": "string", "category": "string", "transitionDateTime": "datetime" }
}
],
"attachments": [{ "descriptions": ["string"], "url": "string" }]
}
]
}
}
That is a trimmed version of JazzHR's sample, which fills every field with its type name rather than a real value. A few things to know about it:
- IDs come in pairs.
person.id.valueis the candidate's key, and thealternateIdsentries map to the legacy IDs in JazzHR's API:applicant_idfor the candidate,appjob_idfor the job application andjob_idfor the job. profilesholds one entry per job application. JazzHR says it is often a single object, but treat it as a list.candidateStatus.categoryisNew,Active,HiredorNot Hired.- A start date, if the user sets one on export, arrives as a job custom field named
startDate. - Every field can be
null. Datetimes are UTC, formattedYYYY-MM-DD HH:mm:ss.
The VERIFY body is just {"data":"JazzHR Verify Event"}.
Setting up JazzHR webhooks
- Generate a random Secret Key and store it server-side, for example as
JAZZHR_WEBHOOK_SECRET. - Make sure your endpoint is running and answers a
POSTwith200. - In JazzHR, go to Settings > Integrations and click Add Custom Integration.
- Enter an integration name, your webhook URL and the Secret Key, and click Save. JazzHR sends the
VERIFYevent and only finishes saving if your endpoint returns200. - Export a test candidate with Export Candidate on a candidate profile, or add a Workflow Helper to a workflow step to export automatically.
If your service receives exports for several JazzHR customers, JazzHR requires a unique client identifier in the URL, for example https://example.com/webhooks/jazzhr/{clientId}. That identifier is how you tell whose candidate arrived, and each customer should have their own Secret Key.
If you receive JazzHR webhooks through Hookdeck, create a source with the JazzHR type, paste the same Secret Key you entered in JazzHR, and use the source URL as the integration URL. Hookdeck answers every request with a 200, which is all the VERIFY event needs.
Securing JazzHR webhooks
Compute an HMAC-SHA256 of the raw request body with the Secret Key, hex-encode it, and compare it with X-JazzHR-Signature in constant time. The VERIFY event is signed the same way, so verify it like any other request and return 200.
const crypto = require('crypto');
const express = require('express');
function verifyJazzHRSignature(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/jazzhr', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.JAZZHR_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyJazzHRSignature(req.body, req.get('x-jazzhr-signature'), secret)) {
return res.sendStatus(401);
}
const eventType = req.get('x-jazzhr-event');
if (eventType === 'VERIFY') return res.sendStatus(200);
const { candidate } = JSON.parse(req.body.toString('utf8'));
// Deduplicate, download attachments promptly, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_jazzhr_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_jazzhr_signature(raw_body, request.headers.get("x-jazzhr-signature"),
# os.environ["JAZZHR_WEBHOOK_SECRET"])
JazzHR publishes a worked example you can use as a test. With the secret secret-key and the exact body {"data":"JazzHR Verify Event"}, the signature is:
4fdc7dcdcb02bca16c7ec4a15b9d6436de1f186e11ff155d0ab178b5aca4f30e
JazzHR's header table says the signature covers the "response body". It means the request body: its own PHP example hashes the raw request body.
There is no timestamp in the request, so nothing tells you how old it is.
Make JazzHR webhooks production-ready. Hookdeck Event Gateway answers JazzHR's
VERIFYevent with a200, verifies theX-JazzHR-Signatureheader, and stores every exported candidate before your service sees it.
JazzHR webhook limitations and pain points
The Secret Key is optional
The Problem: Your endpoint accepts exports, but has no way to tell them from forged requests.
Why It Happens: JazzHR lets a user save a Custom Integration without a Secret Key. Without one, you have no key to check a signature against.
Workarounds:
- Always set a Secret Key, and give users a generated one as part of your onboarding.
- Reject requests without a valid signature rather than accepting unsigned ones.
How Hookdeck Can Help: Hookdeck's JazzHR source checks X-JazzHR-Signature against the Secret Key you paste. Hookdeck answers JazzHR with a 200 either way, then records a request that fails the check as failed verification, so it never reaches your destination.
Errors are shown to the recruiter
The Problem: A recruiter sees an export error and exports the candidate again, or gives up.
Why It Happens: JazzHR treats any non-2xx response, including redirects, as a failed export and shows the user an error. It doesn't document any automatic retry.
Workarounds:
- Return
200as soon as the request is verified and stored, and do the slow work afterwards. - Register the exact final URL, so no redirect is involved.
- Deduplicate, because a manual re-export sends the same candidate again.
How Hookdeck Can Help: Hookdeck answers JazzHR straight away and stores the export, so a slow or failing service on your side doesn't show up as an error in JazzHR. Hookdeck then delivers to your service with retries you configure.
Document links expire
The Problem: Résumé and document links in the payload stop working.
Why It Happens: Document URLs in the export are public for only 2 hours 30 minutes after the export.
Workarounds:
- Download attachments as soon as you process the export, and store them yourself.
- If you queue exports, make sure the queue drains well within that window.
How Hookdeck Can Help: Hookdeck delivers each export to your service as soon as it arrives, with retries if your service is down, which keeps the time between export and download short.
Sample values are type names
The Problem: You can't test against realistic data from the docs.
Why It Happens: JazzHR's sample payload uses placeholders such as "string" and "date" for every value.
Workarounds:
- Export a test candidate and capture the request before writing your parser.
- Treat every field as nullable, as JazzHR says.
How Hookdeck Can Help: Hookdeck stores every request with its headers and body, so the first real export is there to inspect in full.
Best practices
Verify against the raw body
The signature covers the exact bytes JazzHR sent. In Express, use express.raw({ type: '*/*' }). In Next.js, use await request.text(). In FastAPI, use await request.body().
Route on X-JazzHR-Event
Neither body names its event, so read the header. Return 200 for VERIFY and process CANDIDATE-EXPORT.
Deduplicate exports
Recruiters can export the same candidate more than once, and workflows can trigger exports too. Use X-JazzHR-UUID to spot repeated requests, and the candidate and job application IDs to decide whether an export changes anything. See our guide to webhook idempotency.
Use the export context
X-JazzHR-Export-Context tells you which job the candidate was exported from, when there is one. Use it to pick the right profile from profiles when a candidate has applied for several jobs.
Acknowledge fast and process asynchronously
Verify, store the export, return 200, and then download documents and call other systems. See why to process webhooks asynchronously.
Conclusion
JazzHR's Candidate Export Webhook sends each exported candidate as an HR Open Standards object, signed with a bare hex HMAC-SHA256 of the raw body in X-JazzHR-Signature when the integration has a Secret Key. Always set that key, answer VERIFY with a 200, respond quickly so recruiters don't see errors, and download documents before their links expire.
Hookdeck Event Gateway sits in front of your receiver, verifies every JazzHR signature, and holds each export until your service has processed it.
Get started with Hookdeck for free and handle JazzHR webhooks reliably in minutes.