# Microsoft Dynamics 365 Customer Service

> Send Dynamics 365 Customer Service case emails and chat transcripts to Dimension Labs with the universal API.

This brings in two kinds of Dynamics 365 Customer Service conversations. Each case becomes one conversation made of the emails tracked on it, from the customer and from your agents. Each chat or messaging conversation from Dynamics 365 Contact Center (the Omnichannel conversations) becomes one conversation made of its transcript. You run it on your side: a small script pulls from the Dataverse Web API and posts each message to the [universal API](/api/tracker).

## What you need

- A Dynamics 365 Customer Service environment, and its URL (for example `https://yourorg.crm.dynamics.com`).
- A Microsoft Entra ID admin, to register an app and create a client secret.
- A Power Platform admin, to add that app to the environment as an application user with a security role that can read cases (`incident`), emails (`email`), notes (`annotation`) and transcripts (`msdyn_transcript`).
- For chat transcripts: Dynamics 365 Contact Center with transcripts turned on for your channels.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Dynamics 365

Microsoft's steps are in [server-to-server authentication](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/use-single-tenant-server-server-authentication).

1. In the Microsoft Entra admin center, register an app (**Entra ID > App registrations > New registration**). Under **Supported account types**, choose **Single tenant only**. No redirect URI is needed.
2. From the app's **Overview**, copy the **Application (client) ID** and the **Directory (tenant) ID**.
3. Under **Certificates & secrets**, choose **+ New client secret**, add it, and copy the secret. You can't view it again.
4. In the [Power Platform admin center](https://learn.microsoft.com/en-us/power-platform/admin/manage-application-users#create-an-application-user), go to **Manage > Environments**, select the environment, then **Settings > Users + permissions > Application users**. Select **+ New app user**, add your app, and give it a security role. Microsoft recommends a custom role for real integrations.
5. Get a token with the client credentials flow: `POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` with `client_id`, `client_secret`, `grant_type=client_credentials` and `scope` set to your environment URL plus `/.default`, for example `https://yourorg.crm.dynamics.com/.default` ([client credentials flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow), [OAuth with Dataverse](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/authenticate-oauth)).

Send the token as `Authorization: Bearer YOUR_TOKEN`. Tokens expire, so request a new one for each run.

## 2. Pull new emails and transcripts

**Case emails.** List emails created since your cursor that are linked to a record, and keep the ones whose regarding record is a case:

```bash
curl -H "Authorization: Bearer $D365_TOKEN" -H "Accept: application/json" \
  -H 'Prefer: odata.include-annotations="Microsoft.Dynamics.CRM.lookuplogicalname"' \
  "https://yourorg.crm.dynamics.com/api/data/v9.2/emails?\$select=activityid,description,directioncode,senton,createdon,statuscode,_regardingobjectid_value&\$filter=createdon%20ge%202026-10-13T00:00:00Z%20and%20_regardingobjectid_value%20ne%20null&\$orderby=createdon%20asc"
```

- `_regardingobjectid_value@Microsoft.Dynamics.CRM.lookuplogicalname` is `incident` for an email on a case ([lookup annotations](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/select-columns)).
- `directioncode` is `true` for an outgoing email and `false` for an incoming one. `description` is the email body, in HTML.
- Get each case once with `GET /api/data/v9.2/incidents({id})?$select=ticketnumber,caseorigincode,casetypecode,prioritycode,statuscode,_customerid_value`. `_customerid_value` is the account or contact the case is for.

**Chat and messaging transcripts.** Microsoft stores each transcript base64 encoded in the notes (`annotations`) table, and says there is no bulk download out of the box ([download transcripts in bulk](https://learn.microsoft.com/en-us/dynamics365/customer-service/develop/download-transcripts-bulk)):

```bash
curl -H "Authorization: Bearer $D365_TOKEN" -H "Accept: application/json" \
  "https://yourorg.crm.dynamics.com/api/data/v9.2/annotations?\$select=annotationid,documentbody,_objectid_value,createdon&\$filter=objecttypecode%20eq%20'msdyn_transcript'%20and%20createdon%20ge%202026-10-13T00:00:00Z"
```

- Decode `documentbody` from base64 to get the transcript JSON. Its messages carry `content`, `createdDateTime`, `from` and `tags`.
- `_objectid_value` is the transcript. `GET /api/data/v9.2/msdyn_transcripts({id})?$select=_msdyn_liveworkitemidid_value` gives the conversation it belongs to.
- Skip control messages (`isControlMessage` is `true`) and system messages (`tags` contains `system`). File attachments are separate annotations and have no text.
- A transcript is only created once the customer has sent at least one message.

Both lists page the same way: up to 5,000 rows per page, then follow `@odata.nextLink` without changing it ([page results](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/page-results)).

Rate limits, per Microsoft: by default each user gets 6,000 requests and 20 minutes of combined execution time in a five-minute sliding window, and 52 concurrent requests, per web server. Going over returns `429` with a `Retry-After` header in seconds ([service protection API limits](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/api-limits)).

## 3. Map the fields

| Dimension Labs | Dynamics 365 | Notes |
|---|---|---|
| `text` | email `description`; transcript message `content` | The code strips the HTML from email bodies. Drafts (`statuscode` 1), control and system messages are skipped. |
| `userId` | case `_customerid_value` | The account or contact id, on every email of the case. Transcripts use the conversation id. |
| `incoming` | email `directioncode`; transcript message `tags` and `from` | Emails: `true` when `directioncode` is `false`. Transcripts: messages tagged `public` (agent to customer) and messages from an `application` (bots and the system) are outgoing; the rest are the customer's. |
| `sessionId` | case `incidentid`; conversation id from the transcript | Prefix it, for example `d365-case-{id}` or `d365-chat-{id}`. |
| `conversationId` | same as `sessionId` | |
| `dimensionlabs_timestamp` | email `senton`, else `createdon`; message `createdDateTime` | Convert with `toMillis`. Transcript messages are kept at least 1,000 ms apart. |
| `platformJson` | case `ticketnumber`, `caseorigincode`, `casetypecode`, `prioritycode`, `statuscode` | Choice columns come back as numbers, for example `caseorigincode` 1 Phone, 2 Email, 3 Web. |

**Internal notes.** Notes on a case are for your team, so this guide leaves them out. Microsoft also describes transcript messages tagged `private` as messages between two service representatives, but its own example of a customer message carries `private` too, so the tags alone can't separate them. Decode a few transcripts from your environment and check the senders before you load history.

**Personal data.** Don't send email addresses or the names in `from.user.displayName`. Emails and chats hold names, addresses and account numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// dynamics-365.mjs. Environment: D365_URL (https://yourorg.crm.dynamics.com), D365_TENANT_ID, D365_CLIENT_ID,
// D365_CLIENT_SECRET, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ORG = process.env.D365_URL.replace(/\/$/, '');
const API = `${ORG}/api/data/v9.2`;
const LOOKUP = '_regardingobjectid_value@Microsoft.Dynamics.CRM.lookuplogicalname';
let token;

async function newToken() {
  const body = new URLSearchParams({
    client_id: process.env.D365_CLIENT_ID, client_secret: process.env.D365_CLIENT_SECRET,
    grant_type: 'client_credentials', scope: `${ORG}/.default`,
  });
  const res = await fetch(`https://login.microsoftonline.com/${process.env.D365_TENANT_ID}/oauth2/v2.0/token`, { method: 'POST', body });
  if (!res.ok) throw new Error(`Token request returned ${res.status}: ${await res.text()}`);
  token = (await res.json()).access_token;
}

// Gets a fresh token on 401, and retries rate limits (429) and server errors (5xx), honoring Retry-After.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    if (!token) await newToken();
    const res = await fetch(url, {
      headers: {
        Authorization: `Bearer ${token}`, Accept: 'application/json', 'OData-MaxVersion': '4.0', 'OData-Version': '4.0',
        Prefer: 'odata.include-annotations="Microsoft.Dynamics.CRM.lookuplogicalname"',
      },
    });
    if (res.ok) return res.json();
    if (res.status === 401 && attempt === 0) { token = null; continue; }
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Dataverse returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Every row of a query, following @odata.nextLink.
async function* rows(url) {
  while (url) {
    const page = await get(url);
    yield* page.value;
    url = page['@odata.nextLink'];
  }
}

const htmlToText = (html) => String(html ?? '')
  .replace(/<(style|script)[^>]*>[\s\S]*?<\/\1>/gi, '')
  .replace(/<br\s*\/?>|<\/(p|div|li|tr)>/gi, '\n')
  .replace(/<[^>]+>/g, '')
  .replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
  .replace(/\n\s*\n+/g, '\n\n')
  .trim();

// Sends every case email and chat transcript created at or after `since` (Unix ms). Returns the new `since`.
export async function syncDynamics({ since, historical = false }) {
  token = null; // a new token for each run
  const started = Date.now();
  const from = new Date(since).toISOString();
  const cases = new Map();
  let newest = since;
  const send = (msg) => sendToDimensionLabs(msg, { historical: historical || msg.dimensionlabs_timestamp < Date.now() - HOUR });

  // Case emails.
  const emails = `${API}/emails?$select=activityid,description,directioncode,senton,createdon,statuscode,_regardingobjectid_value`
    + `&$filter=createdon ge ${from} and _regardingobjectid_value ne null&$orderby=createdon asc`;
  for await (const e of rows(emails)) {
    newest = Math.max(newest, toMillis(e.createdon));
    if (e[LOOKUP] !== 'incident' || e.statuscode === 1) continue; // only emails on cases, no drafts
    const text = htmlToText(e.description);
    if (!text) continue;
    const id = e._regardingobjectid_value;
    if (!cases.has(id)) cases.set(id, await get(`${API}/incidents(${id})?$select=ticketnumber,caseorigincode,casetypecode,prioritycode,statuscode,_customerid_value`));
    const c = cases.get(id);
    await send({
      text,
      userId: c._customerid_value ?? `d365-case-${id}`,
      incoming: e.directioncode === false,
      sessionId: `d365-case-${id}`,
      conversationId: `d365-case-${id}`,
      dimensionlabs_timestamp: toMillis(e.senton ?? e.createdon),
      platformJson: { source: 'email', ticketnumber: c.ticketnumber, caseorigincode: c.caseorigincode, casetypecode: c.casetypecode, prioritycode: c.prioritycode, statuscode: c.statuscode },
    });
  }

  // Chat and messaging transcripts.
  const notes = `${API}/annotations?$select=annotationid,documentbody,_objectid_value,createdon`
    + `&$filter=objecttypecode eq 'msdyn_transcript' and createdon ge ${from}&$orderby=createdon asc`;
  for await (const a of rows(notes)) {
    newest = Math.max(newest, toMillis(a.createdon));
    let doc;
    try { doc = JSON.parse(Buffer.from(a.documentbody ?? '', 'base64').toString('utf8')); } catch { continue; }
    const messages = Array.isArray(doc) ? doc : Object.values(doc ?? {}).find(Array.isArray) ?? [];
    const transcript = await get(`${API}/msdyn_transcripts(${a._objectid_value})?$select=_msdyn_liveworkitemidid_value`);
    const session = `d365-chat-${transcript._msdyn_liveworkitemidid_value ?? a._objectid_value}`;
    let last = 0;
    const turns = messages
      .filter((m) => !m.isControlMessage && !String(m.tags ?? '').split(',').includes('system') && String(m.content ?? '').trim())
      .sort((x, y) => toMillis(x.createdDateTime) - toMillis(y.createdDateTime));
    for (const m of turns) {
      const tags = String(m.tags ?? '').split(',');
      const ts = Math.max(toMillis(m.createdDateTime), last + 1000);
      last = ts;
      await send({
        text: String(m.content).trim(),
        userId: session,
        incoming: !tags.includes('public') && !m.from?.application,
        sessionId: session,
        conversationId: session,
        dimensionlabs_timestamp: ts,
        platformJson: { source: 'transcript', transcriptid: a._objectid_value, deliveryMode: m.deliveryMode },
      });
    }
  }
  // The two queries run one after the other: an email written while the transcripts were being read would be
  // older than the newest transcript but not yet read. Never move the cursor past five minutes before this run started.
  return Math.max(since, Math.min(newest, started - 5 * 60 * 1000));
}
```

Each run starts from `since` again, and never later than five minutes before the previous run started, so a record at the boundary can be sent twice. That's fine: duplicates are removed automatically.

## 5. Run it

Save the code as `dynamics-365.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncDynamics as sync } from './dynamics-365.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every case email and transcript created since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new messages to `/track`, and they are enriched automatically every day. A transcript is written when its conversation ends, so most of its messages are more than an hour old by then: those go to `/trackhistorical`, and need a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

A history load reads every email in the environment, not only those on cases, so it takes a while; the code waits whenever Dataverse asks it to. You can also [upload a file](/data/upload-a-file) of exported emails or transcripts (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Dynamics 365 reference

- [Use single-tenant server-to-server authentication](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/use-single-tenant-server-server-authentication)
- [Manage application users](https://learn.microsoft.com/en-us/power-platform/admin/manage-application-users)
- [Client credentials flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow)
- [Download Dynamics 365 Contact Center transcripts in bulk](https://learn.microsoft.com/en-us/dynamics365/customer-service/develop/download-transcripts-bulk)
- [Transcript (msdyn_transcript) table](https://learn.microsoft.com/en-us/dynamics365/customer-service/develop/reference/entities/msdyn_transcript)
- [Case (incident) table](https://learn.microsoft.com/en-us/dynamics365/developer/reference/entities/incident)
- [Email table](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/email)
- [Note (annotation) table](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/annotation)
- [Page results using OData](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/page-results)
- [Service protection API limits](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/api-limits)

Checked against Microsoft's documentation on 2026-10-26.
