Skip to main content

Mastering the Dataverse Web API: CRUD, OData Query Syntax, Optimistic Concurrency, and Multipart Batching


1. Conceptual Foundation

The Microsoft Dataverse Web API provides a development experience that can be used across a wide variety of programming languages, platforms, and devices. It is fully compliant with the OData (Open Data Protocol) v4 specification, an OASIS standard built on HTTP, JSON, and RESTful design principles.

To build high-performance, enterprise-grade integrations with Dataverse, developers must understand how the Web API maps to the platform's underlying architecture, how its data models are structured, and how HTTP operations translate into transactional database actions.

The Web API and the Dataverse Event Execution Pipeline

The Dataverse Web API is not a separate database engine; it is a RESTful wrapper around the core Dataverse Organization Service (the IOrganizationService interface). Every HTTP request sent to the Web API is intercepted by the IIS web server, authenticated, parsed, and translated into an equivalent OrganizationRequest message.

[ HTTP Client ]

│ (HTTP POST /api/data/v9.2/accounts)

[ Dataverse Web API Endpoint ]

│ (Translates to CreateRequest)

[ Organization Service (IOrganizationService) ]

├─► Pre-Validation (Stage 10) ──► Out-of-transaction plugins
├─► Pre-Operation (Stage 20) ──► In-transaction plugins
├─► Main Operation (Stage 30) ──► Database Write (SQL Server)
└─► Post-Operation (Stage 40) ──► In-transaction & Async plugins

When you perform a CRUD operation via the Web API, it triggers the exact same Event Execution Pipeline as a call made via the SDK for .NET. This means:

  • An HTTP POST request to /accounts translates to a CreateRequest message, triggering the Create message pipeline for the account table.
  • An HTTP PATCH request to /accounts(GUID) translates to an UpdateRequest message, triggering the Update message pipeline.
  • An HTTP DELETE request to /accounts(GUID) translates to a DeleteRequest message, triggering the Delete message pipeline.
  • An HTTP GET request to /accounts(GUID) translates to a RetrieveRequest message, while a query to /accounts translates to a RetrieveMultipleRequest message.

Because these requests flow through the Event Execution Pipeline, all registered plug-ins, power automate flows, business rules, and synchronous workflows execute exactly as they would if triggered by a model-driven app or a .NET SDK client.

The Entity Data Model (EDM) and CSDL Metadata

The Web API exposes the Dataverse data model through the Entity Data Model (EDM). The structure of this model can be inspected by downloading the CSDL (Common Schema Definition Language) metadata document, which is dynamically generated for every environment at the following endpoint:

GET https://[Organization URI]/api/data/v9.2/$metadata

The CSDL document defines:

  • EntityTypes: Represent Dataverse tables (e.g., account, contact). Each EntityType has a set of properties (columns) and navigation properties (relationships).
  • ComplexTypes: Represent structured data that does not have its own identity but is used as a property of an EntityType (e.g., the WhoAmIResponse returned by the WhoAmI function).
  • Actions: Operations that can have side effects (such as modifying data) and are invoked using HTTP POST. Actions can be bound to a specific entity record or collection, or unbound (global).
  • Functions: Operations that must not have side effects and are used to retrieve data. They are invoked using HTTP GET. Functions can also be bound or unbound.

HTTP Methods and CRUD Mapping

The Web API maps standard HTTP verbs to Dataverse data operations:

HTTP MethodTarget URIDataverse OperationSuccess Status
POST/[EntitySet]Create: Creates a new record.204 No Content (or 201 Created with representation)
GET/[EntitySet]([GUID])Retrieve: Retrieves a single record by its primary key.200 OK
GET/[EntitySet]Retrieve Multiple: Queries a collection of records.200 OK
PATCH/[EntitySet]([GUID])Update: Updates specified columns of an existing record.204 No Content (or 200 OK with representation)
PUT/[EntitySet]([GUID])/[Property]Update Property: Sets or updates a single property value.204 No Content
DELETE/[EntitySet]([GUID])Delete: Deletes an existing record.204 No Content
DELETE/[EntitySet]([GUID])/[Property]Delete Property: Clears a single property value (sets to null).204 No Content

Deep Inserts and Relationship Binding

Dataverse supports Deep Inserts, allowing you to create a record and its related records in a single HTTP POST request. This is highly efficient as it reduces network round-trips and executes the entire creation tree within a single database transaction.

To link a new record to an existing record during creation, you use the @odata.bind annotation on the single-valued navigation property. This is known as Relationship Binding. For example, when creating a contact, you can bind it to an existing account:

{
"firstname": "John",
"lastname": "Doe",
"parentcustomerid_account@odata.bind": "accounts(00000000-0000-0000-0000-000000000001)"
}

Upsert Operations and Alternate Keys

An Upsert operation (PATCH without an If-Match or If-None-Match header) updates a record if it exists, or creates it if it does not.

In data integration scenarios, the external system often does not know the Dataverse GUID. Dataverse solves this by allowing you to define Alternate Keys (composed of one or more business columns, such as an employee ID or email address). You can target records using these alternate keys directly in the URI:

PATCH https://[Organization URI]/api/data/v9.2/contacts(employeeid='EMP-10024')

If the contact with employeeid equal to 'EMP-10024' exists, it is updated. If not, a new contact is created with that alternate key value populated.

Optimistic Concurrency and ETags

To prevent "lost updates" in multi-user or highly concurrent environments, Dataverse implements Optimistic Concurrency Control (OCC) using ETags (Entity Tags). Every record retrieved from Dataverse includes an @odata.etag property containing a weak version identifier (e.g., W/"5012345").

When performing an update (PATCH) or delete (DELETE), you pass this ETag value in the If-Match HTTP request header. Dataverse compares the ETag in the header with the current version of the record in the database:

  • If they match, the operation proceeds, and the record's version is incremented.
  • If they do not match (meaning another process updated the record in the interim), the operation is aborted, and Dataverse returns an HTTP status code 412 Precondition Failed.

Multipart Batching ($batch)

The $batch endpoint allows you to group up to 1,000 individual Web API operations into a single HTTP POST request. This is critical for bypassing network latency and staying within Dataverse service protection limits.

A batch request is sent as a multipart/mixed payload. It can contain:

  1. Query Operations: Individual GET requests executed sequentially.
  2. Change Sets: Groups of write operations (POST, PATCH, DELETE) that must be executed atomically. If any operation within a change set fails, the entire change set is rolled back as a single database transaction.

2. Architecture & Decision Matrix

When designing integrations or client-side extensions for Microsoft Power Platform, developers must choose the appropriate execution context and toolset. The table below compares the five primary architectural approaches for executing Dataverse Web API and data operations.

