DevicesMDM & Device Operations
On this page0

Reference

Included in: All plans. Which Plan Includes What

MDM & Device Operations

Operate the Apple MDM channel and the cross-OS device fleet from your instance — enrol devices, lock or wipe a Mac through Apple MDM, push profiles, scope smart groups, and recover an encrypted disk with a full audit trail.

What It Does

The device console does two distinct jobs, and it is worth keeping them apart because they reach a computer in different ways. Apple MDM is the Apple channel — it locks, wipes, restarts, or profiles a Mac through Apple’s push service. The Zonitix agent is the cross-OS channel — on macOS, Linux and Windows it applies each system’s own hardening settings (the firewall on all three, Gatekeeper on a Mac) and runs queued commands (restart, install OS updates, rename; lock screen on Windows and Linux) each time the agent checks in. Some operations here go through MDM and some through the agent, and what each can do differs.

Where Apple MDM Runs

The Zonitix agent reports to your own instance. Apple MDM does not: it is a preview feature that runs from one Zonitix-hosted service, shared across customers, with each customer’s Macs in a separate tenant. When you use the Apple MDM actions on this page, your instance passes the request to that service, and the records for your enrolled Macs are held there. What it holds is in the Trust Center sub-processor list, and how your instance connects to it is on Design Philosophy. If Apple MDM is not set up for your instance, the Apple MDM panel shows that it is not connected and the Mac actions are not offered.

If something here is not working as you expect, start with When something goes wrong.

The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it. Closed: select the heading above to open it. While it is closed, your browser’s find-on-page (Ctrl+F or Cmd+F) does not search this part.

Three Gates, Everywhere

Every admin route on this page is gated by one of three RBAC permissions. Reads that only show state require view_devices; routine changes — lock, restart, deploy a profile, edit a group — require manage_devices. The irreversible or secret-revealing actions sit behind a third, separate permission, manage_device_destructive, plus a step-up re-authentication and a written reason (at least 3 characters, else 400): wiping a Mac, revealing a disk recovery key, rotating a FileVault key, permanently deleting a device, and uninstalling the agent. Exporting the push certificate’s private key needs the same permission and the step-up (no reason); installing a raw, hand-written configuration profile needs the permission. Holding manage_devices alone does not unlock any of them.

view_devices — readmanage_devices — writemanage_device_destructive — step-up + reasonAudited device events

MDM Is Fail-Safe, Not Fail-Loud

If MDM is not configured for your instance, the status and list routes return { configured: false } and the Apple MDM panel shows a connect step in place of the Mac actions — it never throws an error that blocks the page. Write routes return a plain 400 “MDM is not configured” instead.

The Connection Key Never Reaches You

The per-tenant MDM API key is attached server-side; the browser never sees it. If the control plane rejects that key, the app remaps the 401/403 to a 502 “control plane rejected the connection key” so a valid admin session is never bounced to the login screen. A control plane that does not answer at all is reported as 424 on command routes, described under Per-Device Commands.

Path Prefix & Auth

Every admin route below is reached through the authenticated gateway at /api/v1/user-management/devices/… with your session bearer token. The one exception is recovery-key escrow, which the agent posts on the signed /api/v1/agent/… channel — no human session, authenticated by the device’s own signature. Examples use https://<your-instance>-api.zonitix.com, your instance’s API address; every customer has a private one.

MDM Status & Enrolled Devices

Two read routes tell you whether MDM is wired up and what it sees. status aggregates three upstream calls (devices, enrollment, push cert) and reports connected only when the MDM device list returns a 2xx. MDM-enrolled Macs are folded into the main fleet inventory on a throttled (60s), best-effort sync, so they also appear under Groups and can be scoped by profiles.

