The Complete Windows 365 Graph API Developer Guide

Managing Windows 365 Cloud PCs at scale demands more than clicking through the Intune portal. If you're automating provisioning policies, building CI/CD pipelines for Cloud PC deployments, or integrating Windows 365 into your infrastructure-as-code workflows, the Microsoft Graph API is your only path forward.

The problem? The official documentation is auto-generated and covers the "what" but rarely the "why" or "watch out for this." After months of working with these APIs in production, I've compiled every undocumented behavior, silent failure mode, and gotcha I've encountered into this guide.

What you'll learn:

  • Authentication and SDK setup (the quick version)
  • Provisioning policy CRUD operations and their hidden requirements
  • The real value: pagination, error handling, rate limiting, and the undocumented behaviors that will break your automation

Prerequisites:

  • Azure AD (EntraID) tenant with Global Admin or Cloud Application Administrator role
  • Windows 365 Enterprise or Business license
  • .NET 6.0+ with C# async/await knowledge
  • Basic familiarity with Microsoft Graph concepts

Part 1: Foundation & Setup

I'll keep this section brief—Microsoft's documentation covers the basics well. I'll point you to the right resources and highlight only Windows 365-specific requirements.

1.1 Project Setup

dotnet new console -n Windows365Manager
cd Windows365Manager
dotnet add package Microsoft.Graph.Beta
dotnet add package Azure.Identity

Critical: Use Microsoft.Graph.Beta, not the v1.0 SDK. Most Windows 365 functionality—creating provisioning policies, managing assignments, performing actions like resize and reprovision—exists only in beta endpoints.

📚 Setup resources:

1.2 App Registration & Permissions

Follow the standard app registration process. The Windows 365-specific requirements:

Required API Permissions (Application type):

PermissionPurpose
CloudPC.Read.AllRead Cloud PC configurations and provisioning status
CloudPC.ReadWrite.AllFull management: create policies, manage assignments, trigger actions
Directory.Read.AllUser and group lookups for assignments

The step everyone forgets: Click "Grant admin consent" after adding permissions. Without this, you'll get cryptic 403 errors with no explanation. (Permission reference)

1.3 Authentication

For automation scenarios, use the client credentials flow:

using Azure.Identity;
using Microsoft.Graph.Beta;

var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var scopes = new[] { "https://graph.microsoft.com/.default" };
var graphClient = new GraphServiceClient(credential, scopes);

Production note: Don't use client secrets in production. Use certificates or federated credentials for workloads running in Azure.

📚 Authentication deep dives:


Part 2: Core Operations

The SDK handles most CRUD operations cleanly. I'll focus on the gotchas rather than duplicating the official API reference.

2.1 Provisioning Policy Operations

📚 Official references:

GOTCHA #1: Azure Network Connection dependency

Creating a policy requires a pre-existing Azure Network Connection. The policy references it by display name in CloudPcGroupDisplayName. If the ANC doesn't exist or the name doesn't match exactly (case-sensitive), you get a generic 400 error with no useful message.

// Always verify ANCs exist before creating policies
var connections = await graphClient.DeviceManagement
    .VirtualEndpoint
    .OnPremisesConnections
    .GetAsync();

// Use the EXACT display name
var policy = new CloudPcProvisioningPolicy
{
    CloudPcGroupDisplayName = connections.Value.First().DisplayName,
    // ... other properties
};

GOTCHA #2: ImageType validation is strict but silent

If you reference a Gallery image but set ImageType = Custom (or vice versa), the API accepts the request but provisioning fails silently later. Always verify image types match.

2.2 Assignment Gotchas

📚 Official references:

GOTCHA #3: $expand doesn't work for assignments

The API does not support $expand on assignments despite what you might expect from other Graph endpoints:

// ❌ This throws an error
var policy = await graphClient.DeviceManagement
    .VirtualEndpoint
    .ProvisioningPolicies["policy-id"]
    .GetAsync(config => config.QueryParameters.Expand = new[] { "assignments" });