Architectural Comparison

Comparison DimensionPower Automate (Dataverse Connector)Azure Functions (C# / Node.js + Web API)Dataverse Plug-ins (C# SDK / Org Service)Power Apps Component Framework (PCF)Client-Side JavaScript (Model-Driven Web Resources)
ComplexityLow (No-code / Low-code designer)Medium (Standard cloud development)High (Requires deep platform knowledge)High (TypeScript, React, PCF CLI)Medium (JavaScript, Client API)
ScalabilityMedium (Subject to API call limits & throttling)High (Elastic scaling, serverless execution)High (Executes directly on Dataverse servers)Client-dependent (Runs in user's browser)Client-dependent (Runs in user's browser)
Execution ContextAsynchronous (or synchronous via instant flows)External (Server-to-Server S2S authentication)Synchronous or Asynchronous (In-pipeline)Client-side (Runs within the context of the app)Client-side (Runs within the context of the form)
Offline SupportNoNoNoYes (via Canvas/Model-driven offline profiles)Yes (via Client API offline capabilities)
Licensing CostPower Automate / Power Apps per-user/per-app licensesAzure consumption costs + S2S Application User (No Dataverse license)Included in base Dataverse capacityIncluded in Power Apps licenseIncluded in Power Apps license
PL-400 Exam RelevanceHigh (Focus on triggers, actions, and limits)High (Focus on S2S, MSAL, and Web API integration)Critical (Focus on pipeline stages, transactions, and SDK)Critical (Focus on context.webAPI and manifest)Critical (Focus on Xrm.WebApi, namespaces, and events)

Decision Matrix: When to Use Which Approach

Use Power Automate when:

  • The integration is orchestrating business processes across multiple systems with pre-built connectors.
  • The logic is non-time-critical (asynchronous execution is acceptable).
  • Rapid prototyping and low maintenance overhead are prioritized over raw execution speed.

Use Azure Functions when:

  • You need to execute heavy data processing, long-running operations (beyond the 2-minute plug-in limit), or complex computations.
  • You must integrate with external systems using proprietary libraries or protocols not supported by Dataverse plug-ins.
  • You want to build a custom API gateway or webhook receiver that authenticates securely via Microsoft Entra ID.

Use Dataverse Plug-ins when:

  • The business logic must execute synchronously within the database transaction (e.g., real-time validation, auto-numbering, or preventing a record from being saved based on complex criteria).
  • The operation must run with maximum performance and zero network latency between the compute layer and the database.
  • You need to intercept and modify data before it is committed to SQL Server.

Use Power Apps Component Framework (PCF) when:

  • You need to build reusable, rich user interface controls (e.g., custom sliders, maps, or interactive grids) for Canvas or Model-Driven apps.
  • The control must interact directly with Dataverse data using the context-provided context.webAPI helper, respecting the user's security context and offline status.

Use Client-Side JavaScript (Web Resources) when:

  • You need to apply dynamic form behavior, show/hide fields, or perform lightweight data validation directly on a model-driven form.
  • You need to query related data on-demand when a user changes a field value on a form using Xrm.WebApi.retrieveMultipleRecords.

3. Step-by-Step Implementation Guide

This guide walks through setting up a secure, production-grade C# .NET 8 console application that authenticates against Microsoft Dataverse using Microsoft Authentication Library (MSAL.NET) and executes CRUD, OData queries, optimistic concurrency, and batch operations via the Web API.

Step 1: Register the Application in Microsoft Entra ID

To authenticate against the Dataverse Web API from an external application, you must register an application in Microsoft Entra ID and configure Server-to-Server (S2S) authentication.

  1. Navigate to the Azure Portal and sign in with your administrator credentials.
  2. Search for and select Microsoft Entra ID from the top search bar.
  3. In the left navigation pane, select App registrations, then click New registration.
  4. Configure the registration:
    • Name: Enter a descriptive name (e.g., Dataverse Web API Integration).
    • Supported account types: Select Accounts in this organizational directory only (Single tenant).
    • Redirect URI: Leave this blank.
  5. Click Register. Note the Application (client) ID and Directory (tenant) ID displayed on the Overview page.
  6. In the left menu, select Certificates & secrets, then click New client secret.
    • Description: Enter a description (e.g., S2S Integration Secret).
    • Expires: Select the recommended duration (e.g., 180 days).
    • Click Add.
    • CRITICAL: Copy the secret's Value immediately. It will be hidden permanently once you navigate away from this page.
  7. In the left menu, select API permissions, then click Add a permission.
    • Select the APIs my organization uses tab.
    • Search for and select Dynamics CRM.
    • Check the Delegated permissions box, expand user_impersonation, and check it.
    • Click Add permissions.
    • Click Grant admin consent for [Your Tenant Name] and select Yes to confirm.

Step 2: Create the Application User in Dataverse

The Entra ID app registration must be mapped to a special Application User record in your Dataverse environment to enforce security roles and privileges.

  1. Navigate to the Power Platform Admin Center.
  2. In the left navigation pane, select Environments, then click on the name of the target environment.
  3. On the environment details page, under the Access card, click See all under Users or navigate to Settings -> Users + permissions -> Application users.
  4. Click New app user.
  5. Click Add an app. Select your registered Entra ID application (Dataverse Web API Integration) from the list, then click Add.
  6. Select the Business unit to which this user belongs (typically the root business unit).
  7. Click the edit pencil icon next to Security roles.
  8. Select the appropriate security roles required for the integration (e.g., a custom integration role or System Administrator for development environments).
  9. Click Save, then click Create.

Step 3: Set Up the .NET 8 C# Project

Create a new C# console application and install the required dependencies.

  1. Open your terminal or command prompt.
  2. Execute the following commands to create the project and install the Microsoft Authentication Library (MSAL) and JSON serialization packages:
dotnet new console -n DataverseWebAPIDemo -f net8.0
cd DataverseWebAPIDemo
dotnet add package Microsoft.Identity.Client
dotnet add package System.Text.Json
  1. Open the project folder in your preferred IDE (e.g., Visual Studio or Visual Studio Code).

4. Complete Code Reference

The following is a complete, production-grade, compilable C# implementation of a Dataverse Web API client. It handles OAuth 2.0 authentication, CRUD operations, OData query syntax, optimistic concurrency, and multipart batching.

Configuration File: appsettings.json

Create a file named appsettings.json in your project root directory. Ensure its properties are set to "Copy to Output Directory: Copy if newer".

{
"DataverseConfig": {
"BaseUri": "https://org8f1a2b3c.api.crm.dynamics.com/",
"ClientId": "00000000-0000-0000-0000-000000000000",
"ClientSecret": "Your_Entra_App_Client_Secret_Here",
"TenantId": "00000000-0000-0000-0000-000000000000"
}
}

Implementation: Program.cs

/*
* Title: Dataverse Web API CRUD and OData Query Syntax Engine
* Description: This production-grade C# client demonstrates advanced integration patterns
* with the Microsoft Dataverse Web API (v9.2) using MSAL.NET.
* It implements secure S2S token acquisition, CRUD, alternate keys,
* optimistic concurrency via ETags, and transactional multipart batching.
*/

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Identity.Client;

namespace DataverseWebAPIDemo
{
public class Program
{
public static async Task Main(string[] args)
{
// In production, load these securely from Azure Key Vault or Environment Variables
string baseUri = "https://org8f1a2b3c.api.crm.dynamics.com/"; // Must end with '/'
string clientId = "00000000-0000-0000-0000-000000000000";
string clientSecret = "Your_Entra_App_Client_Secret_Here";
string tenantId = "00000000-0000-0000-0000-000000000000";

var client = new DataverseWebApiClient(baseUri, clientId, clientSecret, tenantId);

try
{
Console.WriteLine("=== Starting Dataverse Web API Operations ===");

// 1. Create an Account (Basic Create)
Console.WriteLine("\n--- Creating Account ---");
var accountData = new Dictionary<string, object>
{
{ "name", "Contoso Enterprise Solutions" },
{ "revenue", 15000000.00 },
{ "numberofemployees", 250 }
};
string accountUri = await client.CreateRecordAsync("accounts", accountData);
Guid accountId = ExtractGuidFromUri(accountUri);
Console.WriteLine($"Account created successfully. URI: {accountUri} (ID: {accountId})");

// 2. Retrieve Account with Specific Columns ($select)
Console.WriteLine("\n--- Retrieving Account ---");
string accountJson = await client.RetrieveRecordAsync("accounts", accountId, "$select=name,revenue,numberofemployees");
Console.WriteLine($"Retrieved Payload: {accountJson}");

// Parse ETag for Optimistic Concurrency
using var doc = JsonDocument.Parse(accountJson);
string etag = doc.RootElement.GetProperty("@odata.etag").GetString();
Console.WriteLine($"Current ETag: {etag}");

// 3. Update Account with Optimistic Concurrency (If-Match)
Console.WriteLine("\n--- Updating Account with ETag Verification ---");
var updateData = new Dictionary<string, object>
{
{ "revenue", 18500000.00 }
};

// This should succeed because we pass the correct ETag
bool updateSuccess = await client.UpdateRecordAsync("accounts", accountId, updateData, etag);
Console.WriteLine($"Update with correct ETag status: {updateSuccess}");

// Attempt update with stale ETag (Should fail with 412 Precondition Failed)
Console.WriteLine("\n--- Attempting Update with Stale ETag (Expected to Fail) ---");
try
{
await client.UpdateRecordAsync("accounts", accountId, updateData, "W/\"stale-version-12345\"");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
{
Console.WriteLine("Success: Update blocked as expected due to ETag mismatch (HTTP 412).");
}

// 4. Deep Insert: Create Account and Related Contact in One Transaction
Console.WriteLine("\n--- Executing Deep Insert ---");
var deepInsertData = new Dictionary<string, object>
{
{ "name", "Fabrikam Industries Ltd" },
{ "account_primary_contact", new Dictionary<string, object>
{
{ "firstname", "Jane" },
{ "lastname", "Smith" },
{ "emailaddress1", "jane.smith@fabrikam.com" }
}
}
};
string deepInsertUri = await client.CreateRecordAsync("accounts", deepInsertData);
Console.WriteLine($"Deep Insert completed. Parent Account URI: {deepInsertUri}");

// 5. Query Dataverse using OData Syntax ($filter, $expand, $orderby, $top)
Console.WriteLine("\n--- Querying Accounts with OData Syntax ---");
string queryOptions = "$select=name,revenue&$filter=revenue gt 10000000&$orderby=revenue desc&$top=3";
string queryResult = await client.QueryRecordsAsync("accounts", queryOptions);
Console.WriteLine($"Query Results:\n{queryResult}");

// 6. Execute Transactional Multipart Batch Request ($batch)
Console.WriteLine("\n--- Executing Transactional Batch Request ($batch) ---");
string batchPayload = client.BuildSampleBatchPayload(accountId);
string batchResponse = await client.ExecuteBatchAsync(batchPayload);
Console.WriteLine("Batch executed successfully. Response payload received.");

// 7. Clean Up (Delete Created Records)
Console.WriteLine("\n--- Cleaning Up Records ---");
await client.DeleteRecordAsync("accounts", accountId);
Console.WriteLine($"Deleted Account: {accountId}");

Guid fabrikamId = ExtractGuidFromUri(deepInsertUri);
await client.DeleteRecordAsync("accounts", fabrikamId);
Console.WriteLine($"Deleted Account: {fabrikamId}");

Console.WriteLine("\n=== All Operations Completed Successfully ===");
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\nExecution Failed: {ex.Message}");
if (ex.InnerException != null)
{
Console.WriteLine($"Inner Exception: {ex.InnerException.Message}");
}
Console.ResetColor();
}
}

private static Guid ExtractGuidFromUri(string uri)
{
int openParenthesis = uri.IndexOf('(');
int closeParenthesis = uri.IndexOf(')');
if (openParenthesis == -1 || closeParenthesis == -1)
{
throw new ArgumentException("Invalid Dataverse OData URI format.");
}
string guidStr = uri.Substring(openParenthesis + 1, closeParenthesis - openParenthesis - 1);
return Guid.Parse(guidStr);
}
}

/// <summary>
/// High-performance, thread-safe HTTP client wrapper for Microsoft Dataverse Web API.
/// </summary>
public class DataverseWebApiClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly IConfidentialClientApplication _msalClient;
private readonly string _baseUri;
private readonly string[] _scopes;
private AuthenticationResult _authResult;
private readonly object _tokenLock = new object();

public DataverseWebApiClient(string baseUri, string clientId, string clientSecret, string tenantId)
{
_baseUri = baseUri.EndsWith("/") ? baseUri : baseUri + "/";
_scopes = new[] { _baseUri + ".default" };

// Initialize MSAL Confidential Client for S2S Authentication
_msalClient = ConfidentialClientApplicationBuilder.Create(clientId)
.WithClientSecret(clientSecret)
.WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}"))
.Build();

// Configure HttpClient Handler to optimize connection pooling
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};

_httpClient = new HttpClient(handler)
{
BaseAddress = new Uri(_baseUri + "api/data/v9.2/")
};

// Apply default OData headers
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_httpClient.DefaultRequestHeaders.Add("OData-MaxVersion", "4.0");
_httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0");
}

/// <summary>
/// Ensures a valid OAuth 2.0 Access Token is present and applied to the Authorization header.
/// Implements thread-safe token caching and automatic renewal.
/// </summary>
private async Task EnsureAccessTokenAsync()
{
bool acquireNewToken = false;

lock (_tokenLock)
{
if (_authResult == null || _authResult.ExpiresOn < DateTimeOffset.UtcNow.AddMinutes(5))
{
acquireNewToken = true;
}
}

if (acquireNewToken)
{
try
{
// Acquire token using Client Credentials Flow (S2S)
_authResult = await _msalClient.AcquireTokenForClient(_scopes)
.ExecuteAsync();

_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _authResult.AccessToken);
}
catch (MsalServiceException ex)
{
throw new InvalidOperationException("Failed to acquire access token from Microsoft Entra ID.", ex);
}
}
}

