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.IdentityCritical: 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):
| Permission | Purpose |
|---|---|
CloudPC.Read.All | Read Cloud PC configurations and provisioning status |
CloudPC.ReadWrite.All | Full management: create policies, manage assignments, trigger actions |
Directory.Read.All | User 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:
- Existing Cloud PCs continue working
- You cannot add new assignments
- You cannot update the policy
- 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:
| Code | HTTP Status | Meaning | Action |
|---|---|---|---|
Forbidden | 403 | Missing permissions or admin consent | Check app registration, verify consent granted |
ResourceNotFound | 404 | Policy/Cloud PC doesn't exist | Verify ID, check if deleted |
Conflict | 409 | Resource already exists (name collision) | Use different name or check for soft-deleted resources |
TooManyRequests | 429 | Rate limit exceeded | Implement backoff, respect Retry-After header |
ServiceUnavailable | 503 | Temporary service issue | Retry with exponential backoff |
BadRequest | 400 | Invalid request body | Check 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:
- Does the ANC exist with that exact name?
- Does every assigned group still exist?
- Does the image ID match the image type?
- 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:
- Microsoft Graph throttling guidance
- Service-specific throttling limits
- Intune rate limiting deep dive (community)
- RetryHandler middleware design
GOTCHA #8: Windows 365/Intune endpoints have stricter limits
The Intune/deviceManagement endpoints have tighter constraints than general Graph:
- Lower requests per minute
- Stricter per-user limits
- Sometimes missing Retry-After headers (requiring exponential backoff guessing)
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-Afterheader 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
└── reportsRequired permissions matrix:
| Operation | Minimum Permission |
|---|---|
| List/read policies | CloudPC.Read.All |
| Create/update/delete policies | CloudPC.ReadWrite.All |
| Manage assignments | CloudPC.ReadWrite.All + Directory.Read.All |
| Reprovision/resize Cloud PCs | CloudPC.ReadWrite.All |
B. Additional Resources
Official documentation:
- Windows 365 Graph API overview
- Cloud PC resource reference
- Graph Explorer - test API calls interactively
Community resources:
- Windows 365 Cloud PC automation - PowerShell examples
- Deep dive into Windows 365 APIs - comprehensive walkthrough
- Graph API rate limiting in Intune - throttling details
SDK repositories:
- msgraph-sdk-dotnet - .NET SDK
- msgraph-sdk-dotnet-core - Core library (handlers, middleware)
- msgraph-sdk-design - SDK design documents
C. Summary of Gotchas
- Azure Network Connection dependency - ANC must exist before creating policies, name matching is case-sensitive
- ImageType validation is silent - Mismatched image types cause provisioning failures, not API errors
- $expand doesn't work for assignments - Always use separate calls
- ODataType is automatic - SDK sets it in constructors, don't set manually
- Orphaned group assignments break updates - Clean up before modifying policies
- DirectoryPageTokenNotFoundException - Don't use retry tokens for pagination
- Generic 400 errors - Systematically validate all references when debugging
- Stricter Intune limits - Windows 365 endpoints throttle more aggressively
- Rate limit cascading - Throttling on one endpoint affects related endpoints
- Batch limit is 20 - SDK handles overflow but each request counts against limits
- Throttled batch requests aren't retried - Handle 429s in batches manually