# Guide to Jamf Pro Webhooks: Features and Best Practices

Jamf Pro webhooks report what's happening across your Apple fleet: a Mac enrolls, a device checks in or finishes an inventory update, a policy runs, a smart group gains or loses members, or the Jamf Pro server itself starts or shuts down. If you manage devices with Jamf Pro, webhooks are how those events reach your other IT systems without polling the Jamf Pro API.

This guide covers how Jamf Pro webhooks are configured, the authentication options and which one to use, the events and payload structure, and the practices that keep a Jamf Pro receiver reliable.

## What are Jamf Pro webhooks?

Jamf Pro webhooks "allow you to subscribe to specific events on a Jamf Pro instance. When an event occurs, an HTTP POST payload is sent to a specified URL." Each webhook listens for exactly one event, so you create one webhook per event you need, and several can share a URL.

Each webhook also sets its own authentication, timeouts and format. The format is JSON or XML. Every JSON payload has two parts: `webhook`, which identifies the webhook and the event, and `event`, which carries the data.

Jamf Pro offers five authentication types. **Header Authentication** sends headers you define, as JSON key/value pairs, on every request, and your receiver compares one of them with the value it expects. That's the option this guide uses. Jamf Pro also has a **Hash Signature** option that signs webhooks with SHA-256 or SHA-512, but Jamf doesn't publicly document which header carries the signature or exactly what is signed, so you can't build a verifier for it from the documentation.

## Jamf Pro webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Global > Webhooks > New** |
| Subscription model | One event per webhook. Several webhooks can share a URL |
| Method | `POST` |
| Format | JSON or XML, chosen per webhook |
| Event identifier | `webhook.webhookEvent` |
| Event time | `webhook.eventTimestamp`, epoch milliseconds |
| Authentication | None, Basic, Header Authentication, Mutual TLS, or Hash Signature |
| Timeouts | Connection and read timeouts set per webhook |
| Retries | Not documented |
| Source IPs | Not published |

## Common events

Jamf documents 22 events:

| Area | Events |
| --- | --- |
| Computers | `ComputerAdded`, `ComputerCheckIn`, `ComputerInventoryCompleted`, `ComputerPolicyFinished`, `ComputerPatchPolicyCompleted`, `ComputerPushCapabilityChanged` |
| Mobile devices | `MobileDeviceEnrolled`, `MobileDeviceUnenrolled`, `MobileDeviceCheckIn`, `MobileDeviceCommandCompleted`, `MobileDeviceInventoryCompleted`, `MobileDevicePushSent` |
| Smart groups | `SmartGroupComputerMembershipChange`, `SmartGroupMobileDeviceMembershipChange`, `SmartGroupUserMembershipChange` |
| Enrollment and patching | `DeviceAddedToDEP`, `PatchSoftwareTitleUpdated` |
| Server and API | `JSSStartup`, `JSSShutdown`, `RestAPIOperation`, `PushSent` |
| Certificates | `SCEPChallenge` |

A few have rules worth knowing:

* `ComputerAdded` doesn't fire when a computer that already has an inventory record re-enrolls. `MobileDeviceEnrolled` fires on enrolment and re-enrolment.
* `ComputerPolicyFinished` and `MobileDeviceCommandCompleted` fire for failures as well as successes.
* `ComputerCheckIn` fires on startup, login, logout, network state change, enrolment complete and recurring check-in, so it can be very busy.
* `RestAPIOperation` fires for every authorised request to a supported Classic API resource. Failed and unauthorised requests don't trigger it.
* `PushSent` doesn't say whether the device was a computer or a mobile device, and Jamf notes the IDs aren't unique across the two.
* `MobileDeviceInventoryCompleted` needs Jamf Pro 10.47.0 or later.
* The section titled `MobileDeviceUnenrolled` shows `MobileDeviceUnEnrolled`, with a capital E, as the value sent. Match the value you actually receive.

Here is the shape of a `ComputerAdded` payload, trimmed. Jamf's own samples show data types (`"string"`, `integer`, `epoch`) rather than values, so this version fills in example values:

```json
{
  "event": {
    "deviceName": "Ada's MacBook Pro",
    "jssID": 52,
    "model": "MacBookPro18,3",
    "osVersion": "14.5",
    "serialNumber": "C02XXXXXXXXX",
    "udid": "55900BDC-347C-58B1-D249-F32244B11D30",
    "username": "ada",
    "managementId": "1a2b3c4d-0000-0000-0000-000000000000"
  },
  "webhook": {
    "eventTimestamp": 1729853000123,
    "id": 7,
    "name": "Computer added",
    "webhookEvent": "ComputerAdded"
  }
}

```

`jssID` is the Jamf Pro ID of the object, the same one you see in the Jamf Pro URL for that record. `webhook.id` and `webhook.name` identify the webhook that sent the event, which helps when several webhooks point at one URL. With XML, the same data arrives inside a `<JSSEvent>` element.

## Setting up Jamf Pro webhooks

1. Generate a long random secret, for example with `openssl rand -hex 32`.
2. In Jamf Pro, click **Settings** in the sidebar, then **Webhooks** in the **Global** section, then **New**.
3. Enter a display name and your HTTPS endpoint URL.
4. Choose **Header Authentication** and enter the header as JSON, for example `{"Authorization":"Bearer <secret>"}`. Jamf doesn't allow `Content-Type`, `User-Agent`, `Accept-Encoding`, `Content-Length` or `Host` as keys.
5. Enter the connection and read timeouts.
6. Choose **JSON** as the format.
7. Choose the event. For smart group events, also choose the group.
8. Click **Save**, and repeat for each event you need.

**In Hookdeck.** Choose Header Authentication on the Jamf Pro webhook, then create a source with the Jamf Pro type and enter the same header: the JSON key in **API Key Header** and the JSON value in **API Key**, including any `Bearer ` prefix. Use the source URL as the webhook URL. Hookdeck matches the name case-insensitively and the value exactly. The source accepts `POST`. It doesn't check Basic Authentication, Mutual TLS or Hash Signature, so a webhook using one of those needs Header Authentication for Hookdeck to verify it. Choose JSON as the format, so Hookdeck's [filters](/docs/filters) and [transformations](/docs/transformations) can work with the fields.

For local development, `hookdeck listen 3000 jamf-pro --path /webhooks/jamf-pro` with the [Hookdeck CLI](/docs/cli) gives you a public HTTPS URL, with no account required.

## Securing Jamf Pro webhooks

Read the header you defined in Header Authentication and compare its whole value with the secret in constant time.

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

const app = express();

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

app.post('/webhooks/jamf-pro', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.JAMF_PRO_WEBHOOK_SECRET; // the JSON value, e.g. "Bearer 4e8d..."
  if (!secret) return res.sendStatus(500); // fail closed when unconfigured

  const name = process.env.JAMF_PRO_WEBHOOK_HEADER || 'Authorization'; // the JSON key
  const received = req.get(name); // case-insensitive lookup
  if (typeof received !== 'string' || !safeEqual(received, secret)) {
    return res.sendStatus(401);
  }

  let payload;
  try {
    payload = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400); // e.g. a webhook still set to XML
  }

  const { webhookEvent, id, eventTimestamp } = payload.webhook || {};
  // Queue { webhookEvent, id, eventTimestamp, event: payload.event } for a worker.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_jamf_pro_request(headers) -> bool:
    secret = os.environ.get("JAMF_PRO_WEBHOOK_SECRET")
    if not secret:
        return False  # fail closed
    name = os.environ.get("JAMF_PRO_WEBHOOK_HEADER", "Authorization")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