/// <summary>
/// Executes an HTTP POST request to create a record in Dataverse.
/// </summary>
public async Task<string> CreateRecordAsync(string entitySet, Dictionary<string, object> data)
{
await EnsureAccessTokenAsync();

string jsonPayload = JsonSerializer.Serialize(data);
using var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");

HttpResponseMessage response = await _httpClient.PostAsync(entitySet, content);

if (response.IsSuccessStatusCode)
{
// Dataverse returns 204 No Content on success, with the URI in the OData-EntityId header
if (response.Headers.Contains("OData-EntityId"))
{
return response.Headers.GetValues("OData-EntityId").GetEnumerator().Current;
}
throw new InvalidOperationException("Record created, but server did not return OData-EntityId header.");
}

string errorContent = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Create failed. Status: {response.StatusCode}. Details: {errorContent}", null, response.StatusCode);
}

/// <summary>
/// Retrieves a single record by its GUID, applying optional OData system query options.
/// </summary>
public async Task<string> RetrieveRecordAsync(string entitySet, Guid id, string queryOptions = null)
{
await EnsureAccessTokenAsync();

string requestUri = $"{entitySet}({id})";
if (!string.IsNullOrEmpty(queryOptions))
{
requestUri += $"?{queryOptions}";
}

HttpResponseMessage response = await _httpClient.GetAsync(requestUri);

if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}

