# 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:

```json
{
  "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`.

```javascript
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:

```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](/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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) sits in front of your receiver, verifies every JazzHR signature, and holds each export until your service has processed it.

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