# 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 } } } ```