Use a synchronous C# plugin when a failure in the external system must prevent the Dataverse record from saving. Use an Azure webhook when it must not. Use a Custom API when an external caller needs a named operation with declared inputs and outputs instead of raw table access. Most integration problems in this ecosystem trace back to picking the wrong one of those three.

The comparison

Synchronous pluginAzure webhookCustom API
Runs inside the transactionYesNoYes, when registered synchronously
Can roll back the saveYesNoYes
User waits for itYes — every timeNoOnly if called synchronously
Callable from outsideNon/a — it calls outwardYes, as a named operation
Right forValidation, enforced consistencyNotification, downstream propagationExternal-facing operations

The row that decides most architectures is the third. A synchronous plugin calling an external service puts that service's latency in front of every user who saves a record. If the endpoint takes two seconds, every save takes two seconds — and if it is down, nobody can save at all.

Building a Custom API

SyncCustomerToERPPlugin.cs
using System;
using Microsoft.Xrm.Sdk;

namespace DataDaur.Dataverse.Integrations
{
    public class SyncCustomerToERPPlugin : IPlugin
    {
        public void Execute(IServiceProvider serviceProvider)
        {
            ITracingService tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
            IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
            IOrganizationServiceFactory factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
            IOrganizationService service = factory.CreateOrganizationService(context.UserId);

            tracing.Trace("Executing Custom API: datadaur_SyncCustomerToERP");

            // Extract Custom API Input Parameters
            string customerNumber = context.InputParameters.Contains("CustomerNumber") ? (string)context.InputParameters["CustomerNumber"] : string.Empty;
            string targetSystem = context.InputParameters.Contains("TargetSystem") ? (string)context.InputParameters["TargetSystem"] : "BusinessCentral";

            if (string.IsNullOrEmpty(customerNumber))
                throw new InvalidPluginExecutionException("Input Error: CustomerNumber is required for ERP synchronization.");

            // Perform Bi-Directional Mapping & Dataverse Validation
            bool syncSuccess = ProcessErpSync(customerNumber, targetSystem, service, tracing);

            // Set Custom API Output Parameters
            context.OutputParameters["IsSuccess"] = syncSuccess;
            context.OutputParameters["SyncTimestamp"] = DateTime.UtcNow.ToString("o");
            context.OutputParameters["ResponseMessage"] = syncSuccess ? "Customer synced successfully." : "Sync queued for retry.";
        }

        private bool ProcessErpSync(string custNo, string sys, IOrganizationService service, ITracingService tracing)
        {
            tracing.Trace(
quot;Initiating sync for {custNo} to {sys}"); // Custom Business Central Webhook Trigger Logic return true; } } }

A Custom API declares its request and response parameters explicitly, which means the contract is discoverable instead of tribal. External developers read it from /api/data/v9.2/$metadata instead of asking someone which fields to send.

Authentication

Outbound and inbound integrations authenticate through Entra ID with the client credentials grant, against an application user holding a security role scoped to exactly the tables the integration touches.

Scope it properly at the start. An application user with broad privileges is the finding that turns a routine security review into a remediation project, and narrowing permissions after integrations depend on them is considerably harder than setting them correctly on day one.

Designing for failure

  1. Service protection limits. Dataverse returns HTTP 429 when a caller exceeds its allowance. Handle it with exponential backoff — through API Management, a Polly policy, or equivalent.
  2. Idempotency. Assume every message can arrive twice. Key on a correlation identifier so a redelivery updates instead of duplicating.
  3. Dead-letter handling. A message that cannot be processed has to land somewhere a person will look. Silent discards are how a month of missing records is discovered at close.
  4. Bounded synchronous work. If a plugin must call outward synchronously, give it a short timeout and decide explicitly what happens when it expires.
The integration that fails loudly on the first bad message costs an hour. The one that swallows failures costs a reconciliation nobody can complete.

In practice

Most estates end up using all three: plugins for the handful of rules that must be enforced at write time, webhooks for everything downstream, and Custom APIs as the surface external systems are allowed to touch. What keeps that coherent is deciding the question per integration — does this system get a vote on the save — instead of standardising on whichever pattern the last project used.