Receives Fleet's macOS migration webhook and automatically unmanages the device in Jamf Pro — removing the MDM profile while keeping the inventory record intact.
Built because Fleet ships the migration prompt and webhook but expects you to wire up the Jamf side yourself. This is the Jamf side.
- Fleet shows the migration prompt to the end user (forced or voluntary mode)
- User clicks Start — Fleet POSTs a JSON payload with the device's serial number to your webhook URL
- This server looks up the device in Jamf by serial number (
GET /api/v1/computers-inventory) - Calls
POST /api/v1/computer-inventory/{id}/remove-mdm-profile— Jamf queues the unmanage command, the MDM profile is removed on the device's next check-in, and inventory is kept - macOS notices it is no longer MDM-enrolled with Jamf and accepts Fleet's MDM profile
The server always returns HTTP 200 to Fleet regardless of Jamf outcome — Fleet Desktop surfaces non-2xx responses as a user-facing migration error even though Fleet's own flow continues regardless of webhook result.
- Jamf Pro with an API role and OAuth2 client (
Settings → System → API Roles and Clients)- Required role privileges: Read Computers and Send Computer Unmanage Command
- Go 1.21+ or download a release binary
- ngrok account (free tier works) if you need a public URL temporarily
In Fleet GitOps, controls.macos_migration is read from the global app config (default.yml), not per-team. The Fleet UI page is labeled "All teams" with no team selector. Configure it once in default.yml:
controls:
macos_migration:
enable: true
mode: forced # or voluntary
webhook_url: https://your-host.example.com/webhook/<your-secret>If your repo already has controls blocks in team YAMLs and GitOps rejects the global one, this is the order of operations — the migration setting cannot be set in a team file even though it appears to apply to all hosts.
Create a config.yaml next to the binary (this file is gitignored):
jamf:
url: https://yourorg.jamfcloud.com
client_id: cae2dfb4-1be8-4081-8aa4-a6bc62313ec9
client_secret: NSdhnEm2ecX-...
ngrok:
authtoken: 2khh0JVuL17l...
domain: my-migrator.ngrok.app # optional — fixed subdomain
webhook:
secret: a-long-random-string # webhook serves at /webhook/<secret>Then:
go run github.com/CampusTech/jamf-fleet-migration-webhook@latest serve
# or with the release binary:
./jamf-fleet-migration-webhook serveCopy the printed URL into Fleet's webhook_url setting.
All settings can be provided via (in order of precedence, highest first):
- CLI flag (
--jamf-url ...) - Environment variable (
JAMF_URL=...) config.yamlin the working directory
Nested YAML keys map to flat env vars with _ separators: jamf.url → JAMF_URL, ngrok.authtoken → NGROK_AUTHTOKEN, webhook.secret → WEBHOOK_SECRET.
| Flag | Env var | Config key | Default | Description |
|---|---|---|---|---|
--jamf-url |
JAMF_URL |
jamf.url |
required | Jamf Pro base URL |
--jamf-client-id |
JAMF_CLIENT_ID |
jamf.client_id |
required | OAuth2 client ID |
--jamf-client-secret |
JAMF_CLIENT_SECRET |
jamf.client_secret |
required | OAuth2 client secret |
--port |
PORT |
port |
8080 |
Port to listen on (when not using ngrok) |
--ngrok |
— | ngrok.enabled |
false |
Enable ngrok (auto-enabled if NGROK_AUTHTOKEN or NGROK_DOMAIN is set) |
--ngrok-token |
NGROK_AUTHTOKEN |
ngrok.authtoken |
— | ngrok auth token |
--ngrok-domain |
NGROK_DOMAIN |
ngrok.domain |
— | Fixed ngrok domain, e.g. myhost.ngrok-free.app |
--secret |
WEBHOOK_SECRET |
webhook.secret |
— | Secret path segment: webhook served at /webhook/<secret> |
--debug |
DEBUG |
debug |
false |
Enable verbose debug logging (request/response bodies, decision points) |
- Always set a
webhook.secretin production. Fleet's migration webhook is unauthenticated by design — anyone who discovers your URL can fire fake "user clicked Start" requests at it and trigger unmanage on real devices. The secret path turns the URL into a bearer credential. - Fleet's payload contains
hardware_serial,host.id, andhost.uuid— device inventory data. If you're concerned about the data transiting ngrok or other tunnels, terminate TLS on infrastructure you own. - Rotate the Jamf OAuth client secret periodically.
Enable with --debug or DEBUG=true. Includes:
- Inbound webhook request method, path, remote IP, and raw body
- Jamf OAuth2 token cache hits/misses and expiry
- Full Jamf request URL and response status + body for inventory lookup and unmanage calls
- Decision points in the handler (lookup failed, unmanage failed, etc.)
Useful when bringing the integration up for the first time or diagnosing why a specific device didn't migrate.
MIT — Campus, Inc.