You MUST make separate calls:

// ✅ Two-call pattern (the only way)
var policy = await graphClient.DeviceManagement
    .VirtualEndpoint
    .ProvisioningPolicies[policyId]
    .GetAsync();
    
var assignments = await graphClient.DeviceManagement
    .VirtualEndpoint
    .ProvisioningPolicies[policyId]
    .Assignments
    .GetAsync();

GOTCHA #4: ODataType is handled automatically

The SDK automatically sets ODataType in constructors (source). You never need to set it manually:

// ✅ This is all you need
var assignment = new CloudPcProvisioningPolicyAssignment
{
    Target = new CloudPcManagementGroupAssignmentTarget
    {
        GroupId = "your-group-id"
    }
};

GOTCHA #5: Orphaned group assignments break everything

When an Azure AD group is deleted but its assignment still exists on a policy:

  1. Existing Cloud PCs continue working
  2. You cannot add new assignments
  3. You cannot update the policy
  4. The API returns a generic 400 error with no indication the group was deleted

Solution: Validate and clean orphaned assignments before any updates:

public async Task CleanOrphanedAssignments(string policyId)
{
    var assignments = await graphClient.DeviceManagement
        .VirtualEndpoint
        .ProvisioningPolicies[policyId]
        .Assignments
        .GetAsync();
    
    foreach (var assignment in assignments.Value)
    {
        if (assignment.Target is CloudPcManagementGroupAssignmentTarget groupTarget)
        {
            try
            {
                await graphClient.Groups[groupTarget.GroupId].GetAsync();
            }
            catch (ODataError ex) when (ex.ResponseStatusCode == 404)
            {
                Console.WriteLine($"Removing orphaned assignment: {groupTarget.GroupId}");
                await graphClient.DeviceManagement
                    .VirtualEndpoint
                    .ProvisioningPolicies[policyId]
                    .Assignments[assignment.Id]
                    .DeleteAsync();
            }
        }
    }
}

Part 3: Advanced Patterns (The Goldmine)

This is where production automation either succeeds or fails. Every pattern here comes from real issues I've encountered or seen reported by the community.

3.1 Pagination Handling

When you have hundreds of Cloud PCs or policies, pagination becomes critical. The Graph API uses OData-style pagination with @odata.nextLink.

📚 Key resources:

The PageIterator approach (recommended for most cases):

var policies = await graphClient.DeviceManagement
    .VirtualEndpoint
    .ProvisioningPolicies
    .GetAsync(config => config.QueryParameters.Top = 50);

var allPolicies = new List<CloudPcProvisioningPolicy>();

var pageIterator = PageIterator<CloudPcProvisioningPolicy, CloudPcProvisioningPolicyCollectionResponse>
    .CreatePageIterator(graphClient, policies, policy =>
    {
        allPolicies.Add(policy);
        return true; // Continue iterating
    });

await pageIterator.IterateAsync();

Manual pagination (when you need more control):

var allPolicies = new List<CloudPcProvisioningPolicy>();
var response = await graphClient.DeviceManagement
    .VirtualEndpoint
    .ProvisioningPolicies
    .GetAsync(config => config.QueryParameters.Top = 100);

while (response?.Value != null)
{
    allPolicies.AddRange(response.Value);
    
    if (string.IsNullOrEmpty(response.OdataNextLink))
        break;
        
    response = await graphClient.DeviceManagement
        .VirtualEndpoint
        .ProvisioningPolicies
        .WithUrl(response.OdataNextLink)
        .GetAsync();
}

GOTCHA #6: DirectoryPageTokenNotFoundException

When paginating and a retry occurs, don't use the token from the retry for subsequent requests—use the token from the last successful non-retry response. This is documented behavior but catches many developers off guard.

Page size recommendations:

  • Default page size varies by endpoint (often 100)
  • Maximum is typically 999, but some endpoints have lower limits
  • For Cloud PC endpoints, I've found 50-100 to be the sweet spot for throughput vs. reliability

