Skip to main content

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 Content upon 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:

  1. 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.
  2. 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.
  3. 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.
  4. Private Messages: Custom APIs can be marked as private (IsPrivate = true). This hides the message from the public OData $metadata document, preventing external consumers or third-party solutions from discovering or taking dependencies on internal APIs. Custom Process Actions are always public.
  5. 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:

CriteriaPower Automate (Cloud Flows)Azure FunctionsPlugins (Custom API)Power Apps Component Framework (PCF)
ComplexityLow (No-code/Low-code drag-and-drop)Medium (C# / TypeScript / Python)High (C# class libraries, SDK, PRT)High (TypeScript, React, Node.js)
ScalabilityMedium (Subject to API limits & concurrency)High (Serverless auto-scaling)High (Executes in-process on Dataverse)Low (Client-side execution)
Execution ContextAsynchronous (Sync triggers are near-real-time)Asynchronous (External callout)Synchronous or Asynchronous (In-transaction)Client-side (Browser context)
Offline SupportNoneNoneYes (Via Mobile Offline sync filters/logic)Yes (Runs locally in Power Apps Mobile)
LicensingPower Apps/Power Automate per-user/per-flowAzure Consumption / App Service PlanIncluded in Dataverse base licenseIncluded in Power Apps base license
PL-400 RelevanceHigh (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

  1. Open Visual Studio and create a new Class Library (.NET Framework) project. Target .NET Framework 4.6.2 (required for Dataverse plugins).
  2. Install the following NuGet packages via the Package Manager Console:
    Install-Package Microsoft.CrmSdk.CoreAssemblies -Version 9.0.2.56
  3. Create a class named ProcessLeadAssignmentPlugin implementing the IPlugin interface.
  4. Write the plugin logic to extract the input parameters from the PluginExecutionContext.InputParameters collection, perform the business logic, and set the output parameters in the PluginExecutionContext.OutputParameters collection. (The complete code is provided in Section 4).
  5. Sign the assembly with a strong name key file (.snk).
  6. Build the project in Release mode to generate Contoso.Plugins.dll.

Step 2: Register the Plugin Assembly

  1. Launch the Plugin Registration Tool (PRT) and connect to your Dataverse environment.
  2. Select Register > Register New Assembly.
  3. Browse to and select your compiled Contoso.Plugins.dll.
  4. Ensure Sandbox isolation mode and Database storage are selected, then click Register Selected Plugins.
  5. Note the registered Plugin Type ID for the Contoso.Plugins.ProcessLeadAssignmentPlugin class.

Step 3: Define the Custom API Record

You can define the Custom API directly in the Power Apps Maker Portal:

  1. Navigate to make.powerapps.com and select your development environment.
  2. Open or create a new Solution (e.g., "Lead Management Extensions").
  3. Select New > More > Other > Custom API.
  4. 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).
+---------------------------------------------------------------------------------+
| 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:

  1. Select New > More > Other > Custom API Request Parameter.
  2. Create the Assignee parameter:
    • Custom API: contoso_ProcessLeadAssignment
    • Unique Name: Assignee
    • Name: Assignee
    • Type: EntityReference
    • Bound Entity Logical Name: systemuser
    • Is Optional: No
  3. 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
  4. Create the Reason parameter:
    • Custom API: contoso_ProcessLeadAssignment
    • Unique Name: Reason
    • Name: Reason
    • Type: String
    • Is Optional: No

Step 5: Define Response Properties

Add the output properties that the API will return:

  1. Select New > More > Other > Custom API Response Property.
  2. Create the IsSuccess property:
    • Custom API: contoso_ProcessLeadAssignment
    • Unique Name: IsSuccess
    • Name: Is Success
    • Type: Boolean
  3. Create the AssignedRecord property:
    • Custom API: contoso_ProcessLeadAssignment
    • Unique Name: AssignedRecord
    • Name: Assigned Record
    • Type: Entity
    • Bound Entity Logical Name: lead

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:

PrincipalPermission / PrivilegeScopeReason
Calling User / Service PrincipalprvReadLeadUser / Parent / OrgRequired to access the bound lead record for contoso_ProcessLeadAssignment.
Calling User / Service PrincipalprvWriteLeadUser / Parent / OrgRequired to update the ownerid and description of the lead record.
Calling User / Service PrincipalprvShareLeadUser / Parent / OrgRequired to execute the GrantAccess action on the lead record.
Calling User / Service PrincipalprvReadUserOrganizationRequired to validate that the Assignee and BackupAssignees exist in the systemuser table.
Calling User / Service PrincipalCustom Execute Privilege (if defined on Custom API)OrganizationIf the Custom API has Execute Privilege Name set, the user must possess this specific privilege.
Entra ID App Registrationuser_impersonationDelegated ScopeRequired when calling the Web API on behalf of a signed-in user.
Service Principal (S2S)Dataverse Search / Web API AccessApplication RoleRequired 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

  1. 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.
  2. Stage 20: Pre-Operation: Executes inside the database transaction.
  3. 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.
  4. 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-Operation stage 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 MaxDepthExceeded exception.
  • Mitigation: Always check the context.Depth property 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 CodeException / Error TypeRoot CauseRemediation
0x8006088aResource not found for the segmentThe 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.
0x80040265InvalidPluginExecutionExceptionCustom business logic validation failed (e.g., lead is inactive).Catch the exception in the client, parse the message, and display a user-friendly error.
0x80044150Bad 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.
0x80040224Sandbox execution timeoutThe 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 Requests status.
  • 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

  1. Plugin Assembly Registration: Ensure the compiled C# plugin assembly is registered and included in the unmanaged solution.
  2. Custom API Component Packaging: Verify that the customapi, customapirequestparameter, and customapiresponseproperty records are added to the solution.
  3. Managed Solutions: Always export the solution as Managed for deployment to downstream environments (Test, UAT, Production).
  4. 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 Found or 400 Bad Request with 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 Found when 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 Allowed or 404 Not Found.
  • Root Cause: Calling an OData Function (which is read-only) using POST, or calling an OData Action using GET.
  • Diagnostic Steps: Check the IsFunction property of the Custom API. If Yes, it must be called with GET. If No, it must be called with POST.
  • 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 Request with the message: "The parameter 'Reason' cannot be null".
  • Root Cause: A request parameter has IsOptional set to No (false), but the client payload omitted the parameter or passed null.
  • Diagnostic Steps: Inspect the JSON payload sent by the client and verify against the IsOptional configuration of the request parameters.
  • Fix: Ensure the client always provides a valid value for required parameters, or change IsOptional to Yes in the parameter definition.

5. Undeclared Property in Payload (OData Type Annotation Missing)

  • Symptom: HTTP 400 Bad Request with the message: "An undeclared property was found in the payload".
  • Root Cause: Passing an Entity or EntityCollection parameter without specifying the @odata.type property 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.type annotation 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 Found after importing the solution into a downstream environment.
  • Root Cause: The Custom API record is in an inactive state (statecode = 1).
  • Diagnostic Steps: Query the customapi table 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 Forbidden with the message: "Principal is missing execute privilege".
  • Root Cause: The Custom API has an Execute Privilege Name defined, but the calling user's security roles do not grant this privilege.
  • Diagnostic Steps: Check the execute_privilege_name column 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 Error with 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 customapiresponseproperty component.
  • Root Cause: Defining the isoptional element 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.xml file 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).
  • 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: /WhoAmI or /contoso_GlobalAction.
  • 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 Entity or EntityCollection and 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 Name property.
  • Private APIs: Setting IsPrivate = true on a Custom API hides it from the OData $metadata document, 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.