# 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:

```json
{
  "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

1. Generate a long random secret, for example with `openssl rand -hex 32`, and store it on your server.
2. Open the Plug-in Registration tool, connect to your environment, and choose **Register New WebHook**.
3. Enter a unique **Name** and the **Endpoint URL**, using port 443 for HTTPS.
4. For **Authentication**, choose **HttpHeader** and add a key/value pair, such as `X-Webhook-Secret` with your secret as the value.
5. 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:

```http
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.

```javascript
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:

```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](/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 `asyncoperations` for 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-exceeded` header before reading those properties.
* When it is present, fetch the row through the Web API using `PrimaryEntityName` and `PrimaryEntityId`.
* Register only the images and filtering attributes you need, to keep payloads small.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Dynamics 365 webhooks reliably in minutes.