MethodPathPurposeAuth
GET/devices/mdm/statusConfigured? connected? tenant, deviceCount, enrollmentUrl, enrollmentReady, hasCert, certNotAfterview_devices
GET/devices/mdm/devicesProxied MDM device list (serial, udid, last-seen). {configured:false,devices:[]} when unconfigured; {configured:true,connected:false,reachable:false,devices:[]} when the MDM does not answerview_devices
GET/devices/mdm/:udid/inventoryLatest inventory the Mac returned over MDM — device information, security info, installed applications, certificates, profiles. 424 when the MDM does not answerview_devices
POST/devices/mdm/:udid/queryAsk the Mac to refresh that inventory. Sibling routes: /security-info, /installed-apps, /certificate-listview_devices

// Is MDM live for this tenant?

curl -s https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/mdm/status \

-H "Authorization: Bearer $TOKEN"

// Not wired up yet — the UI hides the feature, no error:

{ "configured": false }

// Wired up and reachable:

{ "configured": true, "connected": true, "tenant": "acme",

"deviceCount": 3, "enrollmentReady": true,

"hasCert": true, "certNotAfter": "2027-03-01T00:00:00Z" }

Inspector checks: connected:false with configured:true means the tenant key is set but the control plane did not answer 2xx (or rejected the key — see the 502 remap). hasCert:false means no Apple push certificate is installed yet, so no command can actually reach a device even once enrolled.

Apple Push Certificate

The APNs push certificate and its private key are what let MDM reach a Mac at all. The usual path is self-serve: download the signing request from your instance, take it to Apple’s push-certificate portal, then upload the .pem Apple returns — on its own, because the MDM already holds the matching private key from the request step. If you made the key pair yourself, upload the certificate together with its key, or a .p12 with its passphrase. The MDM validates the upload and extracts the APNs topic. The invariant an inspector should verify: on upload, any private key transits app → MDM only, rests only in the MDM (never in the app database), and is never echoed back to the browser. The one deliberate exception is the key-backup route, which exports the certificate and key as a .p12 behind the destructive gate; the download is streamed through, not stored in the app, and audited.

MethodPathPurposeAuth
GET/devices/mdm/pushcertPush-cert status only — hasCert, notAfter. {configured:false,hasCert:false} when unconfigured; {configured:true,hasCert:false,reachable:false} when the MDM does not answerview_devices
GET/devices/mdm/pushcert/requestDownload the signing request to take to Apple. 404 when none is ready yetmanage_devices
POST/devices/mdm/pushcert/validateAdvisory pre-check of a cert + key (or .p12): returns valid, per-check results and plain reasons. Installs nothingmanage_devices
POST/devices/mdm/pushcertUpload the certificate (private key optional). Forwarded to MDM; size-capped; never echoed backmanage_devices
GET/devices/mdm/pushcert/keybackupDownload a .p12 backup of the push certificate AND its private key. Auditedmanage_device_destructive + step-up

// certPem is required. keyPem is optional — omit it when the MDM holds the key from the request step.

curl -s -X POST \

https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/mdm/pushcert \

-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \

-d '{ "certPem": "-----BEGIN CERTIFICATE-----..." }'

// No certificate → 400. Combined length > 200000 characters → 413.

{ "error": "The Apple push certificate (.pem) is required." }

Status codes: 400 when MDM is unconfigured, the certificate is missing, or a .p12 cannot be opened; 413 when certPem + keyPem exceeds 200,000 characters; 424 when the MDM does not answer the upload — the certificate may or may not have been installed, so reload the page to check before retrying; otherwise the MDM’s own status is returned. The upload is audited on every outcome, and the response never contains the key material you sent.

Per-Device Commands (Lock, Wipe, Power, Bootstrap)

These act on one MDM-enrolled Mac addressed by its :udid. Lock, power and bootstrap require manage_devices; wipe requires manage_device_destructive, a step-up re-authentication and a reason. All return 400 if MDM is not configured.

