Guide to Microsoft Dynamics 365 Webhooks: Features and Best Practices
Dynamics 365 webhooks send changes in your CRM data to code you control. When a contact is created, an opportunity is updated or a case is closed, Microsoft Dataverse, the data platform under Dynamics 365 customer engagement apps, can post the details to your service. Teams use them to sync records into other systems, trigger fulfilment, and feed analytics without polling the Web API.
This guide covers how Dataverse webhooks are registered and triggered, what each request contains, how to authenticate requests that are not signed, the delivery limits that matter in production, and the best practices for running a Dynamics 365 receiver.
What are Microsoft Dynamics 365 webhooks?
A Dataverse webhook has two parts. The webhook itself is a service endpoint: a name, a URL and an authentication option, registered with the Plug-in Registration tool. Then you register one or more steps on it, the same way you would register a plug-in step: a message such as Create or Update, the table it applies to, where in the event pipeline it runs, and whether it runs synchronously or asynchronously. When a step's message runs on its table, Dataverse sends a POST to the webhook's URL.
The body is the serialized execution context of the operation, the same RemoteExecutionContext data that Dataverse passes to Azure Service Bus integrations. It names the message and table, carries the input parameters, and can include images of the record before and after the change.
Dataverse does not sign webhook requests. You choose an authentication option when you register the webhook, and Dataverse sends the same static values on every request. Your endpoint is responsible for checking them.
Microsoft Dynamics 365 webhook features
| Feature | Details |
|---|---|
| Configuration | Plug-in Registration tool: Register New WebHook, then register steps on it |
| Trigger | A step for a Dataverse message (for example Create, Update, Delete) on a table |
| Execution | Synchronous or asynchronous, chosen per step |
| Body | JSON serialization of RemoteExecutionContext |
| Event name | x-ms-dynamics-request-name header, and MessageName in the body |
| Table | x-ms-dynamics-entity-name header, and PrimaryEntityName in the body |
| Authentication | HttpHeader, WebhookKey or HttpQueryString, all static values |
| Signature | None |
| Response window | 60 seconds. Any 2xx succeeds, and the response body is ignored |
| Retries | One more attempt, only after 502, 503 or 504 |
| Size limit | Above 256 KB, the context is trimmed and x-ms-dynamics-msg-size-exceeded is sent |
| Ports | 80 for HTTP and 443 for HTTPS only |
The Dataverse webhook documentation publishes no source IP list, so the authentication value you choose is the credential.
Common events
There is no fixed list of webhook events. A step can be registered for any message on any table that supports it, including custom APIs, so you decide what you receive. The ones most integrations use are:
MessageName | Fires when |
|---|---|
Create | A row is created in the step's table |
Update | A row is updated. With filtering attributes set, only when one of those columns changes |
Delete | A row is deleted |
Each request names the message in x-ms-dynamics-request-name and the table's logical name in x-ms-dynamics-entity-name, so you can route before parsing the body. Here is Microsoft's example for an asynchronous Update step on contact with filtering attributes firstname,lastname, trimmed:
{
"BusinessUnitId": "e2b9dd85-e89e-e711-8122-000d3aa2331c",
"CorrelationId": "aaaa0000-bb11-2222-33cc-444444dddddd",
"Depth": 1,
"InputParameters": [{
"key": "Target",
"value": {
"__type": "Entity:http:\/\/schemas.microsoft.com\/xrm\/2011\/Contracts",
"Attributes": [
{ "key": "firstname", "value": "James" },
{ "key": "contactid", "value": "6d81597f-0f9f-e711-8122-000d3aa2331c" },
{ "key": "modifiedon", "value": "\/Date(1506384247000)\/" }
],
"Id": "6d81597f-0f9f-e711-8122-000d3aa2331c",
"LogicalName": "contact"
}
}],
"MessageName": "Update",
"Mode": 1,
"OperationId": "4af10637-4ea2-e711-8122-000d3aa2331c",
"OrganizationName": "OrgName",
"OwningExtension": {
"Id": "75417616-4ea2-e711-8122-000d3aa2331c",
"LogicalName": "sdkmessageprocessingstep"
},
"PostEntityImages": [{
"key": "AsynchronousStepPrimaryName",
"value": { "Attributes": [{ "key": "fullname", "value": "James Glynn (sample)" }] }
}],
"PrimaryEntityId": "6d81597f-0f9f-e711-8122-000d3aa2331c",
"PrimaryEntityName": "contact",
"Stage": 40,
"UserId": "11bb11bb-cc22-dd33-ee44-55ff55ff55ff"
}
Three things trip up new receivers. InputParameters and Attributes are arrays of { "key", "value" } pairs, not objects, so you have to flatten them. Dates are WCF-style strings such as "\/Date(1506384247000)\/", milliseconds since the epoch. And the Target of an Update holds only the columns in the request, not the whole row.
Setting up Microsoft Dynamics 365 webhooks
- Generate a long random secret, for example with
openssl rand -hex 32, and store it on your server. - Open the Plug-in Registration tool, connect to your environment, and choose Register New WebHook.
- Enter a unique Name and the Endpoint URL, using port 443 for HTTPS.
- For Authentication, choose HttpHeader and add a key/value pair, such as
X-Webhook-Secretwith your secret as the value. - Register a step on the webhook: choose the message, the primary table, any filtering attributes, the pipeline stage and the execution mode.
The authentication values cannot be read back once saved. Webhook registrations are stored in the ServiceEndpoint table with a contract of 8, so you can list them through the Web API:
GET [organization URI]/api/data/v9.0/serviceendpoints?$filter=contract eq 8&$select=serviceendpointid,name,authtype,url
The two other authentication options put the credential in the query string: WebhookKey sends ?code=<value>, the convention Azure Functions expects, and HttpQueryString sends one or more key/value pairs. Query strings end up in access logs, so prefer HttpHeader unless your receiver is an Azure Function.
Securing Microsoft Dynamics 365 webhooks
There is no signature to verify. Check the header you registered, compare it with your stored secret in constant time, and reject the request if it is missing or wrong.
const crypto = require('crypto');
const express = require('express');
const HEADER_NAME = 'x-webhook-secret'; // the key you registered under HttpHeader
function isValidSecret(headerValue, secret) {
if (typeof headerValue !== 'string' || !secret) return false;
const received = Buffer.from(headerValue, 'utf8');
const expected = Buffer.from(secret, 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// InputParameters and Attributes are arrays of { key, value } pairs.
function toObject(pairs = []) {
return Object.fromEntries(pairs.map(({ key, value }) => [key, value]));
}
const app = express();
app.post('/webhooks/dynamics', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.DYNAMICS_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!isValidSecret(req.get(HEADER_NAME), secret)) {
return res.sendStatus(401);
}
const context = JSON.parse(req.body.toString('utf8'));
const target = toObject(context.InputParameters).Target;
const attributes = toObject(target?.Attributes);
// Route on context.MessageName and context.PrimaryEntityName, persist, then process.
res.sendStatus(200);
});
The same check in Python:
import hmac
import os
def is_valid_secret(header_value, secret: str) -> bool:
if not header_value or not secret:
return False
return hmac.compare_digest(header_value.encode("utf-8"), secret.encode("utf-8"))
# In FastAPI:
# is_valid_secret(request.headers.get("x-webhook-secret"), os.environ["DYNAMICS_WEBHOOK_SECRET"])
A header secret proves the caller knows the secret. It does not protect the body from tampering and gives no replay protection, so serve the endpoint over HTTPS and treat the body as a notification for anything sensitive.
Make Dynamics 365 webhooks production-ready. Hookdeck Event Gateway answers Dataverse straight away, verifies your HttpHeader secret and stops requests that fail before they reach your service, and durably queues every execution context with retries and replay.
Microsoft Dynamics 365 webhook limitations and pain points
Almost no retries
The Problem: A short outage on your side loses the events that fired during it.
Why It Happens: Dataverse looks only at the status code. A timeout or any non-2xx response fails the operation, and the webhook service makes one more attempt only for 502, 503 and 504. For asynchronous steps, the failure is recorded as a System Job that you can query, but nothing sends the request again after that.
Workarounds:
- Keep the endpoint highly available and quick to respond.
- Monitor failed System Jobs under Settings > System > System Jobs, or query
asyncoperationsfor the step, and resync affected rows. - Choose not to delete System Jobs on success while you are debugging, so failures stay visible.
How Hookdeck Can Help: Hookdeck accepts each request as soon as it arrives and stores it, then retries against your service on a schedule you control and lets you replay any event, so your downtime no longer turns into lost changes.
Synchronous steps block users
The Problem: Users see an "Endpoint unavailable" error and their save fails when your service is slow or down.
Why It Happens: A synchronous step sends the request as part of the operation. If it fails, the data operation rolls back and the user gets an error dialog. The request already sent cannot be recalled, so your service may have received data that was then rolled back.
Workarounds:
- Use asynchronous steps unless the operation must fail when your service does not receive the data.
- Keep synchronous handlers fast, well inside the 60-second timeout.
- Treat data from synchronous steps as provisional, since the operation can still roll back after you receive it.
How Hookdeck Can Help: With Hookdeck in front, Dataverse gets a fast 2xx from Hookdeck's edge, so a slow or unavailable service does not block users. That also means the operation no longer fails when your service does, so use it only for steps where that is what you want.
Large payloads lose their context
The Problem: Some requests arrive without InputParameters or the entity images your handler depends on.
Why It Happens: When the whole HTTP payload exceeds 256 KB, Dataverse adds x-ms-dynamics-msg-size-exceeded and removes ParentContext, InputParameters, PreEntityImages and PostEntityImages.
Workarounds:
- Check for the
x-ms-dynamics-msg-size-exceededheader before reading those properties. - When it is present, fetch the row through the Web API using
PrimaryEntityNameandPrimaryEntityId. - Register only the images and filtering attributes you need, to keep payloads small.
How Hookdeck Can Help: Hookdeck filters can route requests that carry x-ms-dynamics-msg-size-exceeded to a separate destination that re-fetches the full row.
Only one authentication option fits a header check
The Problem: A webhook registered with WebhookKey or HttpQueryString cannot be verified by a header-based check.
Why It Happens: Those two options put the credential in the URL's query string, not in a header.
Workarounds:
- Register with HttpHeader.
- If you must use query-string authentication, check it in your own code and keep it out of your access logs.
How Hookdeck Can Help: Hookdeck's Microsoft Dynamics source type verifies the HttpHeader option: you enter the header name and its exact value, and Hookdeck stops requests that fail before they reach your service.
Best practices
Choose asynchronous steps by default
Asynchronous steps do not block users, and their failures are recorded as System Jobs you can inspect. Reserve synchronous steps for cases where the operation must fail if your service does not receive the data.
Use filtering attributes and images deliberately
Set filtering attributes on Update steps so you only receive changes you care about, and register a post-image with the columns you need instead of re-reading the row for every request.
Route on the headers
Use x-ms-dynamics-request-name and x-ms-dynamics-entity-name to route requests before you parse a body that can be large.
Deduplicate deliveries
There is no delivery ID header. OperationId identifies the operation, and OwningExtension.Id identifies the step, so a key built from the two separates one step's request from another's. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Verify the header, persist the context, return 200, and do the work afterwards. See why to process webhooks asynchronously.
Conclusion
A Dataverse webhook posts the serialized execution context of each operation its steps are registered for, with the message and table in both the headers and the body. Requests are not signed, so register with HttpHeader and compare that header in constant time. Dataverse waits 60 seconds, retries only once and only after 502, 503 or 504, and fails synchronous operations when your endpoint does, so availability on your side decides whether you see every change.
Hookdeck Event Gateway checks the HttpHeader secret, answers Dataverse at once, and holds each request until your service has processed it, so changes survive your outages.
Get started with Hookdeck for free and handle Dynamics 365 webhooks reliably in minutes.