3.2 Error Management

The Graph SDK throws ODataError for API errors. Understanding the error codes is crucial for proper retry logic.

📚 Key resources:

Error code reference for Windows 365:

CodeHTTP StatusMeaningAction
Forbidden403Missing permissions or admin consentCheck app registration, verify consent granted
ResourceNotFound404Policy/Cloud PC doesn't existVerify ID, check if deleted
Conflict409Resource already exists (name collision)Use different name or check for soft-deleted resources
TooManyRequests429Rate limit exceededImplement backoff, respect Retry-After header
ServiceUnavailable503Temporary service issueRetry with exponential backoff
BadRequest400Invalid request bodyCheck required fields, validate references exist

Comprehensive error handling pattern:

public async Task<T?> ExecuteWithRetry<T>(Func<Task<T>> operation, int maxRetries = 3)
{
    int attempt = 0;
    
    while (true)
    {
        try
        {
            return await operation();
        }
        catch (ODataError ex) when (ex.ResponseStatusCode == 429 && attempt < maxRetries)
        {
            attempt++;
            var retryAfter = GetRetryAfterSeconds(ex) ?? Math.Pow(2, attempt);
            Console.WriteLine($"Rate limited. Waiting {retryAfter}s (attempt {attempt}/{maxRetries})");
            await Task.Delay(TimeSpan.FromSeconds(retryAfter));
        }
        catch (ODataError ex) when (ex.ResponseStatusCode == 503 && attempt < maxRetries)
        {
            attempt++;
            var delay = Math.Pow(2, attempt) + Random.Shared.NextDouble();
            Console.WriteLine($"Service unavailable. Waiting {delay:F1}s (attempt {attempt}/{maxRetries})");
            await Task.Delay(TimeSpan.FromSeconds(delay));
        }
        catch (ODataError ex)
        {
            Console.WriteLine($"Graph API error: {ex.Error?.Code} - {ex.Error?.Message}");
            throw;
        }
    }
}

private int? GetRetryAfterSeconds(ODataError ex)
{
    // The SDK exposes retry-after in the response headers
    // Implementation depends on your HTTP client configuration
    return null; // Fall back to exponential backoff
}

GOTCHA #7: Generic 400 errors hide the real problem

Many Windows 365 API errors return a generic BadRequest with minimal details. Common hidden causes:

  • Orphaned group assignments (group deleted from Azure AD)
  • ANC name mismatch (case-sensitive!)
  • Image ID doesn't match ImageType
  • Missing required properties that aren't validated until processing

Debugging strategy: When you get a 400, systematically validate each reference:

  1. Does the ANC exist with that exact name?
  2. Does every assigned group still exist?
  3. Does the image ID match the image type?
  4. Are all required fields populated?

3.3 Rate Limiting Strategies

Graph API throttling is documented in general terms, but Windows 365/Intune endpoints have their own quirks.

📚 Key resources:

GOTCHA #8: Windows 365/Intune endpoints have stricter limits

The Intune/deviceManagement endpoints have tighter constraints than general Graph:

The SDK handles basic retry for you:

The Graph SDK includes a RetryHandler that automatically retries 429 and 503 errors with exponential backoff. Default behavior:

  • Max 10 retries
  • Respects Retry-After header when present
  • Exponential backoff when header is missing

Configuring retry behavior:

var retryOptions = new RetryHandlerOption
{
    MaxRetry = 5,
    ShouldRetry = (delay, attempt, response) => 
    {
        // Custom logic: don't retry 4xx errors other than 429
        if (response.StatusCode >= 400 && response.StatusCode < 500 
            && response.StatusCode != 429)
            return false;
        return true;
    }
};

var policies = await graphClient.DeviceManagement
    .VirtualEndpoint
    .ProvisioningPolicies
    .GetAsync(config => config.Options.Add(retryOptions));

📚 Source: RetryHandlerOption documentation