Each command is answered by its outcome. Lock, wipe, restart, shut down, bootstrap and raw profile install and removal are audited on every outcome. Queued, or refused by the MDM (for example a 404 for an unknown device): the MDM’s own status and body. The MDM did not answer (timeout or unreachable): 424 { error, outcome: 'error' } — the command may or may not have been queued, so check the device before retrying. The MDM answered 2xx but did not queue it: 424 { error, queued: false, outcome: 'not_queued' } — nothing was sent to the Mac. The same status rules cover lock, restart, shut down, bootstrap and raw profile install and removal. They also cover the inventory-refresh routes above, which are read-only and write no audit record. Wipe follows them too, except that its no-answer case returns 502 rather than 424. The lock-PIN, enrollment-code and enrollment-profile reads return 424 when the MDM does not answer, and write no “revealed” or “minted” audit record.

MethodPathPurposeAuth
POST/devices/mdm/:udid/lockRemote lock — RETURNS a PIN to record (macOS has no remote unlock). Optional {message}manage_devices
GET/devices/mdm/:udid/lock-pinRetrieve the device’s stable unlock PIN (typed on the Mac to unlock)manage_devices
POST/devices/mdm/:udid/wipeErase the device — destructive, irreversible. Body {reason*} (3+ characters, else 400). Audited at CRITICALmanage_device_destructive + step-up
POST/devices/mdm/:udid/restartRestart the Macmanage_devices
POST/devices/mdm/:udid/shutdownShut the Mac downmanage_devices
POST/devices/mdm/:udid/install-agentPush agent config profile, then install the Zonitix agent pkg (requires API_DOMAIN)manage_devices

Lock Returns a PIN — Record It

macOS has no remote unlock. When you lock a Mac, the response carries a PIN; an admin must physically type that PIN on the device to unlock it. The PIN is stable and retrievable afterwards via GET /devices/mdm/:udid/lock-pin — note that retrieving it needs manage_devices, not the weaker read permission, and each retrieval is audited. Wipe is destructive and irreversible; there is no undo. It is refused without the destructive permission, a fresh re-authentication and a reason, and a CRITICAL audit record (who, why, from which address, and what the MDM answered) is written on every outcome — including when the MDM did not answer, in which case the route returns 502 and says the wipe may or may not have been queued.

// Lock a Mac — the MDM response carries the unlock PIN to record.

curl -s -X POST \

https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/mdm/$UDID/lock \

-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \

-d '{ "message": "Locked by IT — call the help desk." }'

// Later, retrieve the same stable PIN (manage_devices):

curl -s https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/mdm/$UDID/lock-pin \

-H "Authorization: Bearer $TOKEN"

// Wipe — destructive permission + step-up session + a reason, or it is refused:

curl -s -X POST \

https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/mdm/$UDID/wipe \

-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \

-d '{ "reason": "Laptop reported stolen, ticket 4821" }'

→ 400 { "error": "A reason is required to wipe a device." } (when the reason is missing)

install-agent is a two-step bootstrap: it first pushes this customer’s agent config profile (server URL + reusable tenant enrollment token, so the installed agent can only ever report to your tenant), then issues InstallEnterpriseApplication pointing at your own signed pkg URL https://<API_DOMAIN>/agent/pkg. It returns 400 if API_DOMAIN is unset, because there would be no download URL to build.

The install request carries the hosted package’s SHA-256 and size, which go into the install manifest so the Mac refuses bytes that do not match. If no usable hash is stored for the package, the install relies on the installer signature alone and your instance logs a warning. If the configuration profile is not queued, the package is not sent and the route answers for the configuration step using the outcome rules above, so an agent is never installed without knowing which instance to report to. The audit record says which step was reached and whether the package hash was sent.

Enrol A Mac In Apple MDM

