# Guide to JobAdder Webhooks: Features and Best Practices

JobAdder webhooks notify your application when recruitment data changes. A job's status changes, a candidate is updated, an application moves to a new stage, a placement is approved, an interview is scheduled: each can reach your service as an HTTP request, so a partner integration or internal tool can react without polling the JobAdder API.

This guide covers how JobAdder webhooks are created and scoped, the notification envelope and event list, how to authenticate requests with the authorization value, and what JobAdder leaves undocumented about delivery.

## What are JobAdder webhooks?

JobAdder webhooks are created by an application through the **Webhook API**, not in the JobAdder interface. The application needs the `offline_access` scope, and each event needs its own read scope, such as `read_candidate` for candidate events. A webhook has a name, a URL, a list of events and a status.

When a subscribed event happens, JobAdder sends an HTTP `POST` to the URL. The body is a JSON envelope that names the event in `event` and carries an `eventId` for deduplication.

JobAdder doesn't sign notifications. A webhook has an optional `authorization` field, described as an "optional authorization header to include with the HTTP POST notification". JobAdder sends that string as the `Authorization` header, exactly as you set it. The API's example value is a Basic credential, `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`.

## JobAdder webhook features

| Feature | Details |
| --- | --- |
| Management | Webhook API: `POST /webhooks`, `GET`, `PUT` and `DELETE /webhooks/{webhookId}` |
| Scopes | `offline_access` plus each event's read scopes. Partner action events also need `partner_ui_action` |
| Method | `POST` |
| Envelope | `apiVersion`, `event`, `eventId`, `eventDate`, `eventUser`, `eventData`, then event details |
| Authentication | Optional `authorization` value, sent verbatim as `Authorization`. No signature |
| Filters | `eventFilters` for `jobapplication_status_changed`, by `statusId` or active status |
| Status | `Enabled`, `Disabled`, `Suspended`, and `Failed` (with `failedAt`) |
| Ordering and duplicates | Order not guaranteed; duplicates possible |
| Retries, timeout, source IPs | Not documented |

## Common events

| Area | Events |
| --- | --- |
| Jobs | `job_status_changed`, `job_invoice_sent`, `job_partner_action` |
| Job ads | `jobad_posted`, `jobad_expired`, `jobad_partner_action` |
| Candidates | `candidate_status_changed`, `candidate_updated`, `candidate_skill_updated`, `candidate_partner_action` |
| Applications | `jobapplication_status_changed`, `jobapplication_partner_action` |
| Placements | `placement_approved`, `placement_status_changed`, `placement_partner_action` |
| Companies | `company_status_changed`, `company_updated`, `company_address_updated`, `company_partner_action` |
| Contacts | `contact_status_changed`, `contact_updated`, `contact_partner_action` |
| Interviews and events | `interview_scheduled`, `interview_evaluation_submitted`, `event_scheduled`, `event_interview_evaluation_submitted` |
| Folders | `folder_job_added`, `folder_candidate_added`, `folder_placement_added` and the matching `_removed` events for jobs, candidates, applications, placements, companies and contacts, plus `folder_updated` |
| Other | `user_suspended`, `sms_sent`, `opportunity_stage_changed` |

JobAdder keeps adding events: its release notes list `opportunity_stage_changed`, `contact_updated`, `company_updated`, `company_address_updated` and `candidate_skill_updated` as additions in 2025 and 2026.

The notification envelope looks like this. JobAdder's example ends with an `<event details>` placeholder rather than real fields:

```json
{
  "apiVersion": 2,
  "event": "placement_approved",
  "eventId": "00000000-0000-0000-0000-000000000000",
  "eventDate": "2019-01-01T09:00:00Z",
  "eventUser": {},
  "eventData": {}
}

```

Route on `event`, deduplicate on `eventId`, and capture a real notification for each event you handle to see its details.

> **Inspect JobAdder webhooks as they arrive.** Create a webhook pointing at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see each event's full body and the `Authorization` header, with no account or setup required.

## Setting up JobAdder webhooks

Choose the full `Authorization` value first, for example `Bearer ` followed by the output of `openssl rand -hex 32`. Any string works, because JobAdder sends it unchanged.

Create the webhook with an access token that has `offline_access` and the read scopes for your events. JobAdder's token response includes an `api` field with the base URL to use; the example below uses `https://api.jobadder.com/v2`:

```bash
curl --request POST \
  --url https://api.jobadder.com/v2/webhooks \
  --header "Authorization: Bearer $JOBADDER_ACCESS_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "Placement sync",
    "url": "https://example.com/webhooks/jobadder",
    "events": ["placement_approved", "placement_status_changed"],
    "authorization": "Bearer YOUR_LONG_RANDOM_VALUE",
    "status": "Enabled"
  }'

```

Store the `webhookId` from the response. The webhook representation JobAdder returns doesn't include the `authorization` value, so keep your own copy; you can't read it back later. To change it, send the new value with `PUT /webhooks/{webhookId}`.