string errorContent = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Retrieve failed. Status: {response.StatusCode}. Details: {errorContent}", null, response.StatusCode);
}

/// <summary>
/// Updates a record in Dataverse. Supports optimistic concurrency via ETags.
/// </summary>
public async Task<bool> UpdateRecordAsync(string entitySet, Guid id, Dictionary<string, object> data, string etag = null)
{
await EnsureAccessTokenAsync();

string jsonPayload = JsonSerializer.Serialize(data);
using var request = new HttpRequestMessage(HttpMethod.Patch, $"{entitySet}({id})")
{
Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json")
};

if (!string.IsNullOrEmpty(etag))
{
// Apply optimistic concurrency header
request.Headers.Add("If-Match", etag);
}
else
{
// Prevent accidental upsert if ETag is not provided
request.Headers.Add("If-Match", "*");
}

HttpResponseMessage response = await _httpClient.SendAsync(request);

if (response.StatusCode == HttpStatusCode.NoContent)
{
return true;
}

string errorContent = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Update failed. Status: {response.StatusCode}. Details: {errorContent}", null, response.StatusCode);
}

/// <summary>
/// Deletes a record from Dataverse by its GUID.
/// </summary>
public async Task DeleteRecordAsync(string entitySet, Guid id)
{
await EnsureAccessTokenAsync();

HttpResponseMessage response = await _httpClient.DeleteAsync($"{entitySet}({id})");

if (!response.IsSuccessStatusCode)
{
string errorContent = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Delete failed. Status: {response.StatusCode}. Details: {errorContent}", null, response.StatusCode);
}
}

/// <summary>
/// Queries an entity set using OData query options.
/// </summary>
public async Task<string> QueryRecordsAsync(string entitySet, string queryOptions)
{
await EnsureAccessTokenAsync();

string requestUri = $"{entitySet}?{queryOptions}";
HttpResponseMessage response = await _httpClient.GetAsync(requestUri);

if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}

string errorContent = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Query failed. Status: {response.StatusCode}. Details: {errorContent}", null, response.StatusCode);
}

/// <summary>
/// Executes a multipart batch request ($batch) containing multiple operations.
/// </summary>
public async Task<string> ExecuteBatchAsync(string multipartPayload)
{
await EnsureAccessTokenAsync();

using var request = new HttpRequestMessage(HttpMethod.Post, "$batch")
{
Content = new StringContent(multipartPayload, Encoding.UTF8)
};

// Set the Content-Type header with the exact boundary used in the payload
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/mixed; boundary=batch_developer_batch_id_12345");

HttpResponseMessage response = await _httpClient.SendAsync(request);

if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}

string errorContent = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Batch execution failed. Status: {response.StatusCode}. Details: {errorContent}", null, response.StatusCode);
}

/// <summary>
/// Constructs a raw multipart/mixed batch payload containing a single transactional Change Set.
/// Note: All line endings in the payload MUST be CRLF (\r\n).
/// </summary>
public string BuildSampleBatchPayload(Guid existingAccountId)
{
var sb = new StringBuilder();

// Start Batch
sb.Append("--batch_developer_batch_id_12345\r\n");
// Start Change Set (Atomic Transaction)
sb.Append("Content-Type: multipart/mixed; boundary=changeset_developer_changeset_id_12345\r\n\r\n");

// Operation 1: Create a Task within the Change Set
sb.Append("--changeset_developer_changeset_id_12345\r\n");
sb.Append("Content-Type: application/http\r\n");
sb.Append("Content-Transfer-Encoding: binary\r\n");
sb.Append("Content-ID: 1\r\n\r\n"); // Content-ID allows referencing this record in subsequent operations
sb.Append("POST tasks HTTP/1.1\r\n");
sb.Append("Content-Type: application/json; type=entry\r\n\r\n");
sb.Append($"{{\"subject\":\"Follow up on contract renewal\",\"description\":\"Automated task created via Web API Batch Change Set.\"}}\r\n");

// Operation 2: Update the existing Account to link the Task created in Operation 1
sb.Append("--changeset_developer_changeset_id_12345\r\n");
sb.Append("Content-Type: application/http\r\n");
sb.Append("Content-Transfer-Encoding: binary\r\n\r\n");
sb.Append($"PATCH accounts({existingAccountId}) HTTP/1.1\r\n");
sb.Append("Content-Type: application/json; type=entry\r\n\r\n");
// We use the Content-ID reference "$1" to bind the newly created task to the account's regarding object
sb.Append($"{{\"description\":\"Account updated via batch transaction.\",\"LastUsedRealtimeWorkflowId@odata.bind\":\"tasks($1)\"}}\r\n");

// End Change Set
sb.Append("--changeset_developer_changeset_id_12345--\r\n");
// End Batch
sb.Append("--batch_developer_batch_id_12345--\r\n");

return sb.ToString();
}

public void Dispose()
{
_httpClient?.Dispose();
}
}
}

5. Configuration & Environment Setup

To deploy and run this integration securely across environments (Development, Test, Production), you must establish a robust configuration schema and secure your credentials.

Environment Variables Schema

In enterprise deployments, credentials must never be hardcoded in source code or stored in plain text configuration files. Use the following environment variable schema:

# Dataverse Environment Configuration
DATAVERSE_BASE_URI="https://yourorg.api.crm.dynamics.com/"
DATAVERSE_TENANT_ID="your-entra-tenant-guid"
DATAVERSE_CLIENT_ID="your-entra-app-registration-client-guid"

# Secret Management (Inject via Azure Key Vault or CI/CD Pipeline)
DATAVERSE_CLIENT_SECRET="your-entra-app-registration-client-secret"

Solution Publisher Prefix Rules

When creating custom tables, columns, or alternate keys that will be targeted by the Web API, you must adhere to the publisher prefix rules defined in your Dataverse solution.

  • Rule: Never use the default prefix (new_) in production. Always create a custom Solution Publisher with a unique prefix (e.g., corp_).
  • Impact on Web API: The Web API is case-sensitive and uses the schema names of tables and columns. If your custom table is named corp_integrationlog, the corresponding entity set targeted by the Web API will be corp_integrationlogs (pluralized).

Azure Key Vault and Managed Identity (Bicep Deployment)

To run the integration client securely inside Azure (e.g., within an Azure Function or Azure App Service), use a System-Assigned Managed Identity to authenticate against Azure Key Vault, retrieving the Dataverse Client Secret at runtime without storing secrets in the application configuration.

The following Bicep template provisions an Azure Key Vault, stores the Dataverse Client Secret, and configures access policies for the application's Managed Identity.

param location string = resourceGroup().location
param keyVaultName string = 'kv-dataverse-integration'
param secretName string = 'dataverse-client-secret'
@secure()
param secretValue string
param webAppName string = 'app-dataverse-api'

// 1. Provision Azure Key Vault
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: keyVaultName
location: location
properties: {
sku: {
family: 'A'
name: 'standard'
}
tenantId: subscription().tenantId
enableSoftDelete: true
softDeleteRetentionInDays: 90
enabledForTemplateDeployment: true
accessPolicies: [] // Configured below via access policy resource
}
}

// 2. Store Dataverse Client Secret in Key Vault
resource vaultSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
parent: keyVault
name: secretName
properties: {
value: secretValue
}
}

// 3. Reference Existing Web App (Integration Host)
resource webApp 'Microsoft.Web/sites@2023-12-01' existing = {
name: webAppName
}

// 4. Grant Web App Managed Identity Read Access to Key Vault Secrets
resource kvAccessPolicy 'Microsoft.KeyVault/vaults/accessPolicies@2023-07-01' = {
parent: keyVault
name: 'add'
properties: {
accessPolicies: [
{
tenantId: webApp.identity.tenantId
objectId: webApp.identity.principalId
permissions: {
secrets: [
'get'
'list'
]
}
}
]
}
}

6. Security & Permission Matrix

To ensure the principle of least privilege, the integration's security principal (the Application User) must be configured with the exact permissions required to execute Web API operations.

Security & Permission Matrix

PrincipalPermission / PrivilegeScopeReason
Microsoft Entra ID App RegistrationDynamics CRM / user_impersonationDelegated / OrganizationAllows the application to access the Dataverse Web API on behalf of the application user.
Dataverse Application UserRead (prvReadAccount, prvReadContact, prvReadTask)OrganizationRequired to execute HTTP GET requests and query records via OData syntax.
Dataverse Application UserWrite (prvWriteAccount, prvWriteContact)OrganizationRequired to execute HTTP PATCH requests to update records and bind relationships.
Dataverse Application UserCreate (prvCreateAccount, prvCreateContact, prvCreateTask)OrganizationRequired to execute HTTP POST requests, deep inserts, and upserts.
Dataverse Application UserDelete (prvDeleteAccount, prvDeleteContact, prvDeleteTask)OrganizationRequired to execute HTTP DELETE requests for records or single property values.
Dataverse Application UserAct on Behalf of Another User (prvActOnBehalfOfAnotherUser)OrganizationRequired only if the integration uses impersonation headers (CallerObjectId or MSCRMCallerID).
Dataverse Application UserBypass Custom Plugins (prvBypassCustomPlugins)OrganizationRequired only if the integration uses the MSCRM.BypassCustomPluginExecution header.
Field Security ProfileRead / Update on Secured ColumnsColumn-levelRequired if any targeted columns have Field-Level Security (FLS) enabled.

7. Pipeline Execution Internals

Understanding how Web API requests interact with the Dataverse Event Execution Pipeline is critical for writing efficient, conflict-free integrations.

Pipeline Stages and Transaction Boundaries

When a Web API request is executed, it initiates a database transaction that spans specific pipeline stages:

[ Web API Request Received ]


┌────────────────────────────────────────┐
│ Stage 10: Pre-Validation │ <--- Out of Transaction
└────────────────────────────────────────┘

▼ (Database Transaction Starts)
┌────────────────────────────────────────┐
│ Stage 20: Pre-Operation │ <--- In Transaction
└────────────────────────────────────────┘


┌────────────────────────────────────────┐
│ Stage 30: Main Operation (DB Write) │ <--- In Transaction
└────────────────────────────────────────┘


┌────────────────────────────────────────┐
│ Stage 40: Post-Operation │ <--- In Transaction
└────────────────────────────────────────┘

▼ (Database Transaction Commits)
┌────────────────────────────────────────┐
│ Async Registered Plugins / Flows │ <--- Out of Transaction
└────────────────────────────────────────┘
  1. Pre-Validation (Stage 10): Executes outside the database transaction. Used for basic validation before database resources are locked.
  2. Pre-Operation (Stage 20): Executes inside the database transaction. Plug-ins running in this stage can modify the entity's attributes before they are written to SQL Server.
  3. Main Operation (Stage 30): The platform performs the core database operation (Insert, Update, Delete).
  4. Post-Operation (Stage 40): Executes inside the database transaction. Used to modify related records or perform actions that depend on the successful execution of the main operation.
  5. Asynchronous Operations: Registered plug-ins and Power Automate flows queue for execution outside the transaction boundary.

Transaction Boundaries in Batch Requests

The transaction boundary of a Web API request depends on how the request is structured:

  • Individual Requests: Each HTTP request (POST, PATCH, DELETE) runs in its own isolated database transaction.
  • Batch Requests ($batch) without Change Sets: Each operation within the batch is executed sequentially in its own independent transaction. If operation 3 fails, operations 1 and 2 are not rolled back, and operations 4 and 5 continue to execute (unless the Prefer: odata.continue-on-error header is omitted, which halts execution but does not roll back completed operations).
  • Batch Requests ($batch) with Change Sets: A Change Set defines an atomic transaction boundary. All operations within the Change Set are executed within a single database transaction. If any operation within the Change Set fails, the entire transaction is aborted, and all database modifications made by operations within that Change Set are rolled back.

Sandbox Isolation Mode and Callout Restrictions

All custom plug-ins triggered by Web API requests execute within the Sandbox Isolation Mode (unless deployed on-premises with full trust). The sandbox imposes strict operational limits:

  • Execution Timeout: Any plug-in that runs for more than 2 minutes (120 seconds) is terminated immediately, throwing a System.TimeoutException. This rolls back the active transaction.
  • Network Restrictions: Sandbox plug-ins can only establish outbound network connections over ports 80 (HTTP) and 443 (HTTPS). All other ports are blocked.
  • IP Address Restrictions: Connections to local IP addresses (loopback, private subnets) are blocked to prevent access to internal infrastructure.

Depth and Loop-Guard Limits

To prevent infinite loops caused by recursive plug-ins (e.g., an update to Account A triggers a plug-in that updates Account A, which triggers the plug-in again), Dataverse enforces a Depth Limit:

  • Limit: The maximum execution depth is 8.
  • Mechanism: Every time a message is dispatched within the pipeline, the platform increments the depth counter. If the depth counter exceeds 8 within a 10-minute window, the execution is halted, the transaction is rolled back, and the platform throws a MaxPluginDepthExceeded exception (Error Code: 0x80040224).

8. Error Handling & Retry Patterns

Robust integrations must gracefully handle transient network failures, service protection limits, and database concurrency conflicts.

Common Web API Error Codes

Error Code (Hex)HTTP StatusError NameRoot CauseRemediation
0x80060882412 Precondition FailedConcurrencyVersionMismatchThe ETag provided in the If-Match header does not match the current database version.Retrieve the latest record, extract the new ETag, re-apply business logic, and retry the update.
0x80072322429 Too Many RequestsRequestLimitExceededThe application has exceeded the limit of 6,000 requests within a 5-minute sliding window.Read the Retry-After header, pause execution for the specified duration, and retry.
0x80072422429 Too Many RequestsExecutionTimeLimitExceededThe combined execution time of all requests has exceeded 20 minutes within a 5-minute window.Reduce concurrent threads, optimize query performance, and implement exponential back-off.
0x80072522429 Too Many RequestsConcurrentLimitExceededThe number of concurrent requests sent by the user has exceeded the limit of 52.Implement a client-side throttling mechanism to limit concurrent HTTP connections.
0x80040333412 Precondition FailedDuplicateRecordDetectedA duplicate detection rule was violated, and MSCRM.SuppressDuplicateDetection was set to false.Handle the duplicate warning, prompt the user, or set the header to true to force creation.
0x80048405403 ForbiddenAccessDeniedThe Application User lacks the required security role or privilege to perform the operation.Grant the missing privilege (Create, Read, Write, Delete) to the user's security role in Dataverse.

C# Implementation: Exponential Back-off and Retry Policy

The following code demonstrates how to implement a robust retry policy using exponential back-off, specifically handling HTTP 429 (Too Many Requests) by parsing the Retry-After header.

using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;

namespace DataverseWebAPIDemo
{
public static class HttpRetryHandler
{
private const int MaxRetries = 5;
private const int DefaultDelaySeconds = 2;

/// <summary>
/// Executes an HTTP operation with exponential back-off and intelligent handling of HTTP 429 (Throttling).
/// </summary>
public static async Task<HttpResponseMessage> ExecuteWithRetryAsync(Func<Task<HttpResponseMessage>> action)
{
int retryCount = 0;
double delaySeconds = DefaultDelaySeconds;

while (true)
{
try
{
HttpResponseMessage response = await action();

// If successful, return the response immediately
if (response.StatusCode != (HttpStatusCode)429 && response.StatusCode != HttpStatusCode.ServiceUnavailable)
{
return response;
}

retryCount++;
if (retryCount > MaxRetries)
{
throw new HttpRequestException($"API call failed after {MaxRetries} retries. Status Code: {response.StatusCode}");
}

// Handle Throttling (HTTP 429) by checking the Retry-After header
double waitTimeSeconds = delaySeconds;
if (response.StatusCode == (HttpStatusCode)429 && response.Headers.RetryAfter != null)
{
if (response.Headers.RetryAfter.Delta.HasValue)
{
waitTimeSeconds = response.Headers.RetryAfter.Delta.Value.TotalSeconds;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[Throttled] HTTP 429 received. Respecting Retry-After header. Waiting {waitTimeSeconds} seconds...");
Console.ResetColor();
}
}
else
{
// Exponential back-off for generic transient errors (HTTP 503)
waitTimeSeconds = delaySeconds;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[Transient Error] HTTP {response.StatusCode} received. Retrying in {waitTimeSeconds} seconds (Attempt {retryCount}/{MaxRetries})...");
Console.ResetColor();
delaySeconds *= 2; // Double the delay for the next iteration
}

await Task.Delay(TimeSpan.FromSeconds(waitTimeSeconds));
}
catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException)
{
retryCount++;
if (retryCount > MaxRetries)
{
throw new HttpRequestException($"API call failed due to network timeout/exception after {MaxRetries} retries.", ex);
}

double waitTimeSeconds = delaySeconds;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[Network Exception] {ex.Message}. Retrying in {waitTimeSeconds} seconds (Attempt {retryCount}/{MaxRetries})...");
Console.ResetColor();

await Task.Delay(TimeSpan.FromSeconds(waitTimeSeconds));
delaySeconds *= 2;
}
}
}
}
}