GOTCHA #9: Rate limits cascade across endpoints

Hitting limits on one endpoint (e.g., /cloudPCs) can affect your quota for related endpoints (e.g., /provisioningPolicies). This is not well documented but observed in practice.

Mitigation: Build in delays between bulk operations, even if you haven't hit limits yet.

3.4 Request Batching

For operations touching multiple resources, JSON batching can dramatically reduce latency by combining up to 20 requests.

📚 Key resources:

Basic batching pattern:

using Microsoft.Graph.Beta;
using Microsoft.Kiota.Abstractions;

var batchRequest = new BatchRequestContentCollection(graphClient);

// Add multiple requests
var policiesRequest = await batchRequest.AddBatchRequestStepAsync(
    graphClient.DeviceManagement.VirtualEndpoint.ProvisioningPolicies.ToGetRequestInformation()
);

var cloudPcsRequest = await batchRequest.AddBatchRequestStepAsync(
    graphClient.DeviceManagement.VirtualEndpoint.CloudPCs.ToGetRequestInformation()
);

// Execute batch
var batchResponse = await graphClient.Batch.PostAsync(batchRequest);

// Parse responses
var policies = await batchResponse
    .GetResponseByIdAsync<CloudPcProvisioningPolicyCollectionResponse>(policiesRequest);
var cloudPcs = await batchResponse
    .GetResponseByIdAsync<CloudPcCollectionResponse>(cloudPcsRequest);

GOTCHA #10: Batch limit is 20, but SDK handles overflow

The SDK automatically splits batches larger than 20 into multiple HTTP requests. However, each request in the batch is still evaluated against throttling limits individually—batching doesn't bypass rate limits.

GOTCHA #11: Throttled batch requests aren't auto-retried

When a request within a batch gets a 429, it's not automatically retried even though the SDK normally retries throttled requests. You must handle batch failures manually:

var batchResponse = await graphClient.Batch.PostAsync(batchRequest);

// Check each response status
foreach (var requestId in new[] { policiesRequest, cloudPcsRequest })
{
    var statusCode = await batchResponse.GetResponseStatusCodeByIdAsync(requestId);
    if (statusCode == 429)
    {
        // Manually retry this specific request
        Console.WriteLine($"Request {requestId} was throttled, retrying...");
        await Task.Delay(TimeSpan.FromSeconds(10));
        // Re-execute individual request
    }
}

Appendices

A. Quick Reference

All Windows 365 endpoints are under:

/deviceManagement/virtualEndpoint/
    ├── provisioningPolicies
    ├── cloudPCs
    ├── onPremisesConnections
    ├── deviceImages
    ├── userSettings
    └── reports

Required permissions matrix:

OperationMinimum Permission
List/read policiesCloudPC.Read.All
Create/update/delete policiesCloudPC.ReadWrite.All
Manage assignmentsCloudPC.ReadWrite.All + Directory.Read.All
Reprovision/resize Cloud PCsCloudPC.ReadWrite.All

B. Additional Resources

Official documentation:

Community resources:

SDK repositories:

C. Summary of Gotchas

  1. Azure Network Connection dependency - ANC must exist before creating policies, name matching is case-sensitive
  2. ImageType validation is silent - Mismatched image types cause provisioning failures, not API errors
  3. $expand doesn't work for assignments - Always use separate calls
  4. ODataType is automatic - SDK sets it in constructors, don't set manually
  5. Orphaned group assignments break updates - Clean up before modifying policies
  6. DirectoryPageTokenNotFoundException - Don't use retry tokens for pagination
  7. Generic 400 errors - Systematically validate all references when debugging
  8. Stricter Intune limits - Windows 365 endpoints throttle more aggressively
  9. Rate limit cascading - Throttling on one endpoint affects related endpoints
  10. Batch limit is 20 - SDK handles overflow but each request counts against limits
  11. Throttled batch requests aren't retried - Handle 429s in batches manually

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