When a customer calls your contact center, the agent should see who’s calling before they say hello. Dynamics 365 Contact Center does this automatically by matching the caller’s number to a contact or account in Dataverse.
For one of our clients, that wasn’t happening for many callers. Agents were answering calls with no customer record attached, then searching for the customer by hand while the caller waited. Most of these callers had their number saved in the contact’s Business Phone field, not Mobile Phone.
Why it happens
Out of the box, the voice channel identifies a contact only by Mobile Phone. For accounts it uses the main phone field. Microsoft documents this in Identify customers automatically.
That works for consumer businesses, where customers call from their cell phones. In B2B, many contacts only have a direct office line on file, so they never match.
The matching logic lives in a record identification rule. This is a FetchXML query stored on each voice workstream, not a single global setting. Our client had many workstreams, so a one-off change wouldn’t cover them all.
How we fixed it
Microsoft supports adding other phone fields to the rule, as described in Enable fields for identifying customers. We changed the contact check so the caller’s number can match either Mobile Phone or Business Phone:
<filter type="or">
<condition attribute="mobilephone" operator="eq" value="${msdyn_fromphone}" />
<condition attribute="telephone1" operator="eq" value="${msdyn_fromphone}" />
</filter>
We applied the same change to the case rule, so open cases are found through the contact’s business number too.
To cover every workstream at once, we wrote a small script that runs in the browser console of the Dynamics 365 app. It:
- Finds every voice workstream in the environment.
- Reads each one’s existing rule and adds Business Phone next to Mobile Phone, keeping any other customizations.
- Runs first as a dry run and shows what it would change, then applies the update.
- Skips workstreams that already include Business Phone, so it’s safe to run again.
The full script
Open any page of your Dynamics 365 model-driven app, press F12, and paste this into the browser console. Leave DRY_RUN = true for the first run and review the table it prints. Then set it to false and run it again to apply the changes.
Disclaimer: This script is provided as is, with no warranties of any kind, express or implied. Review it before use, and run it in your DEV environment first before applying it to any other environment.
// Adds Business Phone (telephone1) as an OR alternative to Mobile Phone
// in the Contact and Case record identification rules of every voice workstream.
//
// Run in the browser console on a model-driven app page (needs Xrm).
// Start with DRY_RUN = true, review the table, then set it to false and run again.
(async function addBusinessPhoneToVoiceWorkstreams() {
const DRY_RUN = true;
const VOICE_STREAM_SOURCE = 192440000; // msdyn_streamsource "Voice" - verify in your org
const PHONE_TOKEN = '${msdyn_fromphone}'; // literal, not a template string
const orgUrl = Xrm.Utility.getGlobalContext().getClientUrl();
const api = `${orgUrl}/api/data/v9.2`;
const headers = {
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0"
};
// 1. Load every workstream (with paging).
const workstreams = [];
let url = `${api}/msdyn_liveworkstreams` +
`?$select=msdyn_liveworkstreamid,msdyn_name,msdyn_streamsource,msdyn_recordidentificationrule`;
while (url) {
const res = await fetch(url, {
headers: { ...headers, "Prefer": "odata.maxpagesize=200" }
});
if (!res.ok) throw new Error(`Query failed: ${res.status} ${await res.text()}`);
const json = await res.json();
workstreams.push(...json.value);
url = json["@odata.nextLink"];
}
const results = [];
for (const ws of workstreams) {
const xml = ws.msdyn_recordidentificationrule;
const isVoice =
ws.msdyn_streamsource === VOICE_STREAM_SOURCE ||
(xml && xml.includes("msdyn_fromphone"));
if (!isVoice) continue;
const row = { workstream: ws.msdyn_name, id: ws.msdyn_liveworkstreamid };
if (!xml) {
results.push({ ...row, status: "SKIPPED: no rule stored on this workstream" });
continue;
}
const doc = new DOMParser().parseFromString(xml, "application/xml");
if (doc.querySelector("parsererror")) {
results.push({ ...row, status: "SKIPPED: existing XML is invalid" });
continue;
}
// 2. Wrap each Mobile Phone condition in <filter type="or"> with a Business Phone twin.
let changes = 0;
for (const rule of doc.getElementsByTagName("RecordIdentificationRule")) {
const primary = rule.getElementsByTagName("PrimaryEntity")[0];
const collection = primary && primary.getAttribute("LogicalCollectionName");
if (collection !== "contacts" && collection !== "incidents") continue;
const mobileConditions = [...rule.getElementsByTagName("condition")].filter(c =>
c.getAttribute("attribute") === "mobilephone" &&
c.getAttribute("value") === PHONE_TOKEN
);
for (const mobile of mobileConditions) {
const alias = mobile.getAttribute("entityname"); // e.g. "co" on the Case rule
if (collection === "incidents" && !alias) continue; // only the linked contact
const parent = mobile.parentNode;
const alreadyDone =
parent.getAttribute("type") === "or" &&
[...parent.children].some(c =>
c.tagName === "condition" &&
c.getAttribute("attribute") === "telephone1" &&
c.getAttribute("value") === PHONE_TOKEN &&
c.getAttribute("entityname") === alias
);
if (alreadyDone) continue;
const business = mobile.cloneNode(false);
business.setAttribute("attribute", "telephone1");
const orFilter = doc.createElement("filter");
orFilter.setAttribute("type", "or");
parent.replaceChild(orFilter, mobile);
orFilter.appendChild(mobile);
orFilter.appendChild(business);
changes++;
}
}
if (changes === 0) {
results.push({ ...row, status: "NO CHANGE: already includes Business Phone (or no Mobile Phone match found)" });
continue;
}
const newXml = new XMLSerializer().serializeToString(doc);
if (DRY_RUN) {
results.push({ ...row, status: `WOULD UPDATE: ${changes} condition(s)` });
continue;
}
// 3. Save.
const patch = await fetch(`${api}/msdyn_liveworkstreams(${ws.msdyn_liveworkstreamid})`, {
method: "PATCH",
headers: { ...headers, "If-Match": "*" },
body: JSON.stringify({ msdyn_recordidentificationrule: newXml })
});
results.push({
...row,
status: patch.ok
? `UPDATED: ${changes} condition(s)`
: `FAILED: ${patch.status} ${await patch.text()}`
});
}
console.table(results);
console.log(DRY_RUN
? "Dry run only. Set DRY_RUN = false and run again to apply."
: "Done.");
})();
To confirm the fix, place a test call from a number stored only in a contact’s Business Phone. The conversation should open with that contact linked.
Tips if you make the same change
- Use one phone format. Matching is exact. Incoming numbers arrive as
+12485551234, so store Business Phone the same way or it won’t match. - Watch for shared numbers. If more than one contact matches, Dynamics links none of them. Coworkers listed with the same company main line will hit this.
- Plan for new workstreams. A new voice workstream starts with the default Mobile-only rule. Rerun the script whenever you add one.
Small configuration details like this decide whether a contact center feels connected or clunky. If your agents are still searching for callers by hand, BlueSky can help.





