Dynamics 365 Web API: The Complete CRUD, FetchXML and Actions Reference
Between 2018 and 2022 I published a series of short posts on this blog, each covering one Dynamics 365 Web API operation: create, retrieve, update, delete, associate, disassociate, calling actions. They were written as notes to myself while working on real implementations, and over the years they became the posts people landed on most often.
The problem with that format is that nobody works on one operation in isolation. When you write JavaScript for a Dynamics 365 form you usually need three or four of these at once, and jumping between ten browser tabs to copy each helper is a poor way to spend an afternoon. So I have merged the whole series into this single reference, corrected a couple of mistakes that were in the original snippets, added the missing helper function they all quietly depended on, and brought the guidance up to date for Dynamics 365 in 2026.
Everything below is code I have actually used in production environments. Where something is now deprecated I have said so and shown the modern equivalent, because plenty of organisations are still running solutions written against the older patterns and you need to be able to read both.
What the Web API is, and when to use it
Microsoft introduced the Web API with Dynamics CRM 2016. It is an OData v4 endpoint, and it replaced the older 2011 SOAP endpoint and the OData v2 REST endpoint (both of which have since been removed). It is the supported way to read and write Dataverse data from JavaScript, from external applications, and from anything that can speak HTTP.
The endpoint lives at:
[Organisation URI]/api/data/v9.2/The version number in the path matters. My original posts were written against v8.0, v8.1 and v8.2 because that was current at the time. Those paths still resolve on most environments, but there is no reason to write new code against them — use v9.2, or whatever the current version is for your environment. You can confirm what your environment supports by opening [Organisation URI]/api/data/ in a browser while signed in.
A short orientation before the code:
- You address a table by its entity set name, which is the plural logical name —
accounts,contacts,opportunities,new_projects. Getting this wrong is the single most common cause of a 404 from the Web API. Irregular plurals are not guessed for you; check the metadata if you are unsure. - A successful create returns
204 No Content, not200. The new record's ID comes back in theOData-EntityIdresponse header, not in the body. - A successful update, delete, associate and disassociate also return
204. - Lookups are set with the
@odata.bindannotation and a navigation-property path, never by assigning a raw GUID. - Errors come back as JSON with an
errorobject containingcodeandmessage.
The helper every one of these snippets needed
Several of the original posts called a function named removeCurlyBraces without ever defining it, which is a fair criticism of the series and something more than one reader emailed me about. Dynamics returns record IDs wrapped in braces — {A1B2C3D4-...} — and the Web API will not accept them in that form. Here it is:
function removeCurlyBraces(id) {
if (!id) { return id; }
return id.replace("{", "").replace("}", "");
}Call it on anything you get from getId() or from a lookup value before you put it in a URL.
Create a record
/*
entityPlurarName: plural entity logical name, e.g. accounts, opportunities
entityObject: object containing the fields and values to set
*/
function createRecord(entityPlurarName, entityObject) {
var id = null;
var req = new XMLHttpRequest();
req.open("POST", Xrm.Page.context.getClientUrl() + "/api/data/v8.2/" + entityPlurarName, false);
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.send(JSON.stringify(entityObject));
if (req.readyState === 4) {
if (req.status === 204) {
var uri = req.getResponseHeader("OData-EntityId");
var regExp = /\(([^)]+)\)/;
var matches = regExp.exec(uri);
id = matches[1];
}
else {
var error = JSON.parse(req.response).error;
Xrm.Utility.alertDialog(error.message);
}
}
return id;
}And calling it:
var account = {};
account["primarycontactid@odata.bind"] = "/contacts(" + removeCurlyBraces(Xrm.Page.getAttribute("primarycontactid").getValue()[0].id) + ")"; // lookup
account["name"] = "Test Account name"; // single line of text
account["paymenttermscode"] = "1"; // option set, integer value as string
account["creditonhold"] = true; // two options
var accountid = createRecord("accounts", account);Note the regular expression at the end. The OData-EntityId header comes back as a full URL like https://org.crm.dynamics.com/api/data/v8.2/accounts(guid), so the GUID has to be pulled out of the parentheses. That is what the regExp.exec is doing.
Retrieve a single record
/*
entityName: plural entity logical name
entityId: GUID of the record
returns: the record, or null
*/
function retrieveEntityById(entityName, entityId) {
entityId = entityId.replace('{', '').replace('}', '');
var data = null;
var req = new XMLHttpRequest();
req.open('GET', Xrm.Page.context.getClientUrl() + "/api/data/v8.2/" + entityName + "(" + entityId + ")", false);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.setRequestHeader("Prefer", "odata.include-annotations=*");
req.send();
if (req.readyState == 4) {
if (req.status == 200) {
data = JSON.parse(req.response);
}
else {
var error = JSON.parse(req.response).error;
console.log(error.message);
}
}
return data;
}var accountId = Xrm.Page.data.entity.getId();
var account = retrieveEntityById("accounts", accountId);
if (account != null) {
var accountName = account.name;
var accountNumber = account.accountnumber;
}The Prefer: odata.include-annotations=* header is worth understanding rather than copying blindly. Without it you get raw values only: an option set comes back as an integer and a lookup as a GUID. With it you also get the formatted values — the option set label, the lookup's display name — as extra properties suffixed with @OData.Community.Display.V1.FormattedValue. If you are putting values on a form or in a dialog, that is almost always what you want.
One efficiency note that the original post did not make: always add a $select. A bare retrieve returns every column on the table, which on a heavily customised account or contact can be a large payload for the sake of two fields.
var account = retrieveEntityById("accounts", accountId + ")?$select=name,accountnumber&dummy=(");That trick is ugly. In practice, add a query parameter to the function signature as in the next section instead.
Retrieve multiple records
This is the snippet with the bug in it. The original function took a parameter called entityName but used entityPlurarName inside the URL, which is not defined in that scope. It happened to work for anyone who also had the create-record helper loaded on the same form, and failed with a confusing "entityPlurarName is not defined" for everyone else. Corrected version:
/*
entityPlurarName: plural entity logical name
query: the OData query string, starting with ?
returns: an object whose "value" property is the array of records
*/
function retrieveMultiple(entityPlurarName, query) {
var data = null;
var req = new XMLHttpRequest();
req.open('GET', Xrm.Page.context.getClientUrl() + "/api/data/v8.2/" + entityPlurarName + query, false);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.send();
if (req.readyState == 4) {
if (req.status == 200) {
data = JSON.parse(req.response);
}
else {
var error = JSON.parse(req.response).error;
console.log(error.message);
}
}
return data;
}var data = retrieveMultiple('roles', "?$select=roleid&$filter=name eq 'Project Manager'");
if (data != null && data.value.length > 0) {
var PMRoleID = data.value[0].roleid;
}Things worth knowing about the query string:
$selectlimits the columns. Use it every time.$filteruses OData operators —eq,ne,gt,lt,and,or— and string values go in single quotes. A single quote inside a value must be doubled.$toplimits the number of rows. For anything larger, set aPrefer: odata.maxpagesize=nheader and follow the@odata.nextLinkin the response.$expandpulls related records in the same call, which usually beats a second round trip.$orderbysorts.
Retrieve multiple with FetchXML
OData query syntax is fine for simple filters, but the moment you need a link-entity, an aggregate or a not-in condition, FetchXML is far easier — and you can build it in the Advanced Find window and download it rather than writing it by hand.
/*
entityPlurarName: plural entity logical name
fetchXml: the FetchXML query as a string
returns: entity collection object
*/
function executeFetchXml(entityPlurarName, fetchXml) {
var data = null;
var req = new XMLHttpRequest();
req.open('GET', Xrm.Page.context.getClientUrl() + "/api/data/v8.1/" + entityPlurarName + "?fetchXml=" + encodeURIComponent(fetchXml), false);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.send();
if (req.readyState === 4) {
if (req.status === 200) {
data = JSON.parse(req.response);
}
else {
var error = JSON.parse(req.response).error;
console.log(error.message);
}
}
return data;
}var fetchXml = " <fetch version='1.0' output-format='xml-platform' mapping='logical' distinct='false'>" +
" <entity name='account'>" +
" <attribute name='name' />" +
" <attribute name='primarycontactid' />" +
" <attribute name='telephone1' />" +
" <attribute name='accountid' />" +
" <order attribute='name' descending='false' />" +
" <filter type='and'>" +
" <condition attribute='modifiedon' operator='last-seven-days' />" +
" </filter>" +
" </entity>" +
" </fetch>";
var data = executeFetchXml('accounts', fetchXml);
if (data !== null && data.value.length > 0) {
console.log("Account Name: " + data.value[0].name);
}The encodeURIComponent is not optional. FetchXML is full of angle brackets, quotes and ampersands, and an unencoded query will fail in ways that are painful to diagnose. Note also that the entity set name in the URL and the entity name inside the FetchXML must agree — plural in the URL, singular inside the <entity> element. That mismatch catches people out constantly.
Update a record
/*
entityPlurarName: plural entity logical name
id: GUID of the record to update
entityObject: object containing the fields and values to change
*/
function updateRecord(entityPlurarName, id, entityObject) {
var IsUpdated = false;
var req = new XMLHttpRequest();
req.open("PATCH", Xrm.Page.context.getClientUrl() + "/api/data/v8.2/" + entityPlurarName + "(" + id + ")", false);
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.send(JSON.stringify(entityObject));
if (req.readyState === 4) {
if (req.status === 204) {
IsUpdated = true;
}
else {
var error = JSON.parse(req.response).error;
Xrm.Utility.alertDialog(error.message);
}
}
return IsUpdated;
}var account = {};
account["name"] = "Test Account name 2";
account["paymenttermscode"] = "4";
account["creditonhold"] = false;
updateRecord("accounts", removeCurlyBraces(Xrm.Page.data.entity.getId()), account);PATCH is a partial update: only the properties present in the object are written, everything else is left alone. Be careful with this in plugins — every property you send counts as a change and will fire your update logic, so send only what actually changed rather than the whole record back.
Delete a record
function deleteRecord(entityPlurarName, id) {
id = id.replace('{', '').replace('}', '');
var IsDeleted = false;
var serverURL = Xrm.Page.context.getClientUrl();
var req = new XMLHttpRequest();
req.open("DELETE", serverURL + "/api/data/v8.2/" + entityPlurarName + "(" + id + ")", false);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.send();
if (req.readyState == 4) {
if (req.status == 204) {
IsDeleted = true;
}
else {
var error = JSON.parse(req.response).error;
Xrm.Utility.alertDialog(error.message);
}
}
return IsDeleted;
}Deleting from a form script is something to think twice about. If the record has cascading relationships you may remove far more than you intended, and there is no undo. In most of the projects I have worked on, deactivating — setting statecode and statuscode through an update — turned out to be what the business actually wanted.
Associate and disassociate
These two trip people up more than the CRUD operations, because the URL shape is unusual. You are not posting a record; you are posting a reference to one.
function associateRequest(currentEntityPlurarName, currentEntityId, relationShipName, otherEntityPlurarName, otherEntityId) {
var serverURL = Xrm.Page.context.getClientUrl();
var associate = {};
associate["@odata.id"] = serverURL + "/api/data/v8.2/" + otherEntityPlurarName + "(" + otherEntityId + ")";
var req = new XMLHttpRequest();
req.open("POST", serverURL + "/api/data/v8.2/" + currentEntityPlurarName + "(" + currentEntityId + ")/" + relationShipName + "/$ref", true);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
if (this.readyState == 4) {
req.onreadystatechange = null;
if (this.status == 204) {
// associated
} else {
var error = JSON.parse(this.response).error;
Xrm.Utility.alertDialog(error.message);
}
}
};
req.send(JSON.stringify(associate));
}function disAssociateRequest(currentEntityPlurarName, currentEntityId, relationShipName, otherEntityPlurarName, otherEntityId) {
var serverURL = Xrm.Page.context.getClientUrl();
var query = currentEntityPlurarName + "(" + currentEntityId + ")/" + relationShipName + "/$ref?$id=" + serverURL + "/api/data/v8.2/" + otherEntityPlurarName + "(" + otherEntityId + ")";
var req = new XMLHttpRequest();
req.open("DELETE", serverURL + "/api/data/v8.2/" + query, true);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
if (this.readyState == 4) {
req.onreadystatechange = null;
if (this.status == 204) {
// disassociated
} else {
var error = JSON.parse(this.response).error;
Xrm.Utility.alertDialog(error.message);
}
}
};
req.send();
}var currentEntityName = "accounts";
var currentEntityId = removeCurlyBraces(Xrm.Page.data.entity.getId());
var relationShipName = "new_account_contact";
var otherEntityName = "contacts";
var otherEntityId = removeCurlyBraces(Xrm.Page.getAttribute("primarycontactid").getValue()[0].id);
associateRequest(currentEntityName, currentEntityId, relationShipName, otherEntityName, otherEntityId);
// and to remove it again
disAssociateRequest(currentEntityName, currentEntityId, relationShipName, otherEntityName, otherEntityId);Two things to watch. First, relationShipName is the relationship schema name, not the table name and not the lookup field name — open the relationship in the solution explorer and copy it exactly, including case. Second, the ?$id= form shown above is for many-to-many relationships. For a one-to-many relationship you disassociate by deleting the single-valued navigation property on the child record instead, and the $id parameter is not used.
Calling a global action
Actions let you wrap server-side logic and call it from anywhere. A global action is not bound to a table, so the URL is simply the endpoint plus the action name.
/*
actionName: name of the action
param: object containing the input parameters, or null
*/
function callGlobalAction(actionName, param) {
var serverURL = Xrm.Page.context.getClientUrl();
var result = null;
var req = new XMLHttpRequest();
req.open("POST", serverURL + "/api/data/v8.0/" + actionName, false);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
if (this.readyState == 4) {
req.onreadystatechange = null;
if (this.status == 200) {
result = JSON.parse(this.response);
} else if (this.status == 204) {
console.log("Request executed successfully without a response");
} else {
var error = JSON.parse(this.response).error;
alert(error.message);
}
}
};
if (param) {
req.send(window.JSON.stringify(param));
} else {
req.send();
}
return result;
}An action with output parameters returns 200 and a JSON body; one without returns 204 and nothing. Both are success. Handling only 200 is a common mistake that makes a perfectly working action look broken.
Calling an entity-bound action
A bound action runs against a specific record, so the record goes in the URL and the action name is prefixed with the Microsoft.Dynamics.CRM namespace.
function callEntityBasedAction(actionName, entityPlurarName, entityId, param) {
var serverURL = Xrm.Page.context.getClientUrl();
entityId = entityId.replace('{', '').replace('}', '');
var result = null;
var req = new XMLHttpRequest();
req.open("POST", serverURL + "/api/data/v8.0/" + entityPlurarName + "(" + entityId + ")/Microsoft.Dynamics.CRM." + actionName, false);
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.onreadystatechange = function () {
if (this.readyState == 4) {
req.onreadystatechange = null;
if (this.status == 200) {
result = JSON.parse(this.response);
} else if (this.status == 204) {
console.log("Request executed successfully without a response");
} else {
var error = JSON.parse(this.response).error;
alert(error.message);
}
}
};
if (param) {
req.send(window.JSON.stringify(param));
} else {
req.send();
}
return result;
}For a custom action the prefix is your publisher prefix rather than Microsoft.Dynamics.CRM — for example new_MyCustomAction. For the out-of-the-box messages, the Microsoft namespace is correct.
A real example: AddUserToRecordTeam
This one is worth its own section because the parameter shape is not obvious and I lost the better part of a day to it. AddUserToRecordTeam adds a user to a record's access team, and it is bound to systemuser, not to the record you are granting access to. The record itself is passed as a parameter, and both parameters need an explicit @odata.type.
/*
entityId: GUID of the record
entityLogicalName: logical name of the table, e.g. lead, contact
userId: GUID of the systemuser
templateId: team template ID
*/
function createAccessTeam(entityId, entityLogicalName, userId, templateId) {
var parameters = {
Record: { [entityLogicalName + "id"]: entityId, "@odata.type": "Microsoft.Dynamics.CRM." + entityLogicalName },
TeamTemplate: { "teamtemplateid": templateId, "@odata.type": "Microsoft.Dynamics.CRM.teamtemplate" }
};
var req = new XMLHttpRequest();
req.open("POST", Xrm.Page.context.getClientUrl() + "/api/data/v9.1/systemusers(" + userId + ")/Microsoft.Dynamics.CRM.AddUserToRecordTeam", true);
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version", "4.0");
req.setRequestHeader("Accept", "application/json");
req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
req.onreadystatechange = function () {
if (this.readyState === 4) {
req.onreadystatechange = null;
if (this.status === 200) {
var results = JSON.parse(this.response);
} else {
Xrm.Utility.alertDialog(this.statusText);
}
}
};
req.send(JSON.stringify(parameters));
}var entityId = "db4633b7-56d1-e811-a979-000d3af3e0db";
var entityLogicalName = "lead";
var userId = "db4633b7-56d1-e811-a979-000d3af3e0db";
var templateId = "45CED4C4-A971-EB11-A812-000D3AF39D99";
createAccessTeam(entityId, entityLogicalName, userId, templateId);The team template ID is the teamtemplateid of the access team template you configured on the table, which you can read straight out of the teamtemplates table with the retrieve-multiple helper above.
Bringing this up to date for 2026
The functions above are the ones I wrote and used, and they still work. But if you are starting new work today, three things in them are no longer current and you should know why.
1. Xrm.Page is deprecated. It has been since version 9.0. Form scripts should take the execution context as their first parameter and get the form context from it:
function onLoad(executionContext) {
var formContext = executionContext.getFormContext();
var accountId = removeCurlyBraces(formContext.data.entity.getId());
var name = formContext.getAttribute("name").getValue();
}Use Xrm.Utility.getGlobalContext().getClientUrl() in place of Xrm.Page.context.getClientUrl().
2. Synchronous XMLHttpRequest is deprecated in browsers. Every helper above passes false as the third argument to req.open, which blocks the UI thread until the call returns. It made the code easy to read and easy to call, which is why the original posts used it, but browsers have been warning about it for years and it produces a visibly frozen form on a slow connection. New code should be asynchronous and use promises.
3. Xrm.WebApi does most of this for you. Since version 9.0 there has been a supported client API that handles the URLs, headers and parsing. It returns promises and it is what I would reach for now:
// create
Xrm.WebApi.createRecord("account", { name: "Test Account name" }).then(
function (result) { console.log(result.id); },
function (error) { console.log(error.message); }
);
// retrieve
Xrm.WebApi.retrieveRecord("account", accountId, "?$select=name,accountnumber").then(
function (result) { console.log(result.name); },
function (error) { console.log(error.message); }
);
// retrieve multiple
Xrm.WebApi.retrieveMultipleRecords("role", "?$select=roleid&$filter=name eq 'Project Manager'").then(
function (result) { console.log(result.entities.length); },
function (error) { console.log(error.message); }
);
// update
Xrm.WebApi.updateRecord("account", accountId, { creditonhold: false });
// delete
Xrm.WebApi.deleteRecord("account", accountId);Note that Xrm.WebApi takes the singular logical name, while the raw endpoint takes the plural entity set name. That difference alone causes a good number of the errors people hit when they migrate from one to the other.
Actions go through Xrm.WebApi.online.execute, which needs a request object with a getMetadata function describing the operation. It is more verbose than the raw XMLHttpRequest version for a one-off call, which is honestly why the older pattern is still so widespread in the field.
So why keep the older code here at all? Because most Dynamics work is maintenance work. When you open a solution that was written in 2018 you will find exactly these patterns, and being able to read them, spot the deprecated pieces and decide whether to leave them or modernise them is a large part of the job.
Errors you will actually hit
- 404 on a perfectly valid-looking URL. Almost always the entity set name. It is the plural logical name, and irregular plurals are not what you would guess.
- "An undeclared property ... was found" — you set a lookup with a plain GUID instead of using
@odata.bind, or you used the lookup's schema name instead of its navigation property name. - 400 on an option set. Send the integer value, not the label.
- The call succeeds but nothing happens. Check whether you are handling only
200when the operation returns204. - Works for you, fails for a user. The Web API respects security roles. If a user cannot read the table in the UI, the API will not read it for them either.
- "entityPlurarName is not defined". That was my typo in the original retrieve-multiple post, fixed above.
Wrapping up
That is the whole series in one place: create, retrieve, retrieve multiple, FetchXML, update, delete, associate, disassociate, global actions, bound actions, and a worked example with access teams. The individual posts that used to cover these have been retired and now point here, so there is one URL to bookmark instead of a dozen.
If you spot something wrong, or there is an operation you would like added, use the contact page — corrections from readers are how the mistakes in the original series came to light in the first place.
Comments
Post a Comment