Windows 365 VM Migration Pipeline via Microsoft Graph Beta

In this article we're going to figure out how to migrate almost any Azure-based VM into a Windows 365 Cloud PC using the Microsoft Graph beta migration API.

Prerequisites

I assume you're familiar with Microsoft Graph API, Entra ID, Cloud PC, and Provisioning Policies.

What we need to start:

  • A VM disk snapshot exported as a fixed-format VHD page blob, available over a SAS URL.
  • A Windows 365 Enterprise license to provision the target Cloud PC. The license disk size must be greater than or equal to the VM snapshot size, per the Microsoft migration docs.
  • An Entra ID user (or a security group containing them) that will receive the Cloud PC.

The migration API is beta-only and there is no Intune UX for it β€” partners and customers are expected to build their own tooling. That is the part this article is about.

The developer view of the migration

If you flatten the Microsoft flow into a pipeline, you get four steps:

  1. Call the Graph importSnapshot endpoint with the target user ID and the source VHDs.
  2. Poll the import status until it reaches a terminal state.
  3. Create (or reuse) a user setting with provisioningSourceType = snapshot.
  4. Wire a provisioning policy + that user setting + a group containing the licensed target user. Provisioning kicks off automatically once all three line up.

The rest of the article walks through these four steps and shows the public Graph payloads.

1. Importing the snapshot

The migration call lives in the beta surface of Microsoft Graph:

POST /deviceManagement/virtualEndpoint/snapshots/importSnapshot

Required permissions are CloudPC.Read.All (least privileged) or CloudPC.ReadWrite.All, delegated or application, per the Microsoft Graph reference.

Simplified request shape from the public docs:

POST https://graph.microsoft.com/beta/deviceManagement/virtualEndpoint/snapshots/importSnapshot
Content-Type: application/json

{
  "assignedUserId": "93aff428-61f2-467f-a879-1102af6fd4a8",
  "sourceFiles": [
    {
      "sourceType": "azureStorageAccount",
      "fileType": "dataFile",
      "storageBlobInfo": {
        "storageAccountId": "/subscriptions/.../storageAccounts/<account>",
        "containerName": "myContainer",
        "blobName": "snapshotForCloudPc.vhd"
      }
    },
    {
      "sourceType": "azureStorageAccount",
      "fileType": "virtualMachineGuestState",
      "storageBlobInfo": {
        "storageAccountId": "/subscriptions/.../storageAccounts/<account>",
        "containerName": "myContainer",
        "blobName": "virtualMachineGuestState.vhd"
      }
    }
  ]
}

A successful response is 200 OK with a cloudPcSnapshotImportActionResult body that includes importStatus, usageStatus, assignedUserPrincipalName, policyName, and timestamps:

{
  "@odata.context": "https://graph.microsoft.com/beta/$metadata#microsoft.graph.cloudPcSnapshotImportActionResult",
  "filename": "snapshotForCloudPc",
  "usageStatus": "notUsed",
  "importStatus": "inProgress",
  "assignedUserPrincipalName": "snapshot@contoso.com",
  "policyName": null,
  "startDateTime": "2025-01-13T15:13:14Z",
  "endDateTime": null,
  "additionalDetail": null
}

Two things to keep in mind:

  1. This is a beta endpoint. Microsoft states explicitly that beta APIs are subject to change and are not supported for production. Wrap the call in a single thin client so the rest of your code does not depend on the exact shape β€” when Microsoft moves the surface, you change one place.
  2. The endpoint kicks off a long-running, asynchronous process. A 200 response means "import job accepted", not "Cloud PC is ready". Persist the response (especially the filename and start time) on your migration job record so you have something to poll against.

2. Checking the import result

For status, Microsoft exposes a dedicated function bound to the snapshot:

GET /deviceManagement/virtualEndpoint/snapshots/retrieveSnapshotImportResult(snapshotId='{snapshotId}')

See the Microsoft Graph reference for permissions and response details.

Heads up: at the time of writing, the Microsoft Graph SDK generates this call as retrieveSnapshotImportResults (plural, with a trailing s), but the working endpoint is the singular retrieveSnapshotImportResult shown above. SDK calls hit a non-existent route β€” until this is fixed, send the request over raw HTTP or override the SDK request URL. Help me god 🫠
Heads up: yes, really β€” assignedUserPrincipalName in the cloudPcSnapshotImportActionResult response is not a UPN. It's the Entra user ID (GUID). Microsoft just decided to name it like a UPN anyway. Don't ask me why