9. Performance Optimisation & Limits

To build high-throughput integrations that remain stable under heavy load, developers must design around Dataverse platform limits.

Platform Limits & Throttling Thresholds

Dataverse enforces Service Protection Limits per user account, per web server instance, evaluated over a 5-minute sliding window:

  1. Request Count Limit: 6,000 requests per 5 minutes.
  2. Execution Time Limit: 20 minutes (1,200 seconds) of combined execution time per 5 minutes.
  3. Concurrent Request Limit: 52 concurrent requests active at any single millisecond.

Performance Optimization Strategies

1. Limit Payload Size with $select

Never execute a query without a $select clause. By default, Dataverse returns all columns, which increases database read times, serialization overhead, and network payload size. Limiting queries to only the required columns significantly improves performance.

2. Optimize Paging with Prefer: odata.maxpagesize

When retrieving large datasets, control the page size using the Prefer: odata.maxpagesize header. The optimal page size is typically between 500 and 5000 records.

  • If the dataset exceeds the page size, the response includes an @odata.nextLink property containing the URL to retrieve the next page.
  • Always follow the @odata.nextLink to retrieve the complete dataset.

3. Bypass Custom Business Logic

If your integration is performing bulk data synchronization and you do not want to trigger synchronous plug-ins or workflows (which consume execution time and can cause database locks), include the bypass header:

MSCRM.BypassCustomPluginExecution: true
  • Prerequisite: The calling security principal must possess the prvBypassCustomPlugins privilege.

4. Use Batching Appropriately

While batching reduces network round-trips, sending excessively large batches can trigger execution time limits.

  • Best Practice: Limit batch sizes to 100 to 200 operations per batch.
  • For maximum throughput, execute multiple smaller batches in parallel across 10 to 15 concurrent threads, rather than sending a single massive batch of 1,000 operations.

10. ALM & Deployment Checklist

Moving Web API integrations and their dependencies across environments requires a structured Application Lifecycle Management (ALM) process.

Ordered Deployment Checklist

  1. Solution Packaging: Ensure all custom tables, columns, alternate keys, and security roles are packaged into a Managed Solution in the source (Development) environment.
  2. Alternate Key Activation: After importing the solution into the target environment, verify that the alternate key index creation job has completed successfully.
    • Query the status via the Web API:
      GET https://[Org URI]/api/data/v9.2/EntityDefinitions(LogicalName='account')/Keys
    • Ensure the EntityKeyIndexStatus is set to Active.
  3. Environment Variables: Use Dataverse Environment Variables to store environment-specific configuration values (e.g., external API endpoints). Do not hardcode these values in client-side scripts.
  4. Connection References: When deploying Power Automate flows that call the Web API or interact with Dataverse, ensure they use Connection References mapped to the target environment's connection during solution import.
  5. App Registration & S2S User:
    • Register the Entra ID application in the target tenant.
    • Create the corresponding Application User in the target Dataverse environment.
    • Assign the imported security roles to the new Application User.
  6. Secret Rotation: Ensure client secrets or certificates used for MSAL authentication are stored in Azure Key Vault and configured to rotate automatically.

CI/CD Pipeline Snippet (GitHub Actions)

The following GitHub Actions workflow snippet demonstrates how to automate the export, unpack, and deployment of a Dataverse solution containing Web API dependencies using the Power Platform Build Tools.

name: Deploy Dataverse Solution

on:
push:
branches:
- main

permissions:
contents: write

jobs:
deploy:
runs-on: ubuntu-latest
env:
DATAVERSE_URL: 'https://org8f1a2b3c.api.crm.dynamics.com/'
CLIENT_ID: '00000000-0000-0000-0000-000000000000'
TENANT_ID: '00000000-0000-0000-0000-000000000000'

steps:
- name: Checkout Source Code
uses: actions/checkout@v4

- name: Install Power Platform CLI
uses: microsoft/powerplatform-actions/actions-install@v1

- name: Authenticate to Dataverse
uses: microsoft/powerplatform-actions/who-am-i@v1
with:
environment-url: `${{ env.DATAVERSE_URL }`}
app-id: `${{ env.CLIENT_ID }`}
client-secret: `${{ secrets.DATAVERSE_CLIENT_SECRET }`}
tenant-id: `${{ env.TENANT_ID }`}

- name: Pack Solution
uses: microsoft/powerplatform-actions/pack-solution@v1
with:
solution-folder: 'solutions/CorpIntegration'
solution-file: 'out/CorpIntegration_managed.zip'
solution-type: 'Managed'

- name: Import Solution to Target Environment
uses: microsoft/powerplatform-actions/import-solution@v1
with:
environment-url: `${{ env.DATAVERSE_URL }`}
app-id: `${{ env.CLIENT_ID }`}
client-secret: `${{ secrets.DATAVERSE_CLIENT_SECRET }`}
tenant-id: `${{ env.TENANT_ID }`}
solution-file: 'out/CorpIntegration_managed.zip'
force-overwrite: true
publish-changes: true

11. Common Pitfalls & Troubleshooting Guide

