# Jamf Device Cleanup Automation (Mini Project)
> A mini-project to automatically delete stale Jamf devices based on age — and keep my technical skills sharp.
---
## Overview
At my current job, we’ve run into an issue where devices tend to **linger in Jamf far longer than they should**. Historically, I was deleting devices after **one year of inactivity**, but that approach started to show its ugly face.
After noticing these inconsistencies, I decided to rethink the process.
Thanks to Rob Schroeder from Mac Admins on his blog that originally helped me with redeploying the framework to devices. I still use his function for API authentication 😁
[Tech it out](https://techitout.xyz/2024/07/29/update-using-powershell-with-jamf-pro-api/)
### New Rule
If a device has **not checked in for 180 days**, its **Jamf record is deleted**.
> Devices are **not released from Apple School Manager (ASM)** — that’s a future enhancement.
This write-up is for anyone who wants to try something a little fancy and follow along.
---
## Key Concepts
- Jamf Automation
- Jamf Webhooks
- PowerShell
- Azure Automation / Runbooks
> Yes — PowerShell still slaps.
---
## High-Level Flow
1. Jamf Smart Group detects devices inactive for 180+ days
2. Webhook fires on membership change
3. Azure Runbook receives webhook payload
4. Script:
- Authenticates to Jamf Pro API
- Extracts device IDs
- Retrieves device inventory (for logging)
- Deletes stale devices from Jamf
#### Technical Breakdown
```mermaid
sequenceDiagram
autonumber
participant J as Jamf
participant A as Azure Webhook
participant R as Runbook
J->>A: POST SmartGroup change<br/>addedIds=[1374]
A->>R: Start job ($WebhookData)
R->>R: Parse RequestBody JSON
```
```mermaid
sequenceDiagram
autonumber
participant R as Runbook
participant P as Jamf API
loop Each added device ID
R->>P: GET device details
P-->>R: Device info
R-->>R: Write verbose output
R->>P: DELETE device
P-->>R: Delete result
end
```
### Azure Runbooks
After looking at the document I created I believe there are some gray areas in configuration. Specifically involving [Azure and Azure runbooks]([[Azure Runbooks]]. So I am going to include some additional information on this.
The goal is to get the data from from the membership from the smart group membership from Jamf.
---
## Requiring Object Param for Webhook
This is mandatory to ingest the data webhook data and automatically captures it.
``` powershell
param
(
[Parameter(Mandatory=$false)]
[object]$WebhookData
)
```
---
## Log Function
Primarily if you want to run this locally or store the log file from azure into a storage account (current work in progress)
```powershell
function Write-Log {
param(
[Parameter(Mandatory = $true)]
[string]$Message,
[ValidateSet('INFO', 'WARN', 'ERROR', 'SUCCESS', 'DEBUG')]
[string]$Level = 'INFO'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$color = switch ($Level) {
'INFO' { 'Cyan' }
'WARN' { 'Yellow' }
'ERROR' { 'Red' }
'SUCCESS' { 'Green' }
'DEBUG' { 'Gray' }
default { 'White' }
}
Write-Output "[$timestamp] [$Level] $Message"
}
```
---
## Authenticating to the Jamf Pro API and Azure
There are many gotchas with connecting to Azure directly to run services with a managed identity. Which can be covered at a later date. But for now, imagine a managed identity(MI) as a service account in the cloud. It can be given access to Entra or Azure resources without a password. With the following below, we are connecting to the Azure resources.
It is frowned upon to have your scripts have secrets or credentials in the script itself. I was successful in giving the MI access to the [[Azure Keyvault]]. It has the capability to read the secret from the vaule.
```powershell
Connect-AzAccount -Identity
$VaultName = "Your-keyvault-name"
$secretName = "Your-secret-name"
# Get secret as plain text
$secretValue = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName -AsPlainText
Write-Output "Secret retrieved successfully."
```
```powershell
# Jamf Pro API Credentials
$global:jamfProInformation = @{
client_id = "$secretName"
client_secret = "$secretValue"
URI = "https://your-jamfcloud.com"
}
# Get a bearer token for Jamf Pro API authentication
$global:bearerTokenInformation = @{}
$bearerTokenAuthHeaders = @{
"Content-Type" = "application/x-www-form-urlencoded"
}
$bodyContent = @{
client_id = $jamfProInformation['client_id']
client_secret = $jamfProInformation['client_secret']
grant_type = "client_credentials"
}
Write-Log "Requesting Jamf Pro bearer token..." -Level INFO
$bearerTokenAuthResponse = Invoke-WebRequest `
-Uri "$($jamfProInformation['URI'])/api/oauth/token" `
-Headers $bearerTokenAuthHeaders `
-Method Post `
-Body $bodyContent `
-ContentType "application/x-www-form-urlencoded"
if ($bearerTokenAuthResponse.StatusCode -ne 200) {
Write-Log "Error generating token. Status code: $($bearerTokenAuthResponse.StatusCode)." -Level ERROR
exit 1
}
else {
$bearerTokenInformation.Add("Token", "$(($bearerTokenAuthResponse.Content | ConvertFrom-Json).access_token)")
Write-Log "Bearer token acquired successfully." -Level SUCCESS
}
$authHeaders = @{
accept = "application/json"
Authorization = "Bearer $($bearerTokenInformation['Token'])"
}
```
---
## Jamf Pro Webhook Config
- Go to your Jamf Pro Portal
- ![[Pasted image 20260315162955.png|399]]
- Create a new webhook and you can fill out the information as it suits your needs. The webhook URL can be either your local webhook instance with LocalTunnel or Azure for example. You will want the Content Type to be JSON.
- ![[Pasted image 20260315163154.png|388]]
- ![[Pasted image 20260315163309.png|518]]
### Node JS and LocalTunnel Webhook
lf you want to try to setup your own webhooks for testing JSON data before going to Azure you can follow this [[Localized Webhooks]] to help you. Below is an example I was able to publish to a completely local webhook service on my device.
```json
{
"webhook": {
"id": 8,
"name": "Test Webhook",
"webhookEvent": "SmartGroupComputerMembershipChange",
"eventTimestamp": 1772942292484
},
"event": {
"name": "Last Check in Greater than 180 days",
"smartGroup": true,
"groupAddedDevicesIds": [
1072, 534, 1211, 374
],
"groupRemovedDevicesIds": [],
"groupAddedDevices": [],
"groupRemovedDevices": [],
"computer": true,
"jssid": 34
}
```
---
### Jamf Webhooks from Azure
```json
{
"WebhookName": "GatherDevicesandDelete",
"RequestBody": "{\"webhook\":{\"id\":8,\"name\":\"Delete Device Record\",\"webhookEvent\":\"SmartGroupComputerMembershipChange\",\"eventTimestamp\":1773198509169},\"event\":{\"name\":\"Last Check in Greater than 180 days\",\"smartGroup\":true,\"groupAddedDevicesIds\":[929,580,634],\"groupRemovedDevicesIds\":[],\"groupAddedDevices\":[],\"groupRemovedDevices\":[],\"computer\":true,\"jssid\":340}}",
"RequestHeader": {
"Connection": "keep-alive",
"Accept": "text/plain",
"Accept-Encoding": "gzip",
"Host": "82aee0f0.azure-automation.net",
"User-Agent": "Apache-HttpClient/5.4.4",
"x-ms-request-id": "26d39d5a-2bec-4189-8f73-c5df0079ffda"
}
}
```
---
## Extracting Device IDs
From the JSON above we grab the device IDs to be used in the device inventory
```powershell
$RequestBody = $WebhookData.RequestBody | ConvertFrom-Json
$groupAddedDevicesIds = $RequestBody.event.groupAddedDevicesIds
Write-Log "Webhook received. Devices to process: $($groupAddedDevicesIds.Count)"
```
---
## Option A: Fetch Device Inventory
In this example, since there were quite a few number of devices. I was needing to refresh the token mid way through in order for it to work. This could probably be removed as the script will run as a device(s) are added dynamically. I imagine no more than 1 - 5 should be added at a time.
```powershell
# Process each device ID with token refresh every 5 requests
$requestCount = 0
$tokenRefreshInterval = 5
$deviceInfoArray = @()
foreach ($deviceId in $groupAddedDevicesIds) {
Write-Log "Processing device ID: $deviceId" -Level DEBUG
if ($requestCount -gt 0 -and $requestCount % $tokenRefreshInterval -eq 0) {
Write-Log "Refreshing authentication token after $requestCount requests..." -Level INFO
try {
Invoke-WebRequest -Uri "$($jamfProInformation['URI'])/api/v1/auth/keep-alive" -Headers $authHeaders -Method Post -ErrorAction Stop | Out-Null
Write-Log "Token refreshed successfully. Continuing with device ID: $deviceId" -Level SUCCESS
}
catch {
Write-Log "Error refreshing token. Status: $($_.Exception.Message)" -Level ERROR
exit 1
}
}
try {
$response = Invoke-WebRequest -Uri "$($jamfProInformation['URI'])/api/v3/computers-inventory-detail/$deviceId" -Method GET -Headers $authHeaders -ErrorAction Stop
$computerDetails = $response.Content | ConvertFrom-Json
Write-Log "Retrieved computer inventory for $deviceId : $($computerDetails.general.name)" -Level SUCCESS
$deviceInfo = [PSCustomObject]@{
ID = $deviceId
Name = $computerDetails.general.name
MacAddress = $computerDetails.hardware.macAddress
SerialNumber = $computerDetails.hardware.serialNumber
Site = $computerDetails.hardware.model
Username = $computerDetails.userAndLocation.username
DisplayName = $computerDetails.userAndLocation.realName
LastCheckIn = $computerDetails.general.lastContactTime
}
$deviceInfoArray += $deviceInfo
$requestCount++
}
catch {
Write-Log "Error retrieving computer inventory for $deviceId. Status: $($_.Exception.Message)" -Level ERROR
}
}
Write-Log "Completed processing $requestCount devices." -Level SUCCESS
```
---
## Deleting Devices from Jamf
And of course, the final piece. Deleting the actual devices.
```powershell
foreach ($device in $deviceInfoArray) {
try {
$deleteUri = "$($jamfProInformation['URI'])/api/v3/computers-inventory/$($device.ID)"
Write-Log "Deleting device ID: $($device.ID) - $($device.Name)" -Level WARN
$deleteResponse = Invoke-WebRequest -Uri $deleteUri -Method DELETE -Headers $authHeaders -ErrorAction Stop
if ($deleteResponse.StatusCode -eq 204) {
Write-Log "Successfully deleted device ID: $($device.ID) - $($device.Name)" -Level SUCCESS
}
}
catch {
if ($_.Exception.Response.StatusCode -eq 404) {
Write-Log "Error deleting device ID: $($device.ID) - Object ID does not exist" -Level ERROR
}
else {
Write-Log "Error deleting device ID: $($device.ID). Status: $($_.Exception.Message)" -Level ERROR
}
}
}
```
```zsh
ID : 1374
Name : fl-mb35
MacAddress : 14:7F:LiAlarmCheck:
SerialNumber : L7D9T
Site : MacBook Air (M2, 2022)
Username :
DisplayName :
LastCheckIn : 9/12/2025 8:42:11 PM
[2026-03-12 01:01:46] [WARN] Deleting device ID: 1374 - fl-mb35
[2026-03-12 01:01:48] [SUCCESS] Successfully deleted device ID: 1374 - fl-mb35
```
---
## Future Enhancements
- Persist logs to **Azure Storage**
- ASM cleanup logic
- Safety flags / dry-run mode
---
## Final Full Script
```powershell
param(
[Parameter(Mandatory = $false)]
[object] $WebhookData
)
function Write-Log {
param(
[Parameter(Mandatory = $true)]
[string]$Message,
[ValidateSet('INFO', 'WARN', 'ERROR', 'SUCCESS', 'DEBUG')]
[string]$Level = 'INFO'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$color = switch ($Level) {
'INFO' { 'Cyan' }
'WARN' { 'Yellow' }
'ERROR' { 'Red' }
'SUCCESS' { 'Green' }
'DEBUG' { 'Gray' }
default { 'White' }
}
Write-Output "[$timestamp] [$Level] $Message"
}
Connect-AzAccount -Identity
$VaultName = "your-vault-name"
$secretName = "your-client-id"
# Get secret as plain text
$secretValue = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName -AsPlainText
Write-Output "Secret retrieved successfully."
# Jamf Pro API Credentials
$global:jamfProInformation = @{
client_id = "$secretName"
client_secret = "$secretValue"
URI = "https://your.jamfcloud.com"
}
# Get a bearer token for Jamf Pro API authentication
$global:bearerTokenInformation = @{}
$bearerTokenAuthHeaders = @{
"Content-Type" = "application/x-www-form-urlencoded"
}
$bodyContent = @{
client_id = $jamfProInformation['client_id']
client_secret = $jamfProInformation['client_secret']
grant_type = "client_credentials"
}
Write-Log "Requesting Jamf Pro bearer token..." -Level INFO
$bearerTokenAuthResponse = Invoke-WebRequest `
-Uri "$($jamfProInformation['URI'])/api/oauth/token" `
-Headers $bearerTokenAuthHeaders `
-Method Post `
-Body $bodyContent `
-ContentType "application/x-www-form-urlencoded"
if ($bearerTokenAuthResponse.StatusCode -ne 200) {
Write-Log "Error generating token. Status code: $($bearerTokenAuthResponse.StatusCode)." -Level ERROR
exit 1
}
else {
$bearerTokenInformation.Add("Token", "$(($bearerTokenAuthResponse.Content | ConvertFrom-Json).access_token)")
Write-Log "Bearer token acquired successfully." -Level SUCCESS
}
$authHeaders = @{
accept = "application/json"
Authorization = "Bearer $($bearerTokenInformation['Token'])"
}
#############################################
$RequestBody = $WebhookData.RequestBody
$RequestBody = $RequestBody | ConvertFrom-Json
$groupAddedDevicesIds = $RequestBody.event | Select-Object -ExpandProperty "groupAddedDevicesIds"
$groupAddedDevicesIds
#############################################
# Process each device ID with token refresh every 5 requests
$requestCount = 0
$tokenRefreshInterval = 5
$deviceInfoArray = @()
foreach ($deviceId in $groupAddedDevicesIds) {
Write-Log "Processing device ID: $deviceId" -Level DEBUG
if ($requestCount -gt 0 -and $requestCount % $tokenRefreshInterval -eq 0) {
Write-Log "Refreshing authentication token after $requestCount requests..." -Level INFO
try {
Invoke-WebRequest -Uri "$($jamfProInformation['URI'])/api/v1/auth/keep-alive" -Headers $authHeaders -Method Post -ErrorAction Stop | Out-Null
Write-Log "Token refreshed successfully. Continuing with device ID: $deviceId" -Level SUCCESS
}
catch {
Write-Log "Error refreshing token. Status: $($_.Exception.Message)" -Level ERROR
exit 1
}
}
try {
$response = Invoke-WebRequest -Uri "$($jamfProInformation['URI'])/api/v3/computers-inventory-detail/$deviceId" -Method GET -Headers $authHeaders -ErrorAction Stop
$computerDetails = $response.Content | ConvertFrom-Json
Write-Log "Retrieved computer inventory for $deviceId : $($computerDetails.general.name)" -Level SUCCESS
$deviceInfo = [PSCustomObject]@{
ID = $deviceId
Name = $computerDetails.general.name
MacAddress = $computerDetails.hardware.macAddress
SerialNumber = $computerDetails.hardware.serialNumber
Site = $computerDetails.hardware.model
Username = $computerDetails.userAndLocation.username
DisplayName = $computerDetails.userAndLocation.realName
LastCheckIn = $computerDetails.general.lastContactTime
}
$deviceInfoArray += $deviceInfo
$requestCount++
}
catch {
Write-Log "Error retrieving computer inventory for $deviceId. Status: $($_.Exception.Message)" -Level ERROR
}
}
Write-Log "Completed processing $requestCount devices." -Level SUCCESS
if ($deviceInfoArray.Count -gt 0) {
Write-Log "Devices queued for deletion:" -Level INFO
$deviceInfoArray |
Select-Object ID, Name, SerialNumber, Username, LastCheckIn |
Format-Table -AutoSize |
Out-String |
ForEach-Object {
if ($_ -and $_.Trim()) {
Write-Host $_ -ForegroundColor White
}
}
}
$deviceInfoArray
# Delete each device from Jamf Pro
foreach ($device in $deviceInfoArray) {
try {
$deleteUri = "$($jamfProInformation['URI'])/api/v3/computers-inventory/$($device.ID)"
Write-Log "Deleting device ID: $($device.ID) - $($device.Name)" -Level WARN
$deleteResponse = Invoke-WebRequest -Uri $deleteUri -Method DELETE -Headers $authHeaders -ErrorAction Stop
if ($deleteResponse.StatusCode -eq 204) {
Write-Log "Successfully deleted device ID: $($device.ID) - $($device.Name)" -Level SUCCESS
}
}
catch {
if ($_.Exception.Response.StatusCode -eq 404) {
Write-Log "Error deleting device ID: $($device.ID) - Object ID does not exist" -Level ERROR
}
else {
Write-Log "Error deleting device ID: $($device.ID). Status: $($_.Exception.Message)" -Level ERROR
}
}
}
```