The response is the same cloudPcSnapshotImportActionResult shape returned by importSnapshot. The two fields that drive your pipeline are:

  • importStatus β€” inProgress while the VHD is being copied into the Windows 365 service storage; transitions to a terminal value when the import is finished.
  • usageStatus β€” notUsed while the imported snapshot is waiting to be picked up by provisioning; switches to inUse once provisioning consumes it.

One constraint to design around: per the migration docs, a user can hold only one imported VHD at a time. If retrieveSnapshotImportResult shows the previous import for that user is still attached, do not retry importSnapshot for the same user β€” clean up first, then re-import.

3. Creating a user setting with snapshot provisioning

By default, a Windows 365 user setting provisions Cloud PCs from a gallery image. To tell Microsoft to use the VHD you just imported, you need a user setting with provisioningSourceType = snapshot. Per the cloudPcUserSetting reference, the possible values are image, snapshot, and unknownFutureValue. If the property is missing or null, the behavior is the same as image.

POST https://graph.microsoft.com/beta/deviceManagement/virtualEndpoint/userSettings
Content-Type: application/json

{
  "@odata.type": "#microsoft.graph.cloudPcUserSetting",
  "displayName": "Migration - snapshot provisioning",
  "localAdminEnabled": false,
  "provisioningSourceType": "snapshot",
  "restorePointSetting": {
    "frequencyInHours": 12,
    "userRestoreEnabled": true
  }
}

A successful response is 201 Created and returns the new cloudPcUserSetting object, including its id. Cache that id β€” you will assign it to the same group that holds your migration users.

The full create reference lists more fields (cross-region disaster recovery, notification settings, restore point options). For a migration-specific user setting I would keep the object minimal β€” it only needs to live as long as the migration window. Once the user has been provisioned and you no longer need snapshot-based reprovisioning for them, switch them back to an image-based user setting per the migration docs.

4. Putting it together

importSnapshot alone does not provision a Cloud PC. Microsoft only kicks off provisioning when the following line up for a user:

  1. A provisioning policy that defines:Created via POST /deviceManagement/virtualEndpoint/provisioningPolicies β€” see the reference for the full payload.
    • the target image, which actually doesn't matter but is required to set β€” thx MS;
    • network connection β€” this part is really important. For the first migration I'd recommend using the same subnet as the original VM; it can affect the provisioning result. Believe me, you don't want to wait 20 minutes in Provisioning status and then get an error;
    • region;
    • Entra join type (Entra joined or hybrid Entra joined).
  2. A user setting with provisioningSourceType = snapshot, from step 3. Yeah, you can set this only via the API β€” the Intune portal doesn't allow you to specify it.
  3. A group in Entra ID that contains the target user, with both the provisioning policy and the user setting assigned to that group, and the user holding a Windows 365 Enterprise license whose disk size fits the snapshot.
  4. Pray to W365 god for success

Just in case, both the provisioning policy and the user setting expose an assign action that takes a list of group IDs β€” see cloudPcProvisioningPolicy: assign and cloudPcUserSetting: assign. Once the group exists, both objects are assigned to it, and the user inside it has the matching license, the imported VHD is consumed and a Cloud PC is provisioned for that user.

A practical way to model this in code is to keep four identifiers on the migration job record:

  • snapshotId from step 1 (returned via the import result).
  • provisioningPolicyId from POST .../provisioningPolicies.
  • userSettingId from step 3.
  • groupId of the Entra group that holds the licensed user.

5. After provisioning

After a successful migration the imported snapshot is removed.

Closing

The Windows 365 migration API has a small surface: one call to import a VHD, one to check the status, one user setting toggle, and an assignment to a licensed group. The work that turns those four calls into a tool you can run on Friday afternoon is the wrapping around them β€” idempotency, retry policy, audit trail, and user communication. Start with the import and the status check, get them right against a single pilot user, and the rest is plumbing you already know how to write.

Subscribe to Andrei Shchetkin

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe