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 $action OData 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.

  1. Open make.powerapps.com and select your environment
  2. Go to Solutions → your solution → NewMoreCustom API
  3. 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:

  1. Open Plugin Registration Tool and connect to your environment
  2. Register the assembly (not a step—Custom APIs handle step registration automatically)
  3. Go back to the Custom API record in the maker portal
  4. Set the Plugin Type field to your registered class
  5. 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:

  1. Audit the existing action. Export and review the process XML. Document all input/output parameters and any embedded workflow steps.
  2. Create the Custom API definition with matching parameter names and types so existing callers do not need to change their payload structure.
  3. 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.
  4. 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.
  5. 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_CalculateRenewalDateV2 rather than modifying the existing one, and deprecate the original.
  • Trace generously. The ITracingService is your only debug output in production. Log parameter values (excluding sensitive data) at the start and outcome at the end.
  • Handle errors explicitly. Throw InvalidPluginExecutionException with 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.