```

A matching header proves the sender knows the value. It doesn't prove the body is unchanged, and a captured request can be replayed because nothing is signed. `eventTimestamp` is in every payload, so your own receiver can reject events older than you're willing to accept, but it isn't covered by any check, so it's a sanity test rather than proof of freshness. Use HTTPS and a long random value, and update the webhook and your receiver together if it may have leaked.

> **Make Jamf Pro webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your Header Authentication value on every Jamf Pro webhook, stores each device and policy event durably, and retries delivery to your service on a schedule you control.

## Jamf Pro webhook limitations and pain points

### SCEPChallenge needs an answer

**The Problem:** A SCEPChallenge webhook routed through a queue or gateway breaks certificate distribution.

**Why It Happens:** "The SCEPChallenge event is the only event that currently supports the ability for Jamf Pro to interpret a response. A valid response should consist of the dynamic challenge to be used within the SCEP payload." Every other event is fire-and-forget, but this one expects your service to answer with data.

**Workarounds:**

* Point SCEPChallenge webhooks directly at the service that issues challenges.
* Keep that service fast and highly available, because device enrolment waits on it.

**How Hookdeck Can Help:** Hookdeck acknowledges each request itself and delivers it to your service afterwards, so your service's response never reaches Jamf Pro. Send SCEPChallenge straight to your challenge service, and route the other 21 events through Hookdeck.

### Retries aren't documented

**The Problem:** You can't tell whether Jamf Pro tries again if your endpoint is down.

**Why It Happens:** Jamf documents per-webhook connection and read timeouts, but not retries or which response codes count as success.

**Workarounds:**

* Respond quickly and keep the timeouts reasonable.
* Reconcile state from the Jamf Pro API after an outage, especially for enrolment and unenrolment.

**How Hookdeck Can Help:** Hookdeck accepts each Jamf Pro webhook as it arrives, then [retries](/docs/retries) delivery to your service and lets you replay any event, so an outage on your side doesn't depend on Jamf Pro sending again.

### Hash Signature can't be verified from the docs

**The Problem:** You turn on Hash Signature for stronger security, then can't write the code to check it.

**Why It Happens:** Jamf describes the option and its SHA-256 or SHA-512 algorithms, but not the header name, the encoding or what's signed.

**Workarounds:**

* Use Header Authentication, with a long random value, over HTTPS.
* If you capture a signed delivery and work out the scheme, treat it as undocumented behaviour that may change.

**How Hookdeck Can Help:** Hookdeck's Jamf Pro source compares the Header Authentication header you name with the exact value you enter. Hookdeck answers Jamf Pro with a `200` either way, then records a request without the right header as failed verification, so it never reaches your destination.

### High-volume events

**The Problem:** `ComputerCheckIn` or `RestAPIOperation` floods a receiver built for occasional events.

**Why It Happens:** Every managed computer checks in on startup, login, logout, network changes and on its recurring schedule, and `RestAPIOperation` fires on every authorised Classic API request.

**Workarounds:**

* Only subscribe to these events if you need them, and on a dedicated endpoint.
* Acknowledge immediately and process in a queue.

**How Hookdeck Can Help:** Hookdeck absorbs bursts and delivers to your service at a [rate you set](/docs/destinations), so a fleet-wide check-in wave doesn't overwhelm it.

## Best practices

### Choose JSON

Set every webhook to JSON unless a consumer needs XML. JSON is easier to route on and is what most tooling expects.

### Route on `webhook.webhookEvent`

Since one URL can serve many webhooks, switch on `webhookEvent` and ignore events you don't recognise with a `200`. Match the exact strings you receive, such as `MobileDeviceUnEnrolled`.

### Deduplicate deliveries

Payloads have no event ID. Build a key from `webhook.id`, `webhook.eventTimestamp` and the object's `jssID` or `udid`, and store it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Re-read before destructive actions

For unenrolment or smart group changes that remove access, confirm the device's current state through the Jamf Pro API before acting.

### Acknowledge fast and process asynchronously

Verify, store, respond with `200`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Jamf Pro sends one event per webhook as a `POST`, with `webhook` metadata and `event` data, and Header Authentication is the documented way to authenticate it. Compare your header in constant time over HTTPS, choose JSON, route on `webhookEvent`, deduplicate without an event ID, and keep SCEPChallenge on a direct, synchronous path.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver for every other event, checks the header, keeps each event, and retries delivery to your service, which fills the gap left by Jamf Pro's undocumented retry behaviour.

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