Orchestrating the Status Channel, Part 3: Receiving a Webhook and Acting on DDM Status

A WMI permanent event consumer sits idle until the subscribed condition occurs, then runs a script that reads the event data and takes action. An SCCM status filter rule fires on a client message, extracts the relevant identifiers, and executes whatever response the administrator configured. In both cases, the pattern has three distinct steps: an event fires, state is verified, and a response is issued.

This article implements that same pattern for DDM. A Jamf Pro webhook fires on a device check-in, the script queries the device’s current declaration status, and if a non-compliant declaration is found, the script triggers a DDM sync. Each step is separate and explicit. The webhook is not the compliance verdict. The status query is.

This article focuses on the PowerShell logic that runs when a webhook fires. The listener itself could be a script on a management-tier server, any host that can receive HTTP POST requests, or a function within the AdminCrossover.Tools local management workflow. The hosting infrastructure is out of scope. The PowerShell logic is not.

End-to-end DDM remediation loop: a check-in webhook leads to token validation, managementId resolution, a status query, and a conditional sync, after which the device re-evaluates and reports new state.

The Incoming Payload

When Jamf Pro fires a ComputerCheckIn webhook, the POST body carries device identifiers in the event object. The fields this script depends on are managementId and jssID:

{
  "webhook": {
    "id": 1,
    "name": "DDM Status Check Trigger",
    "webhookEvent": "ComputerCheckIn",
    "eventTimestamp": 1787023800972
  },
  "event": {
    "trigger": "CLIENT_CHECKIN",
    "username": "jappleseed",
    "computer": {
      "udid": "33333333-3333-3333-3333-333333333333",
      "deviceName": "admincrossover-macbook-pro",
      "serialNumber": "C02ABCDEFGHIJK",
      "jssID": 42,
      "managementId": "22222222-2222-2222-2222-222222222222"
    }
  }
}

The identifiers live under event.computer, not directly on event. event.computer.managementId is the clientManagementId used by all DDM API calls; event.computer.jssID is the numeric Jamf Pro computer ID used for inventory lookups when managementId is absent.

The Authorization header on each incoming request carries the pre-shared token configured in the webhook definition. The script validates this before doing anything else. A mismatch means the POST did not originate from your Jamf Pro instance and must be rejected.

Core Logic: Validate, Query, Act

The script below accepts the raw webhook JSON body and the Authorization header value as parameters. In practice, whatever HTTP listener you use passes these two values in. Authentication and session management use AdminCrossover.Tools and stay out of the way of the DDM logic.

<#
.SYNOPSIS
    Processes a Jamf Pro webhook payload and triggers a DDM sync for non-compliant devices.
.DESCRIPTION
    Validates the incoming Authorization header against a pre-shared token.
    Authenticates to Jamf Pro and extracts the clientManagementId
    from the webhook payload, falling back to a jssID-based inventory lookup if absent.
    Queries DDM status items, parses the string-encoded JSON value for each
    management.declarations.* key, and checks the 'valid' field. Triggers a DDM sync
    via POST /api/v1/ddm/{managementId}/sync for any declaration reporting valid: invalid.
    Revokes the session token in a finally block.
.PARAMETER WebhookPayloadJson
    The raw JSON string from the Jamf Pro webhook POST body.
.PARAMETER IncomingAuthToken
    The value of the Authorization header from the incoming webhook request.
.PARAMETER JamfUrl
    Base URL of the Jamf Pro instance.
.PARAMETER ClientId
    Jamf Pro API client ID. The API client requires: Read Computers, Read Mobile Devices,
    Send Declarative Management Command.
.PARAMETER ClientSecret
    Jamf Pro API client secret.
.PARAMETER ExpectedAuthToken
    The pre-shared token configured in the Jamf Pro webhook definition.
.EXAMPLE
    Invoke-DdmWebhookRemediation `
        -WebhookPayloadJson $RawBody `
        -IncomingAuthToken  $RequestAuthHeader `
        -JamfUrl            'https://yourinstance.jamfcloud.com' `
        -ClientId           $env:JAMF_CLIENT_ID `
        -ClientSecret       $env:JAMF_CLIENT_SECRET `
        -ExpectedAuthToken  $env:JAMF_WEBHOOK_TOKEN
