Mastering Dataverse Web API Bound and Unbound Actions and Functions
1. Conceptual Foundation
The Microsoft Dataverse Web API is built upon the OData (Open Data Protocol) v4.0 specification, which provides a standardized, RESTful approach to querying and manipulating data. Within the OData paradigm, operations that extend beyond standard Create, Read, Update, and Delete (CRUD) capabilities are modeled as Actions and Functions. Understanding the theoretical differences, execution mechanics, and data models of these operations is essential for designing high-performance, enterprise-grade solutions on the Power Platform.
Actions vs. Functions
The fundamental distinction between an Action and a Function lies in their side effects and HTTP execution methods:
+-----------------------------------------------------------------------------+
| ODATA v4.0 |
+--------------------------------------+--------------------------------------+
| ACTIONS | FUNCTIONS |
+--------------------------------------+--------------------------------------+
| - Side effects: YES (Modifies data) | - Side effects: NO (Read-only) |
| - HTTP Method: POST | - HTTP Method: GET |
| - Parameters: Request Body (JSON) | - Parameters: URL Query String |
| - Return Type: Optional | - Return Type: Mandatory |
+--------------------------------------+--------------------------------------+
Actions
Actions represent operations that may have side effects in the database. Side effects include creating, updating, deleting, or sharing records, as well as triggering external integrations or state transitions.
- HTTP Method: Actions must always be invoked using the HTTP POST method.
- Parameters: Parameters are passed within the JSON-formatted request body.
- Return Value: Actions can return a value (either a primitive type, a complex type, an entity, or an entity collection) or return
204 No Contentupon successful execution.
Functions
Functions represent operations that must not have side effects. They are strictly read-only and are typically used to perform calculations, retrieve specialized metadata, or query complex relationships.
- HTTP Method: Functions must always be invoked using the HTTP GET method.
- Parameters: Parameters are passed as query options in the URL string (e.g., using parameter aliases).
- Return Value: Functions must return a value. The OData specification requires a return type for all functions.
Bound vs. Unbound Operations
Both actions and functions can be classified as either Bound or Unbound. This classification dictates how the operation is addressed in the Web API URL and how its execution context is established.
Bound Operations
A bound operation is explicitly associated with a specific table (entity) or a table collection (entity set) in Dataverse.
- Context: The first parameter of a bound operation (implicitly defined in the metadata) is a reference to the record or collection to which the operation is bound.
- Addressing: To invoke a bound operation, the URL must target the specific record or collection, followed by the fully qualified name of the operation, which includes the Dataverse service namespace:
Microsoft.Dynamics.CRM. - Example URL (Bound Action):
POST https://org.api.crm.dynamics.com/api/data/v9.2/contacts(00000000-0000-0000-0000-000000000001)/Microsoft.Dynamics.CRM.contoso_CalculateScore - Example URL (Bound Function):
GET https://org.api.crm.dynamics.com/api/data/v9.2/systemusers(00000000-0000-0000-0000-000000000002)/Microsoft.Dynamics.CRM.RetrievePrincipalAccess(Target=@t)?@t={'@odata.id':'accounts(00000000-0000-0000-0000-000000000003)'}
Unbound Operations
An unbound operation is a global operation that is not associated with any specific record or table context.
- Context: It operates globally across the environment.
- Addressing: Unbound operations are called directly from the Web API root URL.
- Example URL (Unbound Action):
POST https://org.api.crm.dynamics.com/api/data/v9.2/contoso_GlobalProcessData - Example URL (Unbound Function):
GET https://org.api.crm.dynamics.com/api/data/v9.2/WhoAmI
Custom API vs. Custom Process Actions (Workflow-based)
Historically, developers created custom messages in Dataverse using Custom Process Actions (declarative workflows configured via the classic workflow designer). While still supported, Custom APIs represent the modern, code-first approach to defining custom messages.
The architectural differences between these two paradigms are profound:
- Execution Engine: Custom Process Actions rely on the legacy Workflow Foundation engine, which introduces overhead and requires a real-time workflow record to be activated. Custom APIs bypass this layer entirely, executing directly within the Dataverse event pipeline as first-class messages.
- OData Functions: Custom Process Actions can only be exposed as OData Actions (HTTP POST). Custom APIs allow developers to define true OData Functions (HTTP GET), enabling read-only operations that align with REST best practices.
- Privilege Enforcement: Custom APIs support binding a specific security privilege (e.g.,
prvReadAccount) directly to the message. If the calling user lacks this privilege, Dataverse rejects the request at the platform boundary before executing any custom code. Custom Process Actions require manual privilege checks inside custom workflow activities or plugins. - Private Messages: Custom APIs can be marked as private (
IsPrivate = true). This hides the message from the public OData$metadatadocument, preventing external consumers or third-party solutions from discovering or taking dependencies on internal APIs. Custom Process Actions are always public. - Localization: Custom APIs support localized display names and descriptions via localized label tables, making them highly compatible with multi-language model-driven apps and canvas apps. Custom Process Actions do not support localization.
Complex Parameter Types in Web API
When invoking actions or functions, developers frequently need to pass parameters beyond simple primitive types (like strings or integers). Dataverse supports passing complex types, which must be formatted precisely in the JSON payload:
EntityReference (Lookup)
Represented as a JSON object containing the primary key of the target record and its OData type annotation. When passing an EntityReference as a parameter to an action, the property name must match the parameter name, and the object must include the @odata.type property:
"Assignee": {
"systemuserid": "e1b8c6d2-3f4a-4b5c-8d6e-7f8a9b0c1d2e",
"@odata.type": "Microsoft.Dynamics.CRM.systemuser"
}
Alternatively, in some standard platform functions, record references are passed using the @odata.id annotation pointing to the relative or absolute URI of the record:
"Target": {
"@odata.id": "accounts(cbcf8bbc-aa41-ec11-8c62-000d3a53893c)"
}
Entity (Record)
Represented as a JSON object containing a collection of attributes. Like EntityReference, it must include the @odata.type property to resolve ambiguity, especially when the parameter type is crmbaseentity (the base type for all Dataverse tables):
"Target": {
"@odata.type": "Microsoft.Dynamics.CRM.lead",
"firstname": "Jane",
"lastname": "Doe",
"emailaddress1": "jane.doe@contoso.com"
}
EntityCollection (Array of Records)
Represented as a JSON array of Entity objects. Each object in the array must be fully qualified with its respective @odata.type:
"BackupAssignees": [
{
"@odata.type": "Microsoft.Dynamics.CRM.systemuser",
"systemuserid": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d"
},
{
"@odata.type": "Microsoft.Dynamics.CRM.systemuser",
"systemuserid": "f9e8d7c6-b5a4-3f2e-1d0c-9b8a7f6e5d4c"
}
]
2. Architecture & Decision Matrix
When designing business logic extensions in Dataverse, architects must evaluate multiple implementation patterns. The table below compares the four primary execution vehicles for custom logic:
| Criteria | Power Automate (Cloud Flows) | Azure Functions | Plugins (Custom API) | Power Apps Component Framework (PCF) |
|---|---|---|---|---|
| Complexity | Low (No-code/Low-code drag-and-drop) | Medium (C# / TypeScript / Python) | High (C# class libraries, SDK, PRT) | High (TypeScript, React, Node.js) |
| Scalability | Medium (Subject to API limits & concurrency) | High (Serverless auto-scaling) | High (Executes in-process on Dataverse) | Low (Client-side execution) |
| Execution Context | Asynchronous (Sync triggers are near-real-time) | Asynchronous (External callout) | Synchronous or Asynchronous (In-transaction) | Client-side (Browser context) |
| Offline Support | None | None | Yes (Via Mobile Offline sync filters/logic) | Yes (Runs locally in Power Apps Mobile) |
| Licensing | Power Apps/Power Automate per-user/per-flow | Azure Consumption / App Service Plan | Included in Dataverse base license | Included in Power Apps base license |
| PL-400 Relevance | High (Integration scenarios) | High (Hybrid/Enterprise patterns) | Critical (Core developer capability) | High (UI/UX customization) |
Architectural Decision Guidelines
Use Plugins (Custom API) when:
- Transactional Integrity is Mandatory: The operation must execute within the database transaction. If any step fails, the entire operation must roll back.
- Ultra-low Latency is Required: The logic must execute in-process with minimal network hop overhead (typically < 100ms).
- Security Enforcement at Platform Boundary: You need to restrict execution based on native Dataverse privileges before any custom code runs.
- Dataverse-First ALM: The logic must be packaged, transported, and versioned entirely within a Dataverse Solution.
Use Azure Functions when:
- Heavy Compute / Long-Running Processes: The operation requires more than the 2-minute Dataverse execution timeout.
- Third-Party Library Dependencies: The logic relies on external NuGet packages or native binaries that cannot be registered in the Dataverse sandbox.
- Multi-Cloud Integrations: The process needs to orchestrate resources across Azure, AWS, or on-premises systems outside the Power Platform ecosystem.
Use Power Automate when:
- Orchestration & Connectors: The primary goal is to send notifications, update external SaaS systems via standard connectors, or orchestrate multi-step approval processes that do not require synchronous database rollbacks.
3. Step-by-Step Implementation Guide
This guide details the end-to-end process of defining, registering, and invoking a Custom API named contoso_ProcessLeadAssignment. This API is bound to the lead table, accepts an assignee (EntityReference), a list of backup assignees (EntityCollection), and a reason (String), and returns a success indicator (Boolean) and the updated lead record (Entity).
Step 1: Create the C# Plugin Assembly
- Open Visual Studio and create a new Class Library (.NET Framework) project. Target .NET Framework 4.6.2 (required for Dataverse plugins).
- Install the following NuGet packages via the Package Manager Console:
Install-Package Microsoft.CrmSdk.CoreAssemblies -Version 9.0.2.56
- Create a class named
ProcessLeadAssignmentPluginimplementing theIPlugininterface. - Write the plugin logic to extract the input parameters from the
PluginExecutionContext.InputParameterscollection, perform the business logic, and set the output parameters in thePluginExecutionContext.OutputParameterscollection. (The complete code is provided in Section 4). - Sign the assembly with a strong name key file (
.snk). - Build the project in Release mode to generate
Contoso.Plugins.dll.
Step 2: Register the Plugin Assembly
- Launch the Plugin Registration Tool (PRT) and connect to your Dataverse environment.
- Select Register > Register New Assembly.
- Browse to and select your compiled
Contoso.Plugins.dll. - Ensure Sandbox isolation mode and Database storage are selected, then click Register Selected Plugins.
- Note the registered Plugin Type ID for the
Contoso.Plugins.ProcessLeadAssignmentPluginclass.
Step 3: Define the Custom API Record
You can define the Custom API directly in the Power Apps Maker Portal:
- Navigate to make.powerapps.com and select your development environment.
- Open or create a new Solution (e.g., "Lead Management Extensions").
- Select New > More > Other > Custom API.
- Configure the Custom API record with the following values:
- Unique Name:
contoso_ProcessLeadAssignment - Name:
Process Lead Assignment - Display Name:
Process Lead Assignment - Description:
Custom API to programmatically assign a lead with backup assignees. - Binding Type:
Bound - Bound Entity Logical Name:
lead - Execute Privilege Name: Leave blank (or set to a custom privilege if restricting access)
- Is Function:
No(This is an Action because it modifies data) - Is Private:
No - Allowed Custom Processing Step Type:
None(Prevents other publishers from registering plugins on this message) - Plugin Type: Select the plugin type registered in Step 2 (
Contoso.Plugins.ProcessLeadAssignmentPlugin).
- Unique Name:
+---------------------------------------------------------------------------------+
| CUSTOM API |
| Unique Name: contoso_ProcessLeadAssignment |
| Binding Type: Bound (lead) |
| Plugin Type: Contoso.Plugins.ProcessLeadAssignmentPlugin |
+---------------------------------------+-----------------------------------------+
|
+------------------------------+------------------------------+
| |
+--------v------------------------------------+ +----------------------v------------------+
| REQUEST PARAMETERS | | RESPONSE PROPERTIES |
| | | |
| 1. Assignee (EntityReference, systemuser) | | 1. IsSuccess (Boolean) |
| 2. BackupAssignees (EntityCollection) | | 2. AssignedRecord (Entity) |
| 3. Reason (String) | | |
+---------------------------------------------+ +------------------------------------------+
Step 4: Define Request Parameters
Within your solution, add the input parameters for the Custom API:
- Select New > More > Other > Custom API Request Parameter.
- Create the Assignee parameter:
- Custom API:
contoso_ProcessLeadAssignment - Unique Name:
Assignee - Name:
Assignee - Type:
EntityReference - Bound Entity Logical Name:
systemuser - Is Optional:
No
- Custom API:
- Create the BackupAssignees parameter:
- Custom API:
contoso_ProcessLeadAssignment - Unique Name:
BackupAssignees - Name:
Backup Assignees - Type:
EntityCollection - Bound Entity Logical Name: Leave blank (allows any entity type, though our plugin will validate for systemuser)
- Is Optional:
Yes
- Custom API:
- Create the Reason parameter:
- Custom API:
contoso_ProcessLeadAssignment - Unique Name:
Reason - Name:
Reason - Type:
String - Is Optional:
No
- Custom API:
Step 5: Define Response Properties
Add the output properties that the API will return:
- Select New > More > Other > Custom API Response Property.
- Create the IsSuccess property:
- Custom API:
contoso_ProcessLeadAssignment - Unique Name:
IsSuccess - Name:
Is Success - Type:
Boolean
- Custom API:
- Create the AssignedRecord property:
- Custom API:
contoso_ProcessLeadAssignment - Unique Name:
AssignedRecord - Name:
Assigned Record - Type:
Entity - Bound Entity Logical Name:
lead
- Custom API:
4. Complete Code Reference
C# Custom API Plugin Implementation
The following is the complete, production-grade C# plugin code for the contoso_ProcessLeadAssignment Custom API. It demonstrates parameter extraction, validation, transactional execution, and response payload construction.
//-----------------------------------------------------------------------
// <copyright file="ProcessLeadAssignmentPlugin.cs" company="Contoso">
// Copyright (c) Contoso. All rights reserved.
// </copyright>
// <summary>
// Plugin implementing the main operation of the contoso_ProcessLeadAssignment Custom API.
// </summary>
//-----------------------------------------------------------------------
namespace Contoso.Plugins
{
using System;
using System.Collections.Generic;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
/// <summary>
/// Main operation plugin for the contoso_ProcessLeadAssignment Custom API.
/// </summary>
public sealed class ProcessLeadAssignmentPlugin : IPlugin
{
/// <summary>
/// Executes the lead assignment business logic.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
public void Execute(IServiceProvider serviceProvider)
{
if (serviceProvider == null)
{
throw new ArgumentNullException(nameof(serviceProvider));
}
// Obtain execution context and services
IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
IOrganizationServiceFactory serviceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
IOrganizationService service = serviceFactory.CreateOrganizationService(context.UserId);
ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
tracingService.Trace("Entering ProcessLeadAssignmentPlugin. Execute Message: {0}, Stage: {1}", context.MessageName, context.Stage);
// Verify this is executing in the correct message context
if (!context.MessageName.Equals("contoso_ProcessLeadAssignment", StringComparison.OrdinalIgnoreCase))
{
tracingService.Trace("Invalid message context: {0}", context.MessageName);
return;
}
try
{
// 1. Extract the Bound Entity (Target) - automatically passed as 'Target' of type EntityReference
if (!context.InputParameters.Contains("Target") || !(context.InputParameters["Target"] is EntityReference targetLeadRef))
{
throw new InvalidPluginExecutionException("The bound 'Target' parameter (Lead EntityReference) is missing or invalid.");
}
tracingService.Trace("Target Lead ID: {0}", targetLeadRef.Id);
// 2. Extract the 'Assignee' Request Parameter
if (!context.InputParameters.Contains("Assignee") || !(context.InputParameters["Assignee"] is EntityReference assigneeRef))
{
throw new InvalidPluginExecutionException("The required parameter 'Assignee' is missing or invalid.");
}
tracingService.Trace("Assignee ID: {0}, Logical Name: {1}", assigneeRef.Id, assigneeRef.LogicalName);
// 3. Extract the 'Reason' Request Parameter
if (!context.InputParameters.Contains("Reason") || !(context.InputParameters["Reason"] is string reason) || string.IsNullOrWhiteSpace(reason))
{
throw new InvalidPluginExecutionException("The required parameter 'Reason' is missing or empty.");
}
tracingService.Trace("Assignment Reason: {0}", reason);
// 4. Extract the optional 'BackupAssignees' Request Parameter
EntityCollection backupAssignees = null;
if (context.InputParameters.Contains("BackupAssignees") && context.InputParameters["BackupAssignees"] is EntityCollection extractedCollection)
{
backupAssignees = extractedCollection;
tracingService.Trace("Extracted {0} backup assignees.", backupAssignees.Entities.Count);
}
// 5. Perform Business Logic & Validations
// Retrieve the current lead to verify its state
Entity lead = service.Retrieve(targetLeadRef.LogicalName, targetLeadRef.Id, new ColumnSet("statecode", "statuscode", "description"));
if (lead.GetAttributeValue<OptionSetValue>("statecode").Value != 0) // 0 = Active
{
throw new InvalidPluginExecutionException("Cannot assign an inactive lead.");
}
// Validate backup assignees are systemusers
if (backupAssignees != null && backupAssignees.Entities.Count > 0)
{
foreach (Entity backupUser in backupAssignees.Entities)
{
if (backupUser.LogicalName != "systemuser")
{
throw new InvalidPluginExecutionException($"Invalid backup assignee type: '{backupUser.LogicalName}'. Only 'systemuser' is allowed.");
}
}
}
// Update the Lead Owner and append the assignment reason to the description
string currentDescription = lead.GetAttributeValue<string>("description") ?? string.Empty;
string updatedDescription = $"{currentDescription}\r\n[System Assignment] Assigned to {assigneeRef.Id} on {DateTime.UtcNow:u} for reason: {reason}";
Entity leadToUpdate = new Entity(targetLeadRef.LogicalName, targetLeadRef.Id)
{
["ownerid"] = assigneeRef,
["description"] = updatedDescription
};
tracingService.Trace("Updating lead owner and description...");
service.Update(leadToUpdate);
// If backup assignees exist, log them to the trace or perform additional custom logic
if (backupAssignees != null && backupAssignees.Entities.Count > 0)
{
tracingService.Trace("Processing backup assignees for custom notification routing...");
// Custom logic to handle backup assignees (e.g., sharing the record, creating tasks, etc.)
foreach (Entity backupUser in backupAssignees.Entities)
{
// Share read access with backup assignees
var shareRequest = new OrganizationRequest("GrantAccess")
{
["Target"] = targetLeadRef,
["PrincipalAccess"] = new PrincipalAccess
{
Principal = backupUser.ToEntityReference(),
AccessMask = AccessRights.ReadAccess
}
};
service.Execute(shareRequest);
tracingService.Trace("Granted ReadAccess to backup user: {0}", backupUser.Id);
}
}
// Retrieve the fully updated lead record to return in the response
Entity updatedLead = service.Retrieve(targetLeadRef.LogicalName, targetLeadRef.Id, new ColumnSet("leadid", "ownerid", "description"));
// 6. Set Output Parameters (Response Properties)
context.OutputParameters["IsSuccess"] = true;
context.OutputParameters["AssignedRecord"] = updatedLead;
tracingService.Trace("ProcessLeadAssignmentPlugin completed successfully.");
}
catch (Exception ex) when (!(ex is InvalidPluginExecutionException))
{
tracingService.Trace("Error occurred in ProcessLeadAssignmentPlugin: {0}", ex.ToString());
throw new InvalidPluginExecutionException($"An error occurred during lead assignment processing: {ex.Message}", ex);
}
}
}
}
Client-Side JavaScript (Xrm.WebApi) Implementation
The following JavaScript function demonstrates how to invoke the bound Custom API contoso_ProcessLeadAssignment using the client-side Xrm.WebApi.online.execute method.
/**
* Invokes the contoso_ProcessLeadAssignment Custom API.
* @param {string} leadId - The GUID of the lead record.
* @param {string} assigneeId - The GUID of the primary assignee (systemuser).
* @param {Array<string>} backupUserIds - Array of GUIDs for backup assignees.
* @param {string} reason - The reason for assignment.
*/
async function callProcessLeadAssignment(leadId, assigneeId, backupUserIds, reason) {
const tracingPrefix = "[callProcessLeadAssignment] ";
console.log(``${tracingPrefix}`Preparing request payload...`);
// 1. Construct the BackupAssignees EntityCollection parameter
const backupAssigneesCollection = [];
if (backupUserIds && backupUserIds.length > 0) {
backupUserIds.forEach(id => {
backupAssigneesCollection.push({
"systemuserid": id,
"@odata.type": "Microsoft.Dynamics.CRM.systemuser"
});
});
}
// 2. Define the request object matching the OData action definition
const request = {
// Input parameters
"Assignee": {
"systemuserid": assigneeId,
"@odata.type": "Microsoft.Dynamics.CRM.systemuser"
},
"BackupAssignees": backupAssigneesCollection,
"Reason": reason,
// Metadata for the bound action
"boundParameter": "entity",
"parameterTypes": {
"entity": {
"typeName": "mscrm.lead",
"structuralProperty": 5 // Entity Reference
},
"Assignee": {
"typeName": "mscrm.systemuser",
"structuralProperty": 5 // Entity Reference
},
"BackupAssignees": {
"typeName": "Collection(mscrm.systemuser)",
"structuralProperty": 4 // Collection of Entities
},
"Reason": {
"typeName": "Edm.String",
"structuralProperty": 1 // Primitive Type
}
},
// Target record URI (the bound parameter)
"entity": {
"leadid": leadId,
"@odata.type": "Microsoft.Dynamics.CRM.lead"
},
// Fully qualified action name
"getMetadata": function () {
return {
boundParameter: "entity",
parameterTypes: {
"entity": { "typeName": "mscrm.lead", "structuralProperty": 5 },
"Assignee": { "typeName": "mscrm.systemuser", "structuralProperty": 5 },
"BackupAssignees": { "typeName": "Collection(mscrm.systemuser)", "structuralProperty": 4 },
"Reason": { "typeName": "Edm.String", "structuralProperty": 1 }
},
operationType: 0, // Action
operationName: "Microsoft.Dynamics.CRM.contoso_ProcessLeadAssignment"
};
}
};
try {
console.log(``${tracingPrefix}`Executing Custom API via Xrm.WebApi...`);
const response = await Xrm.WebApi.online.execute(request);
if (response.ok) {
console.log(``${tracingPrefix}`Response received successfully.`);
const resultJson = await response.json();
const isSuccess = resultJson.IsSuccess;
const assignedRecord = resultJson.AssignedRecord;
console.log(``${tracingPrefix}`IsSuccess: `${isSuccess}``);
console.log(``${tracingPrefix}`Assigned Lead ID: `${assignedRecord.leadid}``);
console.log(``${tracingPrefix}`Updated Description: `${assignedRecord.description}``);
return resultJson;
} else {
const errorText = await response.text();
console.error(``${tracingPrefix}`Error response: `${errorText}``);
throw new Error(`Custom API execution failed: `${response.statusText}`. Details: `${errorText}``);
}
} catch (error) {
console.error(``${tracingPrefix}`Exception caught: `${error.message}``);
throw error;
}
}
C# HttpClient (External Integration) Implementation
The following C# code demonstrates how an external application (such as an Azure Function or daemon service) authenticates using OAuth 2.0 Client Credentials and invokes the Custom API via raw HTTP requests.
using System;
using System.Collections.Generic;
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;
namespace Contoso.IntegrationClient
{
public class LeadAssignmentClient
{
private readonly HttpClient _httpClient;
private readonly string _organizationUrl;
public LeadAssignmentClient(HttpClient httpClient, string organizationUrl)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_organizationUrl = organizationUrl.TrimEnd('/');
}
/// <summary>
/// Invokes the contoso_ProcessLeadAssignment Custom API via HttpClient.
/// </summary>
public async Task<CustomApiResponsePayload> ProcessLeadAssignmentAsync(
Guid leadId,
Guid assigneeId,
List<Guid> backupUserIds,
string reason,
string accessToken)
{
string requestUrl = $"{_organizationUrl}/api/data/v9.2/leads({leadId})/Microsoft.Dynamics.CRM.contoso_ProcessLeadAssignment";
// 1. Construct the JSON payload
var backupAssignees = new List<Dictionary<string, object>>();
foreach (var id in backupUserIds)
{
backupAssignees.Add(new Dictionary<string, object>
{
{ "@odata.type", "Microsoft.Dynamics.CRM.systemuser" },
{ "systemuserid", id.ToString() }
});
}
var payload = new Dictionary<string, object>
{
{
"Assignee", new Dictionary<string, object>
{
{ "@odata.type", "Microsoft.Dynamics.CRM.systemuser" },
{ "systemuserid", assigneeId.ToString() }
}
},
{ "BackupAssignees", backupAssignees },
{ "Reason", reason }
};
string jsonPayload = JsonSerializer.Serialize(payload);
// 2. Prepare the HTTP Request
using (var request = new HttpRequestMessage(HttpMethod.Post, requestUrl))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("OData-MaxVersion", "4.0");
request.Headers.Add("OData-Version", "4.0");
request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
// 3. Send the Request
HttpResponseMessage response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
string responseContent = await response.Content.ReadAsStringAsync();
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
return JsonSerializer.Deserialize<CustomApiResponsePayload>(responseContent, options);
}
else
{
string errorContent = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Dataverse Web API returned error status {response.StatusCode}. Details: {errorContent}");
}
}
}
}
// DTOs for deserializing the response payload
public class CustomApiResponsePayload
{
[JsonPropertyName("@odata.context")]
public string ODataContext { get; set; }
[JsonPropertyName("IsSuccess")]
public bool IsSuccess { get; set; }
[JsonPropertyName("AssignedRecord")]
public LeadEntityDto AssignedRecord { get; set; }
}
public class LeadEntityDto
{
[JsonPropertyName("@odata.type")]
public string ODataType { get; set; }
[JsonPropertyName("leadid")]
public Guid LeadId { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("_ownerid_value")]
public Guid OwnerIdValue { get; set; }
}
}
5. Configuration & Environment Setup
To deploy and run Custom APIs reliably across environments, you must configure the underlying solution components and environment variables correctly.
Solution Publisher Prefix Rules
All Custom APIs must be created within an unmanaged solution associated with a specific publisher. The publisher's customization prefix (e.g., contoso) dictates the prefix of the Custom API's unique name:
- Rule: The unique name of the Custom API must start with the publisher prefix followed by an underscore (e.g.,
contoso_ProcessLeadAssignment). - Case Sensitivity: The unique name is case-sensitive in OData URLs. Ensure consistency across code and configuration.
Environment Variable Schema Definition
When calling Custom APIs from external systems or canvas apps, avoid hardcoding the API endpoint or target parameters. Use Dataverse Environment Variables to store configuration values.
The following JSON represents the schema definition for an environment variable storing the target primary assignee ID:
{
"SchemaName": "contoso_DefaultLeadAssigneeId",
"DisplayName": "Default Lead Assignee ID",
"Type": "String",
"DefaultValue": "00000000-0000-0000-0000-000000000000",
"Description": "The default systemuser GUID used by the lead assignment Custom API when no assignee is specified."
}
Azure App Registration (Microsoft Entra ID) Setup
To allow external applications to invoke the Dataverse Web API, you must register an application in Microsoft Entra ID and grant it access to Dataverse.
Bicep Snippet: Azure App Registration & Key Vault Secret
The following Bicep template provisions an Azure Key Vault to store the Client Secret of the Entra ID App Registration used to authenticate against the Dataverse Web API:
param location string = resourceGroup().location
param keyVaultName string = 'kv-contoso-dataverse-prod'
param secretName string = 'DataverseClientSecret'
@secure()
param clientSecretValue string
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: keyVaultName
location: location
properties: {
sku: {
family: 'A'
name: 'standard'
}
tenantId: subscription().tenantId
accessPolicies: [] // Configure access policies as needed for Managed Identities
enabledForDeployment: true
enabledForTemplateDeployment: true
enabledForDiskEncryption: true
enableRbacAuthorization: true
enableSoftDelete: true
softDeleteRetentionInDays: 90
}
}
resource secret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
parent: keyVault
name: secretName
properties: {
value: clientSecretValue
attributes: {
enabled: true
}
}
}
output keyVaultUri string = keyVault.properties.vaultUri
6. Security & Permission Matrix
Executing actions and functions in Dataverse requires a precise combination of security roles, privileges, and OAuth scopes. The table below outlines the security requirements for both standard platform actions and custom APIs:
| Principal | Permission / Privilege | Scope | Reason |
|---|---|---|---|
| Calling User / Service Principal | prvReadLead | User / Parent / Org | Required to access the bound lead record for contoso_ProcessLeadAssignment. |
| Calling User / Service Principal | prvWriteLead | User / Parent / Org | Required to update the ownerid and description of the lead record. |
| Calling User / Service Principal | prvShareLead | User / Parent / Org | Required to execute the GrantAccess action on the lead record. |
| Calling User / Service Principal | prvReadUser | Organization | Required to validate that the Assignee and BackupAssignees exist in the systemuser table. |
| Calling User / Service Principal | Custom Execute Privilege (if defined on Custom API) | Organization | If the Custom API has Execute Privilege Name set, the user must possess this specific privilege. |
| Entra ID App Registration | user_impersonation | Delegated Scope | Required when calling the Web API on behalf of a signed-in user. |
| Service Principal (S2S) | Dataverse Search / Web API Access | Application Role | Required for non-interactive server-to-server (S2S) authentication. |
7. Pipeline Execution Internals
When a Custom API is invoked via the Web API, it enters the standard Dataverse Event Execution Pipeline. Understanding the transactional boundaries and execution stages is critical for writing robust code.
+---------------------------------------------------------------------------------+
| DATAVERSE EXECUTION PIPELINE |
+---------------------------------------------------------------------------------+
| Stage 10: Pre-Validation | - Security privilege checks |
| | - Parameter validation (outside transaction) |
+----------------------------+----------------------------------------------------+
| Stage 20: Pre-Operation | - Database transaction starts |
| | - State checks |
+----------------------------+----------------------------------------------------+
| Stage 30: MainOperation | - Custom API Plugin executes |
| | - Core database operations occur |
+----------------------------+----------------------------------------------------+
| Stage 40: Post-Operation | - Post-update logic |
| | - Async steps registered here |
+----------------------------+----------------------------------------------------+
| Transaction Commit | - Changes persisted to database |
+---------------------------------------------------------------------------------+
Pipeline Stages
- Stage 10: Pre-Validation: Executes outside the database transaction. This stage is ideal for basic parameter validation and security checks. If an exception is thrown here, no database transaction is created.
- Stage 20: Pre-Operation: Executes inside the database transaction.
- Stage 30: MainOperation (Core): This is where the Custom API's registered plugin executes. You cannot register custom plugin steps on Stage 30 for standard messages, but for Custom APIs, the platform registers your main operation plugin directly in this stage.
- Stage 40: Post-Operation: Executes inside the database transaction. This stage is used for registering follow-up actions (such as creating tasks or sending integration events).
Transaction Boundaries
- Synchronous Custom APIs: Run entirely within a single database transaction. If the plugin (or any synchronous step registered on the message) throws an
InvalidPluginExecutionException, the entire transaction rolls back, reverting all database modifications made during the execution. - Asynchronous Custom APIs: Custom APIs themselves cannot be configured as asynchronous at the message level, but you can register asynchronous plugin steps on the
Post-Operationstage of the Custom API message.
Sandbox Mode Callout Restrictions
Plugins executing in the Dataverse Sandbox are subject to strict security restrictions:
- Network Access: Outbound network calls are restricted to ports 80 (HTTP) and 443 (HTTPS).
- IP Addresses: Direct IP address access is blocked; you must resolve endpoints via DNS.
- Execution Timeout: The entire execution must complete within 2 minutes (120 seconds). This limit is absolute and cannot be configured.
Depth and Loop-Guard Limits
To prevent infinite loops (e.g., a plugin updating a record, which triggers the same plugin recursively), Dataverse enforces a depth limit:
- Limit: If the execution depth exceeds 16 within a 10-minute window, the platform terminates the execution and throws a
MaxDepthExceededexception. - Mitigation: Always check the
context.Depthproperty in your plugin code and terminate execution if it exceeds a safe threshold (typically > 2).
8. Error Handling & Retry Patterns
OData Error Format
When a Custom API or standard platform action fails, the Dataverse Web API returns a standard OData v4.0 error payload with an HTTP status code (typically 400 Bad Request or 500 Internal Server Error):
{
"error": {
"code": "0x80040265",
"message": "An error occurred during lead assignment processing: Cannot assign an inactive lead.",
"innererror": {
"message": "Cannot assign an inactive lead.",
"type": "Microsoft.Crm.CrmException",
"stacktrace": " at Contoso.Plugins.ProcessLeadAssignmentPlugin.Execute(IServiceProvider serviceProvider)..."
}
}
}
Common Failure Modes & Remediation
| Error Code | Exception / Error Type | Root Cause | Remediation |
|---|---|---|---|
0x8006088a | Resource not found for the segment | The Custom API unique name is misspelled or the API is inactive. | Verify the spelling and case of the unique name in the URL. Ensure the Custom API solution component is activated. |
0x80040265 | InvalidPluginExecutionException | Custom business logic validation failed (e.g., lead is inactive). | Catch the exception in the client, parse the message, and display a user-friendly error. |
0x80044150 | Bad Request (Unresolved parameters) | Missing the Microsoft.Dynamics.CRM namespace prefix on a bound action. | Ensure the URL contains the fully qualified action name: /Microsoft.Dynamics.CRM.contoso_ProcessLeadAssignment. |
0x80040224 | Sandbox execution timeout | The plugin exceeded the 2-minute execution limit. | Optimize database queries, use QueryExpression with column filtering, or offload heavy processing to Azure. |
C# HttpClient Retry Policy with Exponential Back-off
When calling the Web API from external systems, implement a retry policy to handle transient network failures or Service Protection limits (429 Too Many Requests).
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Polly;
using Polly.Retry;
namespace Contoso.IntegrationClient
{
public static class HttpPolicyFactory
{
/// <summary>
/// Creates a resilient retry policy for Dataverse Web API calls.
/// Handles transient HTTP errors (5xx) and rate limiting (429).
/// </summary>
public static AsyncRetryPolicy<HttpResponseMessage> GetWebApiResponsePolicy()
{
return Policy
.HandleResult<HttpResponseMessage>(r =>
r.StatusCode == HttpStatusCode.InternalServerError || // 500
r.StatusCode == HttpStatusCode.BadGateway || // 502
r.StatusCode == (HttpStatusCode)429) // Too Many Requests
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: (retryCount, response, context) =>
{
// Check if Dataverse returned a 'Retry-After' header
var headers = response.Result?.Headers;
if (headers != null && headers.RetryAfter != null && headers.RetryAfter.Delta.HasValue)
{
return headers.RetryAfter.Delta.Value;
}
// Fallback to exponential back-off with jitter
double delay = Math.Pow(2, retryCount);
double jitter = new Random().NextDouble() * 0.5;
return TimeSpan.FromSeconds(delay + jitter);
},
onRetryAsync: async (response, timespan, retryCount, context) =>
{
// Log retry attempt
await Task.CompletedTask;
Console.WriteLine($"Transient error encountered: {response.Result.StatusCode}. Retrying in {timespan.TotalSeconds}s (Attempt {retryCount}/3)...");
});
}
}
}
9. Performance Optimisation & Limits
To ensure high throughput and system stability, developers must design Custom APIs to operate within Dataverse platform limits.
Platform Limits
- Service Protection Limits: Dataverse enforces limits on the number of requests per user within a rolling 5-minute window. Exceeding these limits returns an HTTP
429 Too Many Requestsstatus. - Execution Timeout: Plugins and Custom APIs have an absolute 2-minute execution limit.
- Payload Size Limit: The maximum payload size for a single Web API request is 128 MB.
Optimisation Strategies
1. Column Selection (No Select All)
In your Custom API plugins, never retrieve all columns. Always specify only the columns required for the business logic:
// BAD: Retrieving all columns
Entity lead = service.Retrieve("lead", leadId, new ColumnSet(true));
// GOOD: Retrieving only required columns
Entity lead = service.Retrieve("lead", leadId, new ColumnSet("statecode", "statuscode"));
2. Batching Requests with $batch
If you need to invoke a Custom API multiple times, combine the requests into a single HTTP POST request using OData batching. This reduces network latency and HTTP connection overhead.
POST [Organization URI]/api/data/v9.2/$batch HTTP/1.1
Content-Type: multipart/mixed; boundary=batch_123456
MIME-Version: 1.0
--batch_123456
Content-Type: application/http
Content-Transfer-Encoding: binary
POST [Organization URI]/api/data/v9.2/leads(cbcf8bbc-aa41-ec11-8c62-000d3a53893c)/Microsoft.Dynamics.CRM.contoso_ProcessLeadAssignment HTTP/1.1
Content-Type: application/json
{
"Assignee": {
"systemuserid": "8061643d-ebf7-e811-a974-000d3a1e1c9a",
"@odata.type": "Microsoft.Dynamics.CRM.systemuser"
},
"Reason": "Batch assignment 1"
}
--batch_123456--
3. Bypass Custom Business Logic (Optional)
If your Custom API performs bulk updates and you want to bypass other synchronous plugins registered on the target table to maximize performance, pass the BypassCustomPluginExecution parameter in the request headers:
request.Headers.Add("MSCRM.BypassCustomPluginExecution", "true");
Note: This requires the calling user to possess the prvBypassCustomPlugins privilege.
10. ALM & Deployment Checklist
Deploying Custom APIs across environments (Development -> Test -> Production) requires strict adherence to Application Lifecycle Management (ALM) best practices.
Deployment Checklist
- Plugin Assembly Registration: Ensure the compiled C# plugin assembly is registered and included in the unmanaged solution.
- Custom API Component Packaging: Verify that the
customapi,customapirequestparameter, andcustomapiresponsepropertyrecords are added to the solution. - Managed Solutions: Always export the solution as Managed for deployment to downstream environments (Test, UAT, Production).
- Environment Variables: Ensure environment variable values are cleared from the solution before export, allowing target environments to define their own values.
Solution XML Customization Reference
The following XML snippets represent the complete, raw solution files for the Custom API and its parameters. These files are automatically generated by Dataverse when exporting a solution, but understanding their schema is essential for source control and CI/CD troubleshooting.
customapi.xml
<?xml version="1.0" encoding="utf-8"?>
<customapis>
<customapi uniquename="contoso_ProcessLeadAssignment">
<allowedcustomprocessingsteptype>0</allowedcustomprocessingsteptype>
<bindingtype>1</bindingtype> <!-- 1 = Bound -->
<boundentitylogicalname>lead</boundentitylogicalname>
<description>Custom API to programmatically assign a lead with backup assignees.</description>
<displayname>Process Lead Assignment</displayname>
<isfunction>0</isfunction> <!-- 0 = Action -->
<isprivate>0</isprivate>
<name>contoso_ProcessLeadAssignment</name>
<plugintypeid>
<plugintypeid>2c9fdfe6-41e3-ed11-8845-0022480b2800</plugintypeid>
</plugintypeid>
</customapi>
</customapis>
customapirequestparameter.xml
<?xml version="1.0" encoding="utf-8"?>
<customapirequestparameters>
<customapirequestparameter uniquename="Assignee">
<customapiid>
<uniquename>contoso_ProcessLeadAssignment</uniquename>
</customapiid>
<description>The primary user to assign the lead to.</description>
<displayname>Assignee</displayname>
<isoptional>0</isoptional>
<logicalentityname>systemuser</logicalentityname>
<name>contoso_ProcessLeadAssignment.Assignee</name>
<type>5</type> <!-- 5 = EntityReference -->
</customapirequestparameter>
</customapirequestparameters>
customapiresponseproperty.xml
<?xml version="1.0" encoding="utf-8"?>
<customapiresponseproperties>
<customapicallresponseproperty uniquename="IsSuccess">
<customapiid>
<uniquename>contoso_ProcessLeadAssignment</uniquename>
</customapiid>
<description>Indicates if the assignment was successful.</description>
<displayname>Is Success</displayname>
<name>contoso_ProcessLeadAssignment.IsSuccess</name>
<type>0</type> <!-- 0 = Boolean -->
</customapicallresponseproperty>
</customapicallproperties>
GitHub Actions CI/CD Pipeline Snippet
The following YAML snippet demonstrates how to automate the export, unpacking, and deployment of your Dataverse solution containing the Custom API using the Power Platform Build Tools:
name: Deploy Dataverse Solution
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Install Power Platform CLI
uses: microsoft/powerplatform-actions/actions-install@v1
- name: Export Solution from Dev
uses: microsoft/powerplatform-actions/export-solution@v1
with:
environment-url: 'https://contoso-dev.crm.dynamics.com'
app-id: `${{ secrets.CLIENT_ID }`}
client-secret: `${{ secrets.CLIENT_SECRET }`}
tenant-id: `${{ secrets.TENANT_ID }`}
solution-name: 'LeadManagementExtensions'
solution-output-file: 'out/LeadManagementExtensions.zip'
managed: true
- name: Import Solution to Prod
uses: microsoft/powerplatform-actions/import-solution@v1
with:
environment-url: 'https://contoso-prod.crm.dynamics.com'
app-id: `${{ secrets.CLIENT_ID }`}
client-secret: `${{ secrets.CLIENT_SECRET }`}
tenant-id: `${{ secrets.TENANT_ID }`}
solution-file: 'out/LeadManagementExtensions.zip'
force-overwrite: true
11. Common Pitfalls & Troubleshooting Guide
1. Missing Namespace in Bound Action URL
- Symptom: HTTP
404 Not Foundor400 Bad Requestwith the message: "No HTTP resource was found that matches the request URI". - Root Cause: Invoking a bound action without prefixing the action name with the fully qualified namespace
Microsoft.Dynamics.CRM. - Diagnostic Steps: Check the request URL. If it looks like
/leads(guid)/contoso_ProcessLeadAssignment, it is missing the namespace. - Fix: Change the URL to
/leads(guid)/Microsoft.Dynamics.CRM.contoso_ProcessLeadAssignment.
2. Case Sensitivity of Unique Name
- Symptom: HTTP
404 Not Foundwhen calling a Custom API. - Root Cause: The unique name of the Custom API is defined as
contoso_ProcessLeadAssignment, but the code calls/contoso_processleadassignment(lowercase). - Diagnostic Steps: Compare the exact casing of the Custom API's unique name in the solution with the URL string in your code.
- Fix: Ensure the casing in the URL matches the Custom API definition exactly.
3. Incorrect HTTP Method for Functions
- Symptom: HTTP
405 Method Not Allowedor404 Not Found. - Root Cause: Calling an OData Function (which is read-only) using
POST, or calling an OData Action usingGET. - Diagnostic Steps: Check the
IsFunctionproperty of the Custom API. IfYes, it must be called withGET. IfNo, it must be called withPOST. - Fix: Align the HTTP method in your client code with the Custom API definition.
4. Passing Null to Non-Nullable Parameters
- Symptom: HTTP
400 Bad Requestwith the message: "The parameter 'Reason' cannot be null". - Root Cause: A request parameter has
IsOptionalset toNo(false), but the client payload omitted the parameter or passednull. - Diagnostic Steps: Inspect the JSON payload sent by the client and verify against the
IsOptionalconfiguration of the request parameters. - Fix: Ensure the client always provides a valid value for required parameters, or change
IsOptionaltoYesin the parameter definition.
5. Undeclared Property in Payload (OData Type Annotation Missing)
- Symptom: HTTP
400 Bad Requestwith the message: "An undeclared property was found in the payload". - Root Cause: Passing an
EntityorEntityCollectionparameter without specifying the@odata.typeproperty inside the JSON object. - Diagnostic Steps: Check the JSON payload. Ensure all nested entity objects contain
"@odata.type": "Microsoft.Dynamics.CRM.<logicalname>". - Fix: Add the correct
@odata.typeannotation to all entity parameters in the client payload.
6. Plugin Assembly Not Signed
- Symptom: Error during solution import or plugin registration: "Assembly must be signed with a strong name key".
- Root Cause: The C# plugin project was compiled without a strong name key file (
.snk). - Diagnostic Steps: Open the assembly properties in Visual Studio and check the "Signing" tab.
- Fix: Generate a new strong name key file, sign the assembly, and rebuild.
7. Custom API Inactive in Target Environment
- Symptom: HTTP
404 Not Foundafter importing the solution into a downstream environment. - Root Cause: The Custom API record is in an inactive state (
statecode = 1). - Diagnostic Steps: Query the
customapitable in the target environment via Web API:/customapis?$filter=uniquename eq 'contoso_ProcessLeadAssignment'&$select=statecode. - Fix: Activate the Custom API record in the target environment or ensure it is exported in an active state.
8. Missing Execute Privilege
- Symptom: HTTP
403 Forbiddenwith the message: "Principal is missing execute privilege". - Root Cause: The Custom API has an
Execute Privilege Namedefined, but the calling user's security roles do not grant this privilege. - Diagnostic Steps: Check the
execute_privilege_namecolumn on the Custom API record. - Fix: Assign a security role containing the specified privilege to the calling user, or clear the privilege requirement if it is not needed.
9. Infinite Loop / Depth Exceeded
- Symptom: HTTP
500 Internal Server Errorwith the message: "This workflow/plugin has been terminated because it exceeded the maximum depth limit". - Root Cause: The Custom API plugin performs an update on the bound record, which triggers another plugin that calls the same Custom API recursively.
- Diagnostic Steps: Check the plugin trace log. Look for multiple executions of the same plugin within a fraction of a second.
- Fix: Add a depth check at the beginning of the plugin:
if (context.Depth > 1) return;or ensure the update logic does not re-trigger the same message.
10. Invalid Response Property Schema (isoptional Error)
- Symptom: Solution import fails with an error pointing to the
customapiresponsepropertycomponent. - Root Cause: Defining the
isoptionalelement in the XML of a response property. Response properties cannot be optional; they must always be returned by the API. - Diagnostic Steps: Inspect the exported
customapicallresponseproperty.xmlfile for the<isoptional>tag. - Fix: Remove the
<isoptional>tag from the response property XML and re-import.
12. Exam Focus: Key Facts & Edge Cases
To prepare for the PL-400 (Power Platform Developer) exam, memorize the following high-yield facts and edge cases regarding Dataverse Web API actions and functions:
- HTTP Methods are Absolute:
- Actions =
POST(modifies data, side effects allowed). - Functions =
GET(read-only, no side effects allowed).
- Actions =
- Bound vs. Unbound URL Syntax:
- Bound operations must include the fully qualified name with the namespace:
/leads(guid)/Microsoft.Dynamics.CRM.contoso_ProcessLeadAssignment. - Unbound operations are called directly from the root:
/WhoAmIor/contoso_GlobalAction.
- Bound operations must include the fully qualified name with the namespace:
- Custom API Execution Stage: Custom APIs execute in Stage 30 (MainOperation) of the event execution pipeline. This is a critical distinction because standard platform messages do not allow custom plugins to be registered in Stage 30.
- OData Function Return Types: OData Functions must return a value. The return type cannot be void or null. Actions, however, can return
204 No Content. - Open Types (Entity / EntityCollection): To define an "open type" parameter (a parameter that can accept any table type or act as a generic dictionary), set the parameter type to
EntityorEntityCollectionand leave the Bound Entity Logical Name blank. - Privilege Enforcement: Custom APIs can enforce a native security privilege at the platform boundary before executing any code. This is configured via the
Execute Privilege Nameproperty. - Private APIs: Setting
IsPrivate = trueon a Custom API hides it from the OData$metadatadocument, preventing external discovery while still allowing programmatic execution. - Allowed Custom Processing Step Type: This property controls whether other developers can register plugins on your Custom API message. Options are:
None(No steps allowed).AsyncOnly(Only asynchronous post-operation steps allowed).SyncAndAsync(Full extensibility allowed).
- OData Parameter Aliases: When calling functions with complex parameters via
GET, you must use parameter aliases (e.g.,?Target=@t&@t={'@odata.id':'accounts(guid)'}) to pass the values in the query string.