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
POSTrequest to/accountstranslates to aCreateRequestmessage, triggering theCreatemessage pipeline for theaccounttable. - An HTTP
PATCHrequest to/accounts(GUID)translates to anUpdateRequestmessage, triggering theUpdatemessage pipeline. - An HTTP
DELETErequest to/accounts(GUID)translates to aDeleteRequestmessage, triggering theDeletemessage pipeline. - An HTTP
GETrequest to/accounts(GUID)translates to aRetrieveRequestmessage, while a query to/accountstranslates to aRetrieveMultipleRequestmessage.
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). EachEntityTypehas 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., theWhoAmIResponsereturned by theWhoAmIfunction). - 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 Method | Target URI | Dataverse Operation | Success 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:
- Query Operations: Individual
GETrequests executed sequentially. - 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 Dimension | Power 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) |
|---|---|---|---|---|---|
| Complexity | Low (No-code / Low-code designer) | Medium (Standard cloud development) | High (Requires deep platform knowledge) | High (TypeScript, React, PCF CLI) | Medium (JavaScript, Client API) |
| Scalability | Medium (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 Context | Asynchronous (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 Support | No | No | No | Yes (via Canvas/Model-driven offline profiles) | Yes (via Client API offline capabilities) |
| Licensing Cost | Power Automate / Power Apps per-user/per-app licenses | Azure consumption costs + S2S Application User (No Dataverse license) | Included in base Dataverse capacity | Included in Power Apps license | Included in Power Apps license |
| PL-400 Exam Relevance | High (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.webAPIhelper, 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.
- Navigate to the Azure Portal and sign in with your administrator credentials.
- Search for and select Microsoft Entra ID from the top search bar.
- In the left navigation pane, select App registrations, then click New registration.
- 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.
- Name: Enter a descriptive name (e.g.,
- Click Register. Note the Application (client) ID and Directory (tenant) ID displayed on the Overview page.
- 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.
- Description: Enter a description (e.g.,
- 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.
- Navigate to the Power Platform Admin Center.
- In the left navigation pane, select Environments, then click on the name of the target environment.
- On the environment details page, under the Access card, click See all under Users or navigate to Settings -> Users + permissions -> Application users.
- Click New app user.
- Click Add an app. Select your registered Entra ID application (
Dataverse Web API Integration) from the list, then click Add. - Select the Business unit to which this user belongs (typically the root business unit).
- Click the edit pencil icon next to Security roles.
- Select the appropriate security roles required for the integration (e.g., a custom integration role or System Administrator for development environments).
- 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.
- Open your terminal or command prompt.
- 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
- 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 becorp_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
| Principal | Permission / Privilege | Scope | Reason |
|---|---|---|---|
| Microsoft Entra ID App Registration | Dynamics CRM / user_impersonation | Delegated / Organization | Allows the application to access the Dataverse Web API on behalf of the application user. |
| Dataverse Application User | Read (prvReadAccount, prvReadContact, prvReadTask) | Organization | Required to execute HTTP GET requests and query records via OData syntax. |
| Dataverse Application User | Write (prvWriteAccount, prvWriteContact) | Organization | Required to execute HTTP PATCH requests to update records and bind relationships. |
| Dataverse Application User | Create (prvCreateAccount, prvCreateContact, prvCreateTask) | Organization | Required to execute HTTP POST requests, deep inserts, and upserts. |
| Dataverse Application User | Delete (prvDeleteAccount, prvDeleteContact, prvDeleteTask) | Organization | Required to execute HTTP DELETE requests for records or single property values. |
| Dataverse Application User | Act on Behalf of Another User (prvActOnBehalfOfAnotherUser) | Organization | Required only if the integration uses impersonation headers (CallerObjectId or MSCRMCallerID). |
| Dataverse Application User | Bypass Custom Plugins (prvBypassCustomPlugins) | Organization | Required only if the integration uses the MSCRM.BypassCustomPluginExecution header. |
| Field Security Profile | Read / Update on Secured Columns | Column-level | Required 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
└────────────────────────────────────────┘
- Pre-Validation (Stage 10): Executes outside the database transaction. Used for basic validation before database resources are locked.
- 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.
- Main Operation (Stage 30): The platform performs the core database operation (Insert, Update, Delete).
- 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.
- 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-errorheader 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
MaxPluginDepthExceededexception (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 Status | Error Name | Root Cause | Remediation |
|---|---|---|---|---|
0x80060882 | 412 Precondition Failed | ConcurrencyVersionMismatch | The 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. |
0x80072322 | 429 Too Many Requests | RequestLimitExceeded | The 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. |
0x80072422 | 429 Too Many Requests | ExecutionTimeLimitExceeded | The 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. |
0x80072522 | 429 Too Many Requests | ConcurrentLimitExceeded | The 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. |
0x80040333 | 412 Precondition Failed | DuplicateRecordDetected | A 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. |
0x80048405 | 403 Forbidden | AccessDenied | The 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:
- Request Count Limit: 6,000 requests per 5 minutes.
- Execution Time Limit: 20 minutes (1,200 seconds) of combined execution time per 5 minutes.
- 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.nextLinkproperty containing the URL to retrieve the next page. - Always follow the
@odata.nextLinkto 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
prvBypassCustomPluginsprivilege.
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
- Solution Packaging: Ensure all custom tables, columns, alternate keys, and security roles are packaged into a Managed Solution in the source (Development) environment.
- 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
EntityKeyIndexStatusis set toActive.
- Query the status via the Web API:
- 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.
- 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.
- 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.
- 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/mixedbatch 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\nin 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
PATCHrequest 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
PATCHrequest. - 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$filterand$orderbyclauses, but not within nested query options inside a$expandclause. - Diagnostic Steps: Check if your query contains syntax like
$expand=contacts($filter=firstname eq @p1). - Fix: Inline the filter values directly within the
$expandparentheses, 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&Mmust be encoded asM%26M).
5. Attempting to Disassociate via DELETE on Single-Valued Navigation Property
- Symptom: Sending an HTTP
DELETEtocontacts(GUID)/parentcustomerid_accountreturns HTTP 405 Method Not Allowed. - Root Cause: You cannot use
DELETEdirectly 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
PATCHrequest to the entity with the navigation property set tonull:Alternatively, send a{"parentcustomerid_account@odata.bind": null}DELETErequest 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
$filterclauses 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
$batchrequest. 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.CRMnamespace. - 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
PATCHrequest 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.bindannotation 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-MatchorIf-None-Match) on queries that expand related records. - Diagnostic Steps: Check if the request headers include
If-None-Matchand 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-Typeheader of the main$batchrequest must include the exact boundary string used to separate operations in the payload. - Diagnostic Steps: Verify that the
Content-Typeheader is set tomultipart/mixed; boundary=your_boundary_idand 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.0andOData-MaxVersion: 4.0in every request. - Default Return Behavior: By default, a successful
POST(Create) orPATCH(Update) request returns HTTP 204 No Content. The URI of the created record is returned in theOData-EntityIdresponse 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: *.
- To perform an operation only if the record has not changed: Use
- Batch Limits: A single
$batchrequest 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 theprvBypassCustomPluginsprivilege. - 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
CallerObjectIdheader. - To impersonate a user using their Dataverse System User ID (legacy): Use the
MSCRMCallerIDheader.
- To impersonate a user using their Microsoft Entra ID Object ID (preferred): Use the
- Alternate Key Special Characters: Alternate keys containing characters like
/,<,>,*,%,&,:,\\,?, or+cannot be targeted directly in the URI parentheses. You must query them using$filterinstead. - 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).
- A lookup column is represented as a single-valued navigation property (e.g.,
- Retrieving Raw Values: To retrieve the raw, unformatted value of a single property (e.g., a plain string or binary file stream), append
/$valueto the property path in the URI (e.g.,/accounts(GUID)/name/$value).