Skip to content

02 Get your data in /Integrations

Microsoft Dynamics 365 Customer Service

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

  1. MD Microsoft Dynamics 365 Customer ServiceTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

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.

  • 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.

Microsoft’s steps are in server-to-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, 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, OAuth with Dataverse).

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

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

Terminal window
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).
  • 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):

Terminal window
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).

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).

Dimension LabsDynamics 365Notes
textemail description; transcript message contentThe code strips the HTML from email bodies. Drafts (statuscode 1), control and system messages are skipped.
userIdcase _customerid_valueThe account or contact id, on every email of the case. Transcripts use the conversation id.
incomingemail directioncode; transcript message tags and fromEmails: 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.
sessionIdcase incidentid; conversation id from the transcriptPrefix it, for example d365-case-{id} or d365-chat-{id}.
conversationIdsame as sessionId
dimensionlabs_timestampemail senton, else createdon; message createdDateTimeConvert with toMillis. Transcript messages are kept at least 1,000 ms apart.
platformJsoncase ticketnumber, caseorigincode, casetypecode, prioritycode, statuscodeChoice 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.

// dynamics-365.mjs. Environment: D365_URL (https://yourorg.crm.dynamics.com), D365_TENANT_ID, D365_CLIENT_ID,
// D365_CLIENT_SECRET, DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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.

Save the code as dynamics-365.mjs in a project set up as in Set up your project, and run it with the 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 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 of exported emails or transcripts (check it with the file checker), then run a backfill.

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