Enrolling a Mac in Apple MDM is separate from installing the Zonitix agent, and it is done on the Mac by the person holding it — zero-touch (DEP/ADE) enrollment is not available. In order:

  1. Connect Apple MDM. In the device setup flow, the optional “Apple MDM & push certificate” step connects your instance to the MDM service. If it reports that a provisioning key is needed, that is a one-time step on our side — contact support.
  2. Install your Apple push certificate (see Apple Push Certificate above). Until hasCert is true, a Mac can enrol but no command can reach it.
  3. Send the Mac to your enrollment page, https://<your-instance>.zonitix.com/enroll, opened in Safari. A signed-in user gets the enrollment profile straight away. Someone without an account enters an enrollment code an admin generated: a single-use code (the dashboard shows it expiring in about 15 minutes) or a reusable one for a batch of Macs (shown as valid for 24 hours).
  4. Install the profile on the Mac in System Settings → General → VPN & Device Management. The Mac then appears in the MDM device list and is folded into the main inventory.
MethodPathPurposeAuth
POST/devices/mdm/provisionConnect this instance to the MDM service. Returns {configured, needsProvisioningKey}manage_devices
POST/devices/mdm/enroll-codeMint an enrollment code. Body {reusable} — false (default) single-use, true reusable. Auditedmanage_devices
GET/devices/mdm/enroll-profileDownload this instance’s enrollment profile (.mobileconfig). AuditedAny signed-in user
POST/agent/mdm-enroll-redeemExchange an enrollment code for the enrollment profile. Body {code}Enrollment code (no session)

Two things an inspector should know. The profile download needs only a valid session, not view_devices — enrolling your own Mac is treated as self-service, and each download is audited. And a code is redeemed against this instance’s own MDM tenant, so it only works on the instance whose enrollment page it was typed into.

Install / Remove A Raw Profile

Below the curated catalog, two routes install or remove a configuration profile on one enrolled Mac directly. Install takes either a catalog profileKey (the control plane maps it to a locked .mobileconfig) or a raw profileBase64. Remove takes the profile’s payload identifier.

MethodPathPurposeAuth
POST/devices/mdm/install-profileInstall a profile on one Mac. Body {udid*, profileKey | profileBase64}manage_devices (profileKey) · manage_device_destructive (profileBase64)
POST/devices/mdm/remove-profileRemove a profile by identifier. Body {udid*, identifier*}manage_devices

Guards: install returns 400 “udid required” without a udid; remove returns 400 unless both udid and identifier are present. Both return 400 when MDM is unconfigured; otherwise they follow the command outcome rules under Per-Device Commands (the MDM’s own status when it queued or refused the request, 424 when it did not answer or did not queue it). A raw profileBase64 is not bounded by the catalog, so it is refused with 403 unless the caller holds manage_device_destructive (or the administrator role), and the refusal is audited; a raw profile over 1,000,000 characters returns 413. Removing a profile is audited at warning level, because it strips an enforced control.

Configuration Profiles (Catalog, CRUD, Deploy)

A profile is a named bundle of payloads plus a scope (all devices / listed groups / listed devices). Payloads come from a fixed catalog with two engines. A profile belongs to one platform, and a scope can also carry exclusions — an excluded group or device is always out, whatever else matches. engine: 'mdm' payloads deploy as locked Apple profiles or MDM commands: screen_lock, mac_filevault, install_agent. engine: 'agent' payloads are applied by the target OS’s agent via the heartbeat. macOS: firewall, gatekeeper, software_updates, macos_major_updates, app_store_updates, disable_ssh, disable_screen_sharing, disable_guest, disable_autologin, agent_screen_lock. Linux: firewall, linux_auto_updates, auditd, time_sync, fail2ban. Windows: firewall, win_defender, win_auto_updates. On every platform, agent_update holds the scope on a chosen agent version and agent_freeze excludes it from agent auto-updates.