.NOTES
    Requires PowerShell 7 or higher.
    API client privileges: Read Computers, Read Mobile Devices,
    Send Declarative Management Command.
#>
function Invoke-DdmWebhookRemediation {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$WebhookPayloadJson,

        [Parameter(Mandatory)]
        [string]$IncomingAuthToken,

        [Parameter(Mandatory)]
        [string]$JamfUrl,

        [Parameter(Mandatory)]
        [string]$ClientId,

        [Parameter(Mandatory)]
        [string]$ClientSecret,

        [Parameter(Mandatory)]
        [string]$ExpectedAuthToken
    )

    # Step 1: Validate the incoming authorization token before doing anything else.
    if ($IncomingAuthToken -ne $ExpectedAuthToken) {
        Write-Warning "$(Get-Date -Format 'HH:mm:ss') - Authorization token mismatch. Request rejected."
        return
    }
    Write-Host "$(Get-Date -Format 'HH:mm:ss') - Webhook token validated."

    try {
        # Step 2: Authenticate to Jamf Pro.
        $null = Connect-ACJamfPro `
            -JamfUrl      $JamfUrl `
            -ClientId     $ClientId `
            -ClientSecret $ClientSecret `
            -ErrorAction  Stop

        # Step 3: Parse the webhook payload and extract device identifiers.
        $Payload    = $WebhookPayloadJson | ConvertFrom-Json
        # Device details are nested under event.computer, not directly on event.
        $Computer   = $Payload.event.computer
        $DeviceName = $Computer.deviceName
        $JssId      = $Computer.jssID

        $ManagementId = $Computer.managementId

        # Step 4: Fall back to inventory lookup if managementId is absent.
        if ([string]::IsNullOrWhiteSpace($ManagementId)) {
            Write-Host "$(Get-Date -Format 'HH:mm:ss') - managementId absent. Looking up via jssID $JssId..."

            if ([string]::IsNullOrWhiteSpace($JssId)) {
                Write-Warning "$(Get-Date -Format 'HH:mm:ss') - Neither managementId nor jssID in payload. Cannot proceed."
                return
            }

            # Retrieve the computer inventory record. ManagementId maps to general.managementId.
            $Computer     = Get-ACJamfComputer -Id $JssId
            $ManagementId = $Computer.ManagementId

            if ([string]::IsNullOrWhiteSpace($ManagementId)) {
                Write-Warning "$(Get-Date -Format 'HH:mm:ss') - Could not retrieve managementId for jssID $JssId. Device may not be DDM-enrolled."
                return
            }
            Write-Host "$(Get-Date -Format 'HH:mm:ss') - managementId retrieved: $ManagementId"
        }

        Write-Host "$(Get-Date -Format 'HH:mm:ss') - Processing '$DeviceName' (managementId: $ManagementId)."

        # Step 5: Fetch all DDM status items for the device.
        $StatusResponse = Invoke-ACJamfApi `
            -Endpoint "/ddm/$ManagementId/status-items" `
            -Method   Get

        $StatusItems = $StatusResponse.Data.statusItems

        if (-not $StatusItems -or $StatusItems.Count -eq 0) {
            Write-Warning "$(Get-Date -Format 'HH:mm:ss') - No DDM status items for '$DeviceName'. Device may not have sent a status report yet."
            return
        }

        # Step 6: Parse the string-encoded JSON value for each declaration key.
        # The value field contains JSON like: {"active":[...],"valid":"invalid",...}
        # ConvertFrom-Json is required before accessing .valid.
        $NonCompliantDeclarations = $StatusItems |
            Where-Object { $_.key -like 'management.declarations.*' } |
            ForEach-Object {
                $Key   = $_.key
                $Value = $_.value

                try {
                    $Parsed = $Value | ConvertFrom-Json -ErrorAction Stop
                    if ($Parsed.valid -eq 'invalid') {
                        [PSCustomObject]@{
                            Key        = $Key
                            Valid      = $Parsed.valid
                            Identifier = $Parsed.identifier
                            Active     = $Parsed.active -join ', '
                        }
                    }
                }
                catch {
                    Write-Warning "$(Get-Date -Format 'HH:mm:ss') - Could not parse value for key '$Key': $($_.Exception.Message)"
                }
            } |
            Where-Object { $null -ne $_ }

        # Step 7: Act on non-compliance.
        if ($NonCompliantDeclarations.Count -gt 0) {
            foreach ($Declaration in $NonCompliantDeclarations) {
                Write-Warning "$(Get-Date -Format 'HH:mm:ss') - Non-compliant: '$DeviceName' | key: $($Declaration.Key) | identifier: $($Declaration.Identifier)"
            }

            Write-Host "$(Get-Date -Format 'HH:mm:ss') - Triggering DDM sync for '$DeviceName'..."

            Invoke-ACJamfApi `
                -Endpoint "/ddm/$ManagementId/sync" `
                -Method   Post `
                -AllowEmptyBody | Out-Null

            Write-Host "$(Get-Date -Format 'HH:mm:ss') - DDM sync queued for '$DeviceName'."
        }
        else {
            Write-Host "$(Get-Date -Format 'HH:mm:ss') - '$DeviceName' is compliant with all monitored declarations."
        }
    }
    catch {
        $ErrorBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
        if ($ErrorBody) {
            Write-Error "API error: $($ErrorBody | ConvertTo-Json -Depth 5)"
        }
        else {
            Write-Error "Request failed: $($_.Exception.Message)"
        }
    }
    finally {
        # Invalidate the Jamf Pro session token.
        Revoke-ACJamfAccessToken -ErrorAction SilentlyContinue
        Write-Host "$(Get-Date -Format 'HH:mm:ss') - Session token revoked."
    }
}

Why Sync, Not Push a Policy

The remediation action is POST /api/v1/ddm/{managementId}/sync, called here as:

Invoke-ACJamfApi -Endpoint "/ddm/$ManagementId/sync" -Method Post -AllowEmptyBody

This queues a DeclarativeManagement MDM command for the device, which causes it to re-evaluate all received declarations and send a fresh status report.

The distinction from traditional MDM remediation matters. In legacy MDM, non-compliance triggers the server to push a corrective command or re-push a configuration. In DDM, enforcement is autonomous on the device. When a declaration evaluates to valid: invalid, the device itself is responsible for attempting to resolve that state. The server’s role is to ensure the device re-evaluates, not to prescribe how the evaluation should resolve.

Where enforcement logic lives: legacy MDM keeps intelligence server-side with the device executing instructions, while DDM keeps intelligence device-side with the server only triggering re-evaluation.

Forcing a sync is the equivalent of running gpupdate /force on a Windows machine after a Group Policy change. You are telling the device to re-read its declared configuration and apply it, not sending the configuration again. If the declaration is correctly defined and the device can comply, it will. If the device cannot comply, the next status report will reflect that, which is the appropriate signal to investigate the declaration itself or the device’s environment.

One important difference from Group Policy is the timing guarantee, or rather the absence of one. DDM is an eventually consistent system. The sync command queues a DeclarativeManagement MDM command, the device receives it via APNs, re-evaluates its declarations, and sends a new status report to Jamf Pro. Each of those steps introduces latency. A status query run immediately after triggering a sync will almost certainly return the same valid: invalid result that triggered the sync in the first place. The updated state arrives when the device reports it, not when the server requests it. Design any verification step to account for propagation delay, not to expect instantaneous state change.

Triggering a sync without first confirming non-compliance through a status query adds MDM command queue pressure without diagnostic value. The status check in step 6 is not optional.

Handling a Missing managementId

The fallback path in step 4 handles devices where the webhook payload does not include managementId. When it is absent, the script calls Get-ACJamfComputer -Id $JssId to retrieve the full inventory record. The clientManagementId comes back as ManagementId on the returned object, the same field covered in Part 1.

If neither field is present in the payload, the script logs a warning and returns. There is no safe way to proceed without a valid device identifier.

Operational Considerations

The remediation loop described in this article works correctly at small scale with a synchronous implementation. As fleet size grows, several operational realities become relevant.

Webhook bursts. After a major macOS release or a Jamf Pro maintenance window, hundreds or thousands of devices may check in within a short window. If each ComputerCheckIn webhook triggers a synchronous status query and potential sync command, the automation host faces a burst of outbound API calls at the same moment Jamf Pro is already processing the check-in storm. In environments with more than a few hundred managed devices, consider queuing incoming webhook events and processing them asynchronously. The queue absorbs the burst; the worker processes events at a sustainable rate.

Idempotency. A device that checks in twice in quick succession generates two ComputerCheckIn webhooks. The remediation script may trigger two status queries and two sync commands for the same device before the first sync has had time to be delivered and acted on. The sync command itself is idempotent: queuing it twice for the same device produces the same result as queuing it once. The redundant API calls add noise to logs and unnecessary load on the Jamf API. Adding a short-lived deduplication mechanism, keyed on managementId, prevents processing the same device more than once per interval.

Rate limiting. The Jamf Pro API enforces rate limits. A large burst of status queries in rapid succession may result in 429 responses. If the remediation script runs synchronously against every incoming webhook without queuing, a check-in burst can exhaust the API rate limit quickly. Exponential backoff on retry and asynchronous processing both reduce the risk.

Stale status data. The status query returns the most recent DDM status report Jamf Pro has stored for the device. If the device has not checked in recently, that stored data may not reflect the current on-device state. A valid: invalid result from a status report that is 24 hours old may have already resolved on the device without Jamf Pro knowing. Inspect lastUpdateTime on each status item before acting on it in high-stakes automation.

Failure Modes

The remediation loop has several distinct failure points, each with different causes and diagnostic signals.

Webhook arrives but managementId is absent. Older Jamf Pro versions or specific enrollment configurations occasionally omit managementId from the ComputerCheckIn payload. The fallback to jssID handles this, but if jssID is also absent, the script cannot identify the device and exits. Log the raw payload when this happens, as it usually indicates a version or configuration mismatch worth investigating at the Jamf Pro level.

Get-ACJamfComputer returns no ManagementId. The device exists in Jamf Pro inventory but is not DDM-enrolled. This commonly occurs with devices that enrolled before DDM was enabled, or devices running macOS versions below the DDM minimum. The script logs a warning and exits. There is no remediation action available for a device that is not DDM-enrolled.

Status query returns empty statusItems. The device is DDM-enrolled but has not yet sent a status report. This is a timing condition, not an error. The next check-in will either include a status report or not. Logging and exiting is correct. Do not trigger a sync. There is no known non-compliant state to remediate, and triggering a sync to force a status report is an acceptable pattern but should be a deliberate choice, not the default.

Sync command queued but status does not update. The POST /ddm/{managementId}/sync call succeeded, but subsequent status queries still return valid: invalid. Possible causes: the APNs push notification was not delivered (device offline, APNs unreachable, or push certificate expired); the device evaluated the declaration and determined it cannot comply; or the propagation delay has not elapsed yet. Check the device’s APNs reachability in Jamf Pro inventory before escalating. If APNs delivery is confirmed, the valid: invalid state after re-evaluation indicates a genuine compliance failure that requires investigating the declaration configuration or the device’s local environment.

403 on the sync endpoint. The API client is missing the Send Declarative Management Command privilege. This is separate from the read privileges required for the status query. Verify the API role includes all three required privileges: Read Computers, Read Mobile Devices, and Send Declarative Management Command.

Declaration key value fails JSON parse. The value field for a declaration key contains malformed JSON. This is rare but can occur if the device reported a partial status update. The script catches parse failures and logs them without throwing. Treat a parse failure as an inconclusive result. Do not act on it as a compliance verdict in either direction.

The operating rule

A webhook tells you something changed. A DDM status query tells you what the current state is. A sync tells the device to evaluate again. These are three separate steps, and they must stay separate. Triggering a sync without first confirming non-compliance is noise. Conflating the webhook event with a compliance verdict skips the only step that produces a reliable signal. Observe, confirm, then act.