To route through Hookdeck, create a source with the **JobAdder** type and use its URL as `url`. The source has one field, **Authorization Header**. As Hookdeck's setup note says, paste the authorization value exactly as set in the JobAdder webhook, including any `Bearer ` or `Basic ` prefix. Hookdeck compares the whole header with it and accepts `POST`.

## Securing JobAdder webhooks

Compare the whole `Authorization` header with the value you set, in constant time. Don't strip the prefix or decode a Basic value.

```javascript
const crypto = require('crypto');
const express = require('express');

// Exactly the authorization value set on the webhook, e.g. "Bearer 3f9c...".
const EXPECTED = process.env.JOBADDER_AUTHORIZATION;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/jobadder', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('authorization');
  if (!EXPECTED || typeof received !== 'string' || !safeEqual(received, EXPECTED)) {
    return res.sendStatus(401);
  }

  let notification;
  try {
    notification = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Store notification.eventId, queue by notification.event, and respond.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

EXPECTED = os.environ.get("JOBADDER_AUTHORIZATION", "")  # e.g. "Bearer 3f9c..."

def verify_jobadder_request(headers) -> bool:
    if not EXPECTED:
        return False  # never accept when unconfigured
    received = headers.get("authorization")
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), EXPECTED.encode("utf-8"))

```

A static `Authorization` value authenticates the sender, not the body. It cannot detect a modified payload, and with no signature or timestamp a captured request can be replayed and still pass. Recruitment data is personal data, so use HTTPS, a long random value rather than a reusable username and password, and rotate it with `PUT /webhooks/{webhookId}` if it might have leaked.

> **Make JobAdder webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `Authorization` value, accepts every notification as it arrives, and queues it durably for your integration.

## JobAdder webhook limitations and pain points

### Delivery rules aren't documented

**The Problem:** You don't know how long JobAdder waits, whether it retries, or what moves a webhook to `Suspended` or `Failed`.

**Why It Happens:** The API documentation describes the envelope, events and webhook states but not the delivery policy.

**Workarounds:**

* Respond quickly and process in the background, so timeouts are unlikely.
* Poll `GET /webhooks/{webhookId}` and alert if `status` changes or `failedAt` is set.
* Reconcile important records against the API periodically.

**How Hookdeck Can Help:** Hookdeck accepts and stores each notification, so JobAdder sees successful deliveries even when your service is down. Hookdeck then [retries](/docs/retries) delivery to you on your schedule, and you can replay any notification.

### Duplicates and out-of-order events

**The Problem:** A candidate update is processed twice, or an older status change overwrites a newer one.

**Why It Happens:** JobAdder says the order of notifications isn't guaranteed and duplicates may occur.

**Workarounds:**

* Deduplicate on `eventId`.
* Compare `eventDate` with the last change you applied, or re-read the record from the API before writing.

**How Hookdeck Can Help:** [Deduplication](/docs/deduplication) can discard repeats before they reach your service, and every notification is stored with its arrival time for debugging.

### Missing scopes mean missing events

**The Problem:** A webhook is created but some events never arrive.

**Why It Happens:** Every event needs `offline_access` plus its own read scopes, and partner action events also need `partner_ui_action`.

**Workarounds:**

* Check the scope table for each event before you request authorisation from a JobAdder account.

**How Hookdeck Can Help:** Hookdeck's request history shows exactly which events arrive, so a gap is easy to spot.

### A credential you can't read back

**The Problem:** Your receiver rejects every notification after a deploy, and you can't check what JobAdder is sending.

**Why It Happens:** The `authorization` value isn't returned by the Webhook API, and JobAdder sends it unchanged, so a missing `Bearer ` or `Basic ` prefix in your stored copy breaks the comparison.

**Workarounds:**

* Store the exact string you sent when creating the webhook, prefix included.
* If in doubt, set a new value with `PUT` and update your receiver at the same time.

**How Hookdeck Can Help:** Hookdeck's JobAdder source compares the whole `Authorization` value you paste. Hookdeck answers JobAdder with a `200` either way, but a notification with a missing or wrong value is recorded as failed verification and never reaches your service, and you can see what was received.

## Best practices

### Use a random token, not a password

The `authorization` example is a Basic credential, but nothing requires one. A long random bearer value avoids reusing a real username and password.

### Deduplicate on `eventId`

JobAdder warns about duplicates. Store `eventId` before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Filter application status changes

Use `eventFilters` to limit `jobapplication_status_changed` to the statuses you act on.

### Watch webhook status

Check for `Suspended` or `Failed` webhooks and re-enable them once the cause is fixed.

### Acknowledge fast and process asynchronously

Store the notification and respond, then call the JobAdder API for details. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

JobAdder sends webhook notifications as JSON `POST` requests, authenticated only by the `authorization` string you set through the Webhook API. Store that string exactly, compare the whole header in constant time, deduplicate on `eventId`, and keep an eye on webhook status, because JobAdder doesn't document how it retries or when it gives up.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, stores every notification, and delivers it to your integration with retries and replay.

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