Gareth Wilson Gareth Wilson

Guide to JazzHR Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationSettings > Integrations > Add Custom Integration
TriggersManual export from a candidate profile, or a Workflow Helper on a workflow step
Event headerX-JazzHR-Event: CANDIDATE-EXPORT or VERIFY
Setup checkA VERIFY request that must get a 200
Signature headerX-JazzHR-Signature
SchemeHMAC-SHA256, lowercase hex, no prefix
Signed contentThe raw request body, nothing else
SecretThe Secret Key you set on the integration. Optional, but without one you have no key to verify with
Request IDX-JazzHR-UUID
Other headersLink (candidate profile URL), User-Agent: JazzHR, X-JazzHR-Export-Context (the job, if any)
Success responseAny 2xx. Anything else, including 3xx, shows the user an error
RetriesNot documented
Document linksPublic 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.value is the candidate's key, and the alternateIds entries map to the legacy IDs in JazzHR's API: applicant_id for the candidate, appjob_id for the job application and job_id for the job.
  • profiles holds one entry per job application. JazzHR says it is often a single object, but treat it as a list.
  • candidateStatus.category is New, Active, Hired or Not 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, formatted YYYY-MM-DD HH:mm:ss.

The VERIFY body is just {"data":"JazzHR Verify Event"}.

Setting up JazzHR webhooks

  1. Generate a random Secret Key and store it server-side, for example as JAZZHR_WEBHOOK_SECRET.
  2. Make sure your endpoint is running and answers a POST with 200.
  3. In JazzHR, go to Settings > Integrations and click Add Custom Integration.
  4. Enter an integration name, your webhook URL and the Secret Key, and click Save. JazzHR sends the VERIFY event and only finishes saving if your endpoint returns 200.
  5. 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 VERIFY event with a 200, verifies the X-JazzHR-Signature header, 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 200 as 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.

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.


Gareth Wilson

Gareth Wilson

Product Marketing

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