1. Non-CRLF Line Endings in Multipart Batch Payloads

  • Symptom: The batch request fails with HTTP 400 Bad Request or throws a deserialization error: System.ArgumentException: Stream was not readable.
  • Root Cause: The OData v4 specification requires all line endings in a multipart/mixed batch payload to be CRLF (\r\n). If your code or text editor uses LF (\n), the Dataverse parser fails to identify the boundaries.
  • Diagnostic Steps: Inspect the raw byte array of the payload. Verify that every line ends with hex bytes 0x0D 0x0A.
  • Fix: Explicitly format the payload string using \r\n in your code (as shown in the C# code sample).

2. Including Alternate Key Columns in PATCH Request Body

  • Symptom: An upsert operation fails with an error stating that the key columns cannot be updated.
  • Root Cause: When executing a PATCH request targeting a record via an alternate key, you must not include the alternate key columns in the JSON request body. Dataverse treats this as an attempt to modify the identity columns, which is blocked.
  • Diagnostic Steps: Check the JSON payload of the PATCH request.
  • Fix: Remove the alternate key columns from the JSON body. They are already defined in the URI to identify the record.

3. Using Parameter Aliases inside $expand

  • Symptom: The query fails with an HTTP 400 Bad Request stating that the parameter alias is undefined or unsupported.
  • Root Cause: Dataverse supports parameter aliases (e.g., @p1) in the main $filter and $orderby clauses, but not within nested query options inside a $expand clause.
  • Diagnostic Steps: Check if your query contains syntax like $expand=contacts($filter=firstname eq @p1).
  • Fix: Inline the filter values directly within the $expand parentheses, or use standard OData syntax without aliases for the expanded entity.

4. Not URL-Encoding Special Characters in $filter

  • Symptom: Queries return incorrect results, fail with HTTP 400, or throw parsing errors when filtering on strings containing characters like &, +, #, or %.
  • Root Cause: Special characters have structural meaning in URLs. For example, + represents a space, and & separates query parameters.
  • Diagnostic Steps: Inspect the raw URL sent to Dataverse.
  • Fix: Always URL-encode string filter values (e.g., M&M must be encoded as M%26M).

5. Attempting to Disassociate via DELETE on Single-Valued Navigation Property

  • Symptom: Sending an HTTP DELETE to contacts(GUID)/parentcustomerid_account returns HTTP 405 Method Not Allowed.
  • Root Cause: You cannot use DELETE directly on a single-valued navigation property to clear a lookup.
  • Diagnostic Steps: Check the HTTP verb and target URI.
  • Fix: To clear a lookup, send a PATCH request to the entity with the navigation property set to null:
    {
    "parentcustomerid_account@odata.bind": null
    }
    Alternatively, send a DELETE request to the reference URI: contacts(GUID)/parentcustomerid_account/$ref.

6. Exceeding Maximum URL Length

  • Symptom: The client receives HTTP 404 Not Found or HTTP 414 URI Too Long.
  • Root Cause: The maximum URL length supported by the Dataverse Web API endpoint is 32 KB (32,768 characters). Complex queries with large $filter clauses or long FetchXML queries can easily exceed this limit.
  • Diagnostic Steps: Measure the character length of the request URL.
  • Fix: Execute the query within a $batch request. In a batch request, the query URL is placed in the HTTP request body, which supports payloads up to 64 KB.

7. Missing Namespace on Bound Actions/Functions

  • Symptom: Calling a bound action or function returns HTTP 404 Not Found.
  • Root Cause: Unlike unbound operations, bound actions and functions must be called using their fully qualified name, which includes the Microsoft.Dynamics.CRM namespace.
  • Diagnostic Steps: Check the URI. If it is /accounts(GUID)/MyBoundAction, it will fail.
  • Fix: Change the URI to /accounts(GUID)/Microsoft.Dynamics.CRM.MyBoundAction.

8. Updating Lookups using GUID Properties Directly

  • Symptom: A PATCH request to update a lookup column fails with an error: Property 'parentcustomerid' is read-only.
  • Root Cause: You cannot update a lookup column by setting its GUID property directly (e.g., "parentcustomerid": "GUID"). Lookups are represented as navigation properties in OData.
  • Diagnostic Steps: Check the JSON request body.
  • Fix: Use the @odata.bind annotation on the navigation property:
    {
    "parentcustomerid_account@odata.bind": "accounts(GUID)"
    }

9. Using $expand in Queries with Optimistic Concurrency

  • Symptom: The request fails with an error stating that conditional operations are not supported on queries containing $expand.
  • Root Cause: Dataverse does not support optimistic concurrency headers (If-Match or If-None-Match) on queries that expand related records.
  • Diagnostic Steps: Check if the request headers include If-None-Match and the URL contains $expand.
  • Fix: Split the operation into two requests: retrieve the primary record with ETag validation first, then retrieve the related records separately.

10. Missing Content-Type Boundary Parameter in Batch Requests

  • Symptom: The batch request fails with HTTP 400 Bad Request or Mime Multipart Collator cannot find boundary parameter.
  • Root Cause: The Content-Type header of the main $batch request must include the exact boundary string used to separate operations in the payload.
  • Diagnostic Steps: Verify that the Content-Type header is set to multipart/mixed; boundary=your_boundary_id and matches the boundary string in the request body.
  • Fix: Ensure the boundary parameter is correctly configured in your HTTP client headers (as shown in the C# code sample).

12. Exam Focus: Key Facts & Edge Cases

To pass the PL-400 (Microsoft Power Platform Developer) exam, you must memorize several specific technical details, limits, and behaviors of the Dataverse Web API.

  • OData Version: The Dataverse Web API is built on OData v4. You must always pass the headers OData-Version: 4.0 and OData-MaxVersion: 4.0 in every request.
  • Default Return Behavior: By default, a successful POST (Create) or PATCH (Update) request returns HTTP 204 No Content. The URI of the created record is returned in the OData-EntityId response header.
  • Overriding Return Behavior: To return the created or updated record's data in the response body, you must pass the header Prefer: return=representation. This returns HTTP 201 Created for creations and HTTP 200 OK for updates.
  • Optimistic Concurrency Headers:
    • To perform an operation only if the record has not changed: Use If-Match: [ETagValue].
    • To prevent an update and only allow creation during an upsert: Use If-None-Match: *.
    • To prevent a creation and only allow update during an upsert: Use If-Match: *.
  • Batch Limits: A single $batch request can contain a maximum of 1,000 individual requests. It cannot contain nested batch requests.
  • Change Set Atomicity: Operations grouped inside a Change Set within a batch are executed atomically. If one operation fails, the entire Change Set rolls back. Operations outside a Change Set are executed sequentially and commit independently.
  • Line Endings: All line endings in a multipart batch payload must be CRLF (\r\n). Using LF (\n) will cause the request to fail.
  • Bypass Custom Plugins: To bypass custom plug-ins and workflows, use the header MSCRM.BypassCustomPluginExecution: true. This requires the caller to have the prvBypassCustomPlugins privilege.
  • Duplicate Detection: Duplicate detection is suppressed by default when creating or updating records via the Web API. To enable it, you must explicitly pass the header MSCRM.SuppressDuplicateDetection: false.
  • Impersonation Headers:
    • To impersonate a user using their Microsoft Entra ID Object ID (preferred): Use the CallerObjectId header.
    • To impersonate a user using their Dataverse System User ID (legacy): Use the MSCRMCallerID header.
  • Alternate Key Special Characters: Alternate keys containing characters like /, <, >, *, %, &, :, \\, ?, or + cannot be targeted directly in the URI parentheses. You must query them using $filter instead.
  • Single-Valued vs Collection-Valued Navigation Properties:
    • A lookup column is represented as a single-valued navigation property (e.g., parentcustomerid_account).
    • A one-to-many or many-to-many relationship is represented as a collection-valued navigation property (e.g., contact_customer_accounts).
  • Retrieving Raw Values: To retrieve the raw, unformatted value of a single property (e.g., a plain string or binary file stream), append /$value to the property path in the URI (e.g., /accounts(GUID)/name/$value).