MethodPathPurposeAuth
GET/devices/profiles/catalogThe payload catalog + current enforcement stateview_devices
GET/devices/profiles/enforcementEnforcement state: {on, byPlatform:{macos,linux,windows}, profilesOnly}view_devices
GET/devices/profiles/enforcement/previewRead-only: the devices whose agent policy would change if profile enforcement were on, as {total, items}. Optional ?platform=macos|linux|windows; another value is 400view_devices
PUT/devices/profiles/enforcementPer-OS enforcement switch. Body {platform, on}. Returns 409 once profiles are always enforced (see below)manage_devices
GET/devices/profilesList profiles with resolved scope countsview_devices
POST/devices/profilesCreate. Body {name*, platform*, description, payloads[], scope, enabled}. 400 without a platformmanage_devices
PUT/devices/profiles/:idUpdate name / description / payloads / scope / enabled. Add a rollout object to stage the change (below). 409 while the profile is mid-rolloutmanage_devices
DELETE/devices/profiles/:idDelete a profile; its locked MDM profiles are removed from in-scope Macsmanage_devices
POST/devices/profiles/:id/splitSplit an older mixed-platform profile into one profile per platform with the same settings and scope, then delete the original. Returns {created:[{id,platform,name}], deletedId, mdmRemoval}. 404 unknown profile; 409 if it is not mixed or is mid-rolloutmanage_devices
POST/devices/profiles/:id/deployPush MDM payloads to in-scope enrolled Macs now; agent payloads apply at each device’s next check-inmanage_devices
POST/devices/profiles/bulk-deployDeploy up to 100 profiles. Body {ids[]}. Returns {succeeded, failed}; one failure never aborts the restmanage_devices
GET/devices/profiles/:id/statusPer-device status for one profile, with countsview_devices
GET/devices/:id/profile-statusEvery profile’s status on one deviceview_devices
GET/devices/profiles/:id/historyThe profile’s audit trail, newest firstview_devices

A profile change can reach its scope in stages (rings) rather than all at once. Devices the rollout has not reached keep the previous version — or get nothing, when the profile is new. To stage an edit to a live profile, send a rollout object with the edit on PUT /devices/profiles/:id; the edit and the rollout start are saved together, and 400 is returned if nothing changed. To stage a draft (not yet enabled) profile, use POST /devices/profiles/:id/rollout, which enables it for the first ring only. The rollout body is { rings[], autoPromote, promoteAfterHours }: 2–5 rings, each a set of groups or a percentage, the last always everyone. autoPromote defaults to true and promoteAfterHours to 24 (whole hours, 1–720, otherwise 400). A percentage ring is a whole number from 1 to 99, and each must be larger than the one before. With auto-promote on, the rollout moves to the next ring once the current ring has run for promoteAfterHours with no failed device; any failure in the ring holds it there until an admin promotes, pauses or rolls back. The check runs every 15 minutes. While a rollout is in progress the profile refuses further edits with 409 until it completes or is rolled back.

MethodPathPurposeAuth
GET/devices/profiles/rolloutsSummary of every staged rollout in progressview_devices
GET/devices/profiles/:id/rolloutOne profile’s rollout stateview_devices
POST/devices/profiles/:id/rolloutStart a staged rollout of a draft (not yet enabled) profile. Body {rings[], autoPromote, promoteAfterHours}. 409 if the profile is already livemanage_devices
PUT/devices/profiles/:id/rolloutRollout settings. Body {autoPromote, promoteAfterHours}manage_devices
POST/devices/profiles/:id/rollout/promotePromote to the next stage. Sibling actions: /pause, /resume, /rollback. Each is auditedmanage_devices

Scope Applies To Both Engines

A profile’s MDM payloads are pushed to the MDM-enrolled devices in its scope when you deploy. Its agent payloads are applied per device too: each in-scope device picks them up at its next check-in, and a device outside the scope (or excluded from it) does not. Where two profiles cover the same device, the strictest setting wins — a control switched on by any profile stays on, and the shortest screen-lock delay applies. Hardening and update policy now lives in profiles: each platform has a Baseline profile holding its fleet-wide settings, and once an instance has been moved onto that model profiles are always enforced — the enforcement switch returns 409 and you stop a profile by disabling it or narrowing its scope. An instance not yet moved over still has the per-OS switch, and the deploy response then reports enforcementOn: false with a warning that hardening payloads will not apply until it is turned on.

