Introduction
If you have been building Dynamics 365 customisations for a few years, you will likely have a library of classic workflow actions—those XML-driven, designer-based custom messages that allowed you to expose business logic as a callable endpoint. They worked, but they were cumbersome: slow to deploy, awkward to test, and impossible to call directly from external systems without a SOAP wrapper.
Custom APIs, introduced in Dynamics 365 9.1, solve all of these problems. They are proper Dataverse messages backed by C# plugins, fully discoverable through the Web API, and natively supported in Power Automate and Power Apps. More importantly, Microsoft has signalled that classic custom actions are on the deprecation path—so migrating sooner rather than later is the right call.
This article walks through what Custom APIs are, when to use them, how to build one end-to-end, and how to call them from both external systems and Power Automate flows.
Custom APIs vs. Classic Actions: Key Differences
Classic Custom Actions (Legacy)
- Defined in the solution explorer as a Process of type Action
- Logic implemented as workflow steps or embedded C# activity assemblies
- No native Web API endpoint—requires the
$actionOData verb workaround - No support for return values of complex types
- Difficult to unit test in isolation
- Cannot be marked as function (GET) vs. action (POST)
Custom APIs (Modern)
- Defined as Dataverse records (CustomAPI, CustomAPIRequestParameter, CustomAPIResponseProperty)
- Logic implemented as a standard IPlugin class
- First-class Web API endpoint:
POST /api/data/v9.2/<yourpluginname> - Supports entity, entity collection, and primitive return types
- Fully unit testable with the standard plugin test harness
- Can be declared as a Function (idempotent, GET-accessible) or an Action (POST)
- Supports privilege checks and solution awareness
When to Use a Custom API
Custom APIs are the right tool whenever you need to:
- Expose a reusable business operation that can be called from multiple places (flows, external systems, model-driven app commands)
- Replace a classic custom action that is being deprecated
- Wrap complex multi-step logic behind a single, versioned endpoint
- Return structured data (not just side effects) to the caller
- Enforce consistent validation and security in one place
They are not the right tool for simple calculated fields, real-time form logic (use JavaScript), or ETL-style batch processing (use a background job or Azure Function instead).
Building a Custom API End-to-End
We will build a practical example: a CalculateRenewalDate API that takes a contract record ID and a renewal period (in months), then returns the calculated renewal date and a formatted summary string.
Step 1: Define the Custom API Record
Custom API definitions live in Dataverse as records, so you can create them in the maker portal or via the API. The maker portal is the easiest starting point.
- Open make.powerapps.com and select your environment
- Go to Solutions → your solution → New → More → Custom API
- Fill in the details:
Display Name: Calculate Renewal Date
Name: anielak_CalculateRenewalDate
Unique Name: anielak_CalculateRenewalDate
Plugin Type: Leave blank for now (we'll link it after)
Binding Type: Entity (bound to Contract entity)
Bound Entity: contract
Is Function: No (it modifies state, so POST)
Is Private: No (callable from anywhere)
Step 2: Define Request Parameters
Add two request parameters to your Custom API. In the maker portal, open the Custom API record and go to the Request Parameters tab.
Parameter 1:
Name: RenewalMonths
Display Name: Renewal Months
Type: Integer
Required: Yes
Description: Number of months to add to the contract start date
Parameter 2:
Name: UseBusinessDays
Display Name: Use Business Days
Type: Boolean
Required: No
Description: If true, skips weekends when calculating the end date
Step 3: Define Response Properties
Response properties define what the API returns. Add these under the Response Properties tab.
Property 1:
Name: RenewalDate
Display Name: Renewal Date
Type: DateTime
Description: The calculated renewal date
Property 2:
Name: Summary
Display Name: Summary
Type: String
Description: Human-readable summary of the renewal calculation
Step 4: Implement the Plugin
Create a new C# class library targeting .NET Framework 4.6.2. Install the
Microsoft.CrmSdk.CoreAssemblies NuGet package.
using System;
using Microsoft.Xrm.Sdk;
namespace Anielak.D365.Plugins
{
public class CalculateRenewalDatePlugin : IPlugin
{
public void Execute(IServiceProvider serviceProvider)
{
var context = (IPluginExecutionContext)serviceProvider
.GetService(typeof(IPluginExecutionContext));
var tracingService = (ITracingService)serviceProvider
.GetService(typeof(ITracingService));
// Validate we're executing on the correct message
if (context.MessageName != "anielak_CalculateRenewalDate")
throw new InvalidPluginExecutionException(
"This plugin is registered on the wrong message.");
tracingService.Trace("CalculateRenewalDate: Retrieving input parameters");
// Read bound entity (the contract record)
var contractId = context.PrimaryEntityId;
// Read request parameters
if (!context.InputParameters.TryGetValue("RenewalMonths", out object renewalMonthsObj))
throw new InvalidPluginExecutionException("RenewalMonths is required.");
int renewalMonths = (int)renewalMonthsObj;
bool useBusinessDays = false;
if (context.InputParameters.TryGetValue("UseBusinessDays", out object useBusinessDaysObj))
useBusinessDays = (bool)useBusinessDaysObj;
// Retrieve the contract to get its start date
var serviceFactory = (IOrganizationServiceFactory)serviceProvider
.GetService(typeof(IOrganizationServiceFactory));
var service = serviceFactory.CreateOrganizationService(context.UserId);
var contract = service.Retrieve("contract", contractId,
new Microsoft.Xrm.Sdk.Query.ColumnSet("activeon"));
var startDate = contract.GetAttributeValue<DateTime>("activeon");
if (startDate == default)
throw new InvalidPluginExecutionException(
"Contract does not have an activation date set.");
tracingService.Trace($"Start date: {startDate:yyyy-MM-dd}, Months: {renewalMonths}");
// Calculate the renewal date
var renewalDate = startDate.AddMonths(renewalMonths);
if (useBusinessDays)
renewalDate = AdjustToNextBusinessDay(renewalDate);
var summary = $"Contract renews on {renewalDate:dd MMMM yyyy} " +
$"({renewalMonths} months from activation).";
if (useBusinessDays)
summary += " Adjusted to next business day.";
// Set output parameters
context.OutputParameters["RenewalDate"] = renewalDate;
context.OutputParameters["Summary"] = summary;
tracingService.Trace($"CalculateRenewalDate complete: {summary}");
}
private static DateTime AdjustToNextBusinessDay(DateTime date)
{
while (date.DayOfWeek == DayOfWeek.Saturday ||
date.DayOfWeek == DayOfWeek.Sunday)
{
date = date.AddDays(1);
}
return date;
}
}
}
Step 5: Register the Plugin
Use the Plugin Registration Tool to register your assembly. The step configuration for a Custom API is different from a regular plugin step:
- Open Plugin Registration Tool and connect to your environment
- Register the assembly (not a step—Custom APIs handle step registration automatically)
- Go back to the Custom API record in the maker portal
- Set the Plugin Type field to your registered class
- Save the record
Assembly: Anielak.D365.Plugins
Class: Anielak.D365.Plugins.CalculateRenewalDatePlugin
Isolation: Sandbox
Note: you do not manually create a plugin step. Dataverse automatically creates the message processing step when you link the plugin type to the Custom API record. This is one of the key differences from classic plugins.
Calling Your Custom API
From the Web API (External Systems)
Because your API is bound to the contract entity and is an Action (not a Function),
call it with a POST request:
POST https://yourorg.crm.dynamics.com/api/data/v9.2/contracts(guid)/Microsoft.Dynamics.CRM.anielak_CalculateRenewalDate
Authorization: Bearer {token}
Content-Type: application/json
OData-MaxVersion: 4.0
OData-Version: 4.0
{
"RenewalMonths": 12,
"UseBusinessDays": true
}
// Response:
{
"@odata.context": "...",
"RenewalDate": "2027-01-20T00:00:00Z",
"Summary": "Contract renews on 20 January 2027 (12 months from activation). Adjusted to next business day."
}
From Power Automate
Custom APIs appear automatically in the Perform an unbound action or Perform a bound action connector steps. Select your action from the dropdown—no custom connectors or HTTP steps required.
Step Type: Perform a bound action
Table name: Contracts
Action name: anielak_CalculateRenewalDate
Row ID: @{triggerOutputs()?['body/_contractid_value']}
RenewalMonths: 12
UseBusinessDays: true
// Access output:
@{outputs('Perform_a_bound_action')?['body/RenewalDate']}
@{outputs('Perform_a_bound_action')?['body/Summary']}
From a Model-Driven App Command Bar
Custom APIs can be called from JavaScript on command bar buttons using the
Xrm.WebApi.online.execute method:
async function calculateRenewal(formContext) {
const contractId = formContext.data.entity.getId().replace(/[{}]/g, "");
const request = {
contractid: { entityType: "contract", id: contractId },
RenewalMonths: 12,
UseBusinessDays: true,
getMetadata: () => ({
boundParameter: "contractid",
parameterTypes: {
contractid: { typeName: "mscrm.contract", structuralProperty: 5 },
RenewalMonths: { typeName: "Edm.Int32", structuralProperty: 1 },
UseBusinessDays: { typeName: "Edm.Boolean", structuralProperty: 1 }
},
operationType: 0, // Action
operationName: "anielak_CalculateRenewalDate"
})
};
try {
const response = await Xrm.WebApi.online.execute(request);
const result = await response.json();
formContext.ui.setFormNotification(
result.Summary,
"INFO",
"renewalNotification"
);
} catch (error) {
console.error("Failed to calculate renewal:", error);
}
}
Migrating a Classic Custom Action
If you have an existing classic action to migrate, follow this process:
- Audit the existing action. Export and review the process XML. Document all input/output parameters and any embedded workflow steps.
- Create the Custom API definition with matching parameter names and types so existing callers do not need to change their payload structure.
- Migrate the logic. If the classic action used workflow steps, re-implement them in C#. If it used a custom workflow activity, extract that assembly logic directly into the Custom API plugin.
-
Update callers gradually. Because Custom APIs share the same
Web API call structure as classic actions, most callers only need the URL prefix
updated from
$action=to the new endpoint. - Deprecate the old action. Once all callers are migrated, deactivate the classic action. Do not delete it until the next major release cycle.
Privilege and Security Model
Custom APIs integrate with Dataverse security through the CustomAPIPrivilege feature. You can require a specific privilege before the API can be called:
// In your Custom API record:
Is Private: No
Privilege Name: prvAnielakCalculateRenewal
// Add this privilege to security roles that should be allowed to call the API.
// Callers without the privilege receive a 403 Forbidden response.
For APIs that do not require a custom privilege, Dataverse still enforces entity-level security for any records touched by the plugin—a user cannot bypass row-level security through a Custom API call.
Unit Testing
Because the plugin is a plain C# class, you can test it with any standard test framework.
Use the FakeXrmEasy library to mock the plugin context:
[TestMethod]
public void CalculateRenewalDate_Returns_CorrectDate()
{
// Arrange
var context = new XrmFakedContext();
var contract = new Entity("contract", Guid.NewGuid())
{
["activeon"] = new DateTime(2026, 1, 15)
};
context.Initialize(new[] { contract });
var pluginContext = context.GetDefaultPluginContext();
pluginContext.MessageName = "anielak_CalculateRenewalDate";
pluginContext.PrimaryEntityId = contract.Id;
pluginContext.InputParameters["RenewalMonths"] = 12;
pluginContext.InputParameters["UseBusinessDays"] = false;
// Act
context.ExecutePluginWithConfigurations<CalculateRenewalDatePlugin>(
pluginContext, null, null);
// Assert
var renewalDate = (DateTime)pluginContext.OutputParameters["RenewalDate"];
Assert.AreEqual(new DateTime(2027, 1, 15), renewalDate);
var summary = (string)pluginContext.OutputParameters["Summary"];
StringAssert.Contains(summary, "15 January 2027");
}
[TestMethod]
public void CalculateRenewalDate_AdjustsWeekend_ToMonday()
{
// Arrange - Jan 15 2026 + 12 months = Jan 15 2027 (Thursday, no adjustment)
// Use a date that produces a Saturday result
var context = new XrmFakedContext();
var contract = new Entity("contract", Guid.NewGuid())
{
["activeon"] = new DateTime(2026, 1, 17) // Saturday + 12 months = Sunday
};
context.Initialize(new[] { contract });
var pluginContext = context.GetDefaultPluginContext();
pluginContext.MessageName = "anielak_CalculateRenewalDate";
pluginContext.PrimaryEntityId = contract.Id;
pluginContext.InputParameters["RenewalMonths"] = 12;
pluginContext.InputParameters["UseBusinessDays"] = true;
// Act
context.ExecutePluginWithConfigurations<CalculateRenewalDatePlugin>(
pluginContext, null, null);
// Assert
var renewalDate = (DateTime)pluginContext.OutputParameters["RenewalDate"];
Assert.AreEqual(DayOfWeek.Monday, renewalDate.DayOfWeek);
}
Best Practices
- Keep Custom APIs focused. One API, one business operation. Resist the urge to add optional parameters that fundamentally change behaviour.
- Use a publisher prefix. Always prefix your Custom API unique name with your publisher prefix (e.g.
anielak_) to avoid naming conflicts in managed solutions. - Document parameters thoroughly. The Description field on each parameter is surfaced in the Power Automate designer—write it as if it is user-facing documentation.
- Version with a suffix if needed. If you need to break the interface, create
anielak_CalculateRenewalDateV2rather than modifying the existing one, and deprecate the original. - Trace generously. The
ITracingServiceis your only debug output in production. Log parameter values (excluding sensitive data) at the start and outcome at the end. - Handle errors explicitly. Throw
InvalidPluginExecutionExceptionwith clear messages—these surface directly to the caller in the Web API error response. - Include in solution. Custom API records, request parameters, response properties, and the plugin assembly must all be in the same managed solution for proper ALM.
Conclusion
Custom APIs represent a significant improvement over classic workflow actions. They are cleaner to build, easier to test, and far more flexible in how they can be called. If you are still running classic actions, the migration path is straightforward—and the sooner you start, the less technical debt you will carry when Microsoft enforces the deprecation timeline.
The pattern shown here—define the message, implement in C#, call from anywhere—scales from simple date calculations to complex orchestration logic spanning multiple entities. Once you have one Custom API working, the rest follow naturally.