A second nuance worth knowing: within the catalog, some payloads are modelled but not yet live. win_bitlocker is live:false — it reports BitLocker state but does not enforce it (enforcement is pending recovery-key escrow). Deploy only acts on live payloads; a profile of non-live payloads returns "Nothing to deploy".

// Create a profile: Screen Lock (MDM) scoped to one smart group.

curl -s -X POST \

https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/profiles \

-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \

-d '{ "name": "Screen lock — Sales Macs", "platform": "macos", "enabled": true,

"payloads": [ { "key": "screen_lock" } ],

"scope": { "type": "groups", "groupIds": [4] } }'

→ 201 { "id": 12 }

// Deploy it — MDM payloads push to in-scope enrolled Macs.

curl -s -X POST \

https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/profiles/12/deploy \

-H "Authorization: Bearer $TOKEN"

→ { "deployed": 2, "devicesTargeted": 2, "inScope": 2,

"agentPending": 0, "enforcementOn": true, "notPushable": 0, "heldByRollout": 0,

"note": "Deployed — 2 MDM command(s) to 2 device(s)." }

// With agent payloads the note adds: "N agent payload(s) apply at each in-scope device's next check-in".

// notPushable counts enrolled Macs on a previous push topic — they will not receive this until they re-enroll.

Smart Device Groups

Groups are the scope targets profiles point at. A static group has explicit member rows; a smart group computes membership from criteria against the inventory, re-evaluated on every read — so a smart group’s member count is worked out from each device’s last report, never from a cached snapshot.

MethodPathPurposeAuth
GET/devices/groupsList groups with member counts worked out at read time + the queryable fieldsview_devices
POST/devices/groupsCreate. Body {name*, kind:static|smart, platform, criteria, deviceIds[]}manage_devices
PUT/devices/groups/:idUpdate name / description / (smart) criteriamanage_devices
POST/devices/groups/previewPreview smart criteria without saving: {count, sample (up to 10), memberCount}view_devices
GET/devices/groups/:id/usageWhere the group is used — every profile and agent rollout scoped to itview_devices
DELETE/devices/groups/:idDelete the group. 409 {usedBy} while a profile or rollout is scoped to it, unless ?force=1manage_devices
GET/devices/groups/:id/devicesResolve current members (static rows or smart match)view_devices
POST/devices/groups/:id/membersAdd members to a STATIC group. Body {deviceIds[]*}manage_devices
DELETE/devices/groups/:id/members/:deviceIdRemove one member from a static groupmanage_devices
POST/devices/groups/:id/members/bulk-removeRemove several members at once. Body {deviceIds[]*}manage_devices

Smart criteria are { match: 'all' | 'any', rules: [ { field, op, value } ] }. A smart group needs at least one valid rule. Only these fields are queryable. Inventory fields: platform, osVersion, modelName, status, agentVersion, serialNumber, name, hostname, osUpdateAvailable, department, location, costCenter, assetTag. Computed fields: owner (the assigned person’s name or email), diskFreePct, checkinDays (days since the last check-in), compliance (compliant or at_risk) and hasApp (an installed application). Operators: eq, neq, contains, startsWith, gt, gte, lt, lte, isTrue, isFalse. Some fields accept only a subset: diskFreePct and checkinDays take the four comparisons; compliance, department and location take eq / neq; hasApp takes contains / eq only — there is no “does not have app” rule, because it would sweep in every device that has not reported its software yet. Version fields compare as versions, so 14.10 is newer than 14.9.

Deleting a group that a profile or agent rollout is scoped to is refused with 409 and a usedBy list. Adding ?force=1 strips the group from those scopes first, then deletes it. A scope emptied that way targets nobody; it never widens to every device.

Platform-Homogeneous By Construction

A group bound to an OS refuses a device of another platform at add time (400), and also filters cross-platform devices out at resolve time — defence in depth, so a member that later changes platform can’t leak into a mismatched scope.

Smart Groups Reject Manual Edits

Adding members to a smart group returns 400 — “Smart groups compute membership from criteria… edit the criteria instead.” Membership is derived, never hand-managed.

// A smart group: all Macs that have an OS update pending.

curl -s -X POST \

https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/groups \

-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \

-d '{ "name": "Macs — update pending", "kind": "smart", "platform": "macos",

"criteria": { "match": "all", "rules": [

{ "field": "platform", "op": "eq", "value": "macOS" },

{ "field": "osUpdateAvailable", "op": "isTrue" } ] } }'

→ 201 { "id": 7 }

Phased Agent-Update Rollouts (Pilot Rings)

Adopting a new agent release with a scope creates a rollout — a testing ring — instead of moving the whole-OS baseline. scope: all (default) advances the fleet baseline for that OS; groups / devices leave the baseline untouched and offer the new version only to that ring. The list route reports, per ring, how many devices are in scope and how many already report the target version, so you can watch a ring land before widening.

MethodPathPurposeAuth
POST/devices/agent-release/adoptAdopt the store-advertised release. Body {platform, scope, apiBase*} — apiBase is your instance’s own https origin (400 without it). scope=all moves the baseline; groups/devices create a scoped ringmanage_devices
GET/devices/agent-rolloutsList rings for a platform with their counts — deviceCount vs onTargetview_devices
POST/devices/agent-rollouts/:id/holdHold (enabled:false) or resume (enabled:true) a ring — the instant pause for a bad rolloutmanage_devices
DELETE/devices/agent-rollouts/:idRemove a ring (does not touch already-installed agents)manage_devices
POST/devices/:id/pinFreeze one device’s agent updates. Optional {version} (default: the version it runs now). Auditedmanage_devices
DELETE/devices/:id/pinResume agent updates for that device. Auditedmanage_devices

The same two controls exist as profile payloads, so they can follow a scope instead of a single device: agent_update keeps a profile’s scope on a chosen agent version, and agent_freeze excludes the scope from agent auto-updates until the payload is removed.

Forward-Only — Holding Is Not Rollback

Updates are forward-only: the agent version floor rejects downgrades, so adopt refuses a version older than the current baseline (a same-version re-host is allowed as a self-heal). Holding a ring stops offering the update to devices that haven’t taken it yet — it does not roll back devices already on it. Rolling a machine back is a separate, deliberate path: from the inventory you pick an older build your instance still hosts, the device is frozen at that version so it is not pulled forward again, and you run the version-pinned reinstall command on the machine itself. Nothing downgrades remotely.

Disk-Encryption Recovery Keys (FileVault / BitLocker)

This is the path that makes a locked-out FileVault or BitLocker machine recoverable — with a paper trail. The Windows agent escrows a BitLocker recovery key over its signed channel; a FileVault key is not sent by the Mac agent and arrives through Apple MDM instead. Either way the app stores it encrypted at rest (AES-256-GCM), never in plaintext and never in a list response. Revealing the plaintext is the only place decryption happens, and it sits behind the destructive gate: the manage_device_destructive permission, a step-up re-authentication, and a written reason, with a CRITICAL audit record written before the key is returned.

MethodPathPurposeAuth
POST/agent/escrow/recovery-keyAgent escrows a key (today only the Windows agent calls it, with bitlocker). Body {keyType: bitlocker|filevault*, recoveryKey*, volumeId}Device signature (signed channel)
GET/devices/:id/recovery-keysList REDACTED escrow metadata — keyType, volumeId, escrowedAt. NO key materialview_devices
POST/devices/:id/recovery-key/:type/revealReveal the plaintext key — the only decrypt. Body {reason*} (3+ characters, else 400). Audited at CRITICAL with the actor, reason and addressmanage_device_destructive + step-up

// 1. The Windows agent escrows over the signed /agent channel (no human session;

// authenticated by the device's own signature headers).

POST https://<your-instance>-api.zonitix.com/api/v1/agent/escrow/recovery-key

{ "keyType": "bitlocker", "recoveryKey": "XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX",

"volumeId": "<key protector id>" }

→ { "ok": true, "escrowed": true }

// A FileVault key lands in the same store through the Apple MDM sync, not through this call.

// 2. Admin lists what's escrowed — metadata only, no key:

GET .../user-management/devices/42/recovery-keys (view_devices)

→ { "keys": [ { "keyType": "filevault",

"volumeId": "disk1s1", "escrowedAt": "2026-08-09T10:00:00Z" } ] }

// 3. Reveal the plaintext — destructive permission + step-up + reason, AUDITED (DEVICE_RECOVERY_KEY_REVEALED):

POST .../user-management/devices/42/recovery-key/filevault/reveal (manage_device_destructive)

{ "reason": "User locked out after password reset, ticket 4907" }

→ { "keyType": "filevault", "recoveryKey": "XXXX-...", "volumeId": "disk1s1",

"rotationScheduled": true }

Invariants An Inspector Can Verify

Escrow requires a valid device signature and accepts only keyType of bitlocker or filevault (else 400); a re-escrow of the same type rotates the stored ciphertext. The list route selects only keyType, volumeId, updatedAt — it is structurally incapable of returning the key. Reveal is the one route that decrypts; it returns 400 without a reason, writes a CRITICAL-level DEVICE_RECOVERY_KEY_REVEALED audit event tagged with the acting admin, the reason and the source address, returns 404 if nothing is escrowed, and 500 if the ciphertext can no longer be decrypted (encryption key rotated without re-escrow).

Rotating A FileVault Recovery Key (macOS Only)

A recovery key someone has read is no longer a secret, so your instance can replace it. Rotation applies to FileVault on Macs only — there is no BitLocker equivalent — and it is not instant: the Mac generates a new key at its next login by a FileVault user, and the new key is then escrowed through Apple MDM. It needs the current agent, FileVault on, and the FileVault profile installed on that Mac; otherwise the request is refused with 409 and the reason. Three things open a rotation: an admin asking for one, a reveal (on by default — the reveal response’s rotationScheduled says whether it did), and an optional schedule that replaces any key older than 30, 90, 180 or 365 days (off by default). Only one request is open per Mac at a time. Like the rest of FileVault escrow through Apple MDM, rotation is part of the preview and not yet validated end-to-end.

MethodPathPurposeAuth
GET/devices/settings/filevaultRotation settings: fileVaultRotateAfterReveal, fileVaultRotationDaysview_devices
PUT/devices/settings/filevaultChange them. fileVaultRotationDays is 30, 90, 180, 365 or null (off). Audited with the changemanage_devices
GET/devices/:id/filevaultOne Mac’s recovery-key status — metadata only, never key materialview_devices
POST/devices/:id/filevault/rotateRotate at next login. Body {reason*} (3+ characters, else 400). 409 if the Mac is not eligible or a request is already openmanage_device_destructive + step-up

Who Can Do What

Read-Only Operator (view_devices)

Can see MDM status, the enrolled-device list, push-cert status, the profile catalog and profiles, groups and their members, and the redacted recovery-key inventory. It can also ask a Mac to refresh its MDM inventory. Cannot push a command, deploy a profile, upload a cert, or reveal a key.

Device Manager (manage_devices)

Everything above, plus lock / power / bootstrap a Mac, retrieve a lock PIN, mint enrollment codes, upload the push cert, create and deploy catalog profiles, edit groups, run agent rollouts, and freeze a device’s agent updates. Cannot wipe a Mac, reveal or rotate a recovery key, or install a raw profile.

Destructive Actions (manage_device_destructive)

A separate permission. With a fresh re-authentication and a written reason: wipe a Mac, reveal a recovery key, rotate a FileVault key, uninstall the agent, and permanently delete a device. With the re-authentication: export the push certificate’s private key. With the permission alone: install a raw profile. Each writes an attributable audit record.

Related reference