Sam's Dynamics, Power Platform & AI Blog

Monday, 14 September 2026

Calling the Dataverse Web API from Dynamics 365 Form Scripts

Everything so far in this series has stayed on the form: fields, events, lookups, subgrids. This post steps off the form and into Dataverse directly, using the Web API to read and write records the form doesn't already have loaded — the foundation you need before the next post, where this starts connecting to Copilot Studio agents.

Why Call the Web API from a Form Script

A few common reasons: pulling in data that isn't on the form (like a related account's credit limit), creating a related record without navigating the user away, running a real-time check against other records before save, or calling a custom Dataverse action. All of it goes through Xrm.WebApi, and every method returns a Promise.

The Basics: Xrm.WebApi

// Read one record
Xrm.WebApi.retrieveRecord("account", accountId, "?$select=name,creditlimit");

// Read multiple records
Xrm.WebApi.retrieveMultipleRecords("contact", "?$select=fullname&$filter=parentcustomerid_value eq " + accountId);

// Create a record
Xrm.WebApi.createRecord("task", {
    subject: "Follow up",
        "regardingobjectid_account@odata.bind": "/accounts(" + accountId + ")"
        });
        
        // Update a record
        Xrm.WebApi.updateRecord("account", accountId, { creditlimit: 50000 });

Note the entity set names are plural and lowercase (accounts, contacts), but retrieveRecord/createRecord/updateRecord take the singular logical name (account) — it's a common source of confusion.

Reading a Related Record

Here's a real example: when a contact form loads, pull the parent account's credit limit and show it in a notification:

async function onLoad(executionContext) {
    var formContext = executionContext.getFormContext();
        var accountValue = formContext.getAttribute("parentcustomerid").getValue();
            if (!accountValue) return;
            
                var accountId = accountValue[0].id.replace("{", "").replace("}", "");
                
                    try {
                            var result = await Xrm.WebApi.retrieveRecord("account", accountId, "?$select=creditlimit");
                                    formContext.ui.setFormNotification(
                                                "Account credit limit: " + (result.creditlimit || 0),
                                                            "INFO",
                                                                        "credit_limit_info"
                                                                                );
                                                                                    } catch (error) {
                                                                                            console.error("Failed to retrieve account credit limit", error);
                                                                                                }
                                                                                                }

Creating a Related Record

async function logFollowUpTask(executionContext) {
    var formContext = executionContext.getFormContext();
        var contactId = formContext.data.entity.getId().replace("{", "").replace("}", "");
        
            var task = {
                    subject: "Follow up on discount request",
                            "regardingobjectid_contact@odata.bind": "/contacts(" + contactId + ")"
                                };
                                
                                    try {
                                            await Xrm.WebApi.createRecord("task", task);
                                                } catch (error) {
                                                        console.error("Failed to create follow-up task", error);
                                                            }
                                                            }

A Practical Example: Real-Time Duplicate Check on Save

Combining an async Web API call with OnSave, including blocking the save until the check completes:

formContext.data.entity.addOnSave(onSaveCheckDuplicate);

async function onSaveCheckDuplicate(executionContext) {
    var eventArgs = executionContext.getEventArgs();
        var formContext = executionContext.getFormContext();
            var email = formContext.getAttribute("emailaddress1").getValue();
            
                if (!email) return;
                
                    eventArgs.preventDefault(); // pause the save while we check
                    
                        try {
                                var query = "?$select=contactid&$filter=emailaddress1 eq '" + email + "'";
                                        var result = await Xrm.WebApi.retrieveMultipleRecords("contact", query);
                                        
                                                var isDuplicate = result.entities.some(function (c) {
                                                            return c.contactid !== formContext.data.entity.getId().replace("{", "").replace("}", "");
                                                                    });
                                                                    
                                                                            if (isDuplicate) {
                                                                                        formContext.ui.setFormNotification("A contact with this email already exists.", "ERROR", "dup_check");
                                                                                                } else {
                                                                                                            formContext.data.entity.save(); // safe to save now
                                                                                                                    }
                                                                                                                        } catch (error) {
                                                                                                                                console.error("Duplicate check failed", error);
                                                                                                                                        formContext.data.entity.save(); // don't block save on a broken check
                                                                                                                                            }
                                                                                                                                            }

Common Pitfalls

  • Forgetting await. Without it, your code moves on before the API call resolves, and you end up reading undefined data or saving before a check completes.
  • Not handling rejected promises. A network error or a security-role denial throws — always wrap calls in try/catch.
  • Wrong entity set name. retrieveMultipleRecords needs the plural set name; retrieveRecord/createRecord/updateRecord need the singular logical name.
  • Too many calls per save. Each Web API call is a real HTTP request; batch or cache where you can instead of calling it inside a loop.

What's Next

Last post in this series: wiring a Copilot Studio agent action into a JavaScript-driven ribbon button — where form scripting and agents meet.

Working with Lookups, Option Sets, and Subgrids in Dynamics 365 Forms Using JavaScript

So far in this series we've covered basic form events and field-level show/hide/required logic. This post covers the three field types that trip people up most: lookups, option sets, and subgrids — including how to filter a lookup so users only see relevant records.

Reading and Setting Lookup Values

A lookup's value is always an array of objects, even though it only ever holds one record on most forms. Each object has id, name, and entityType:

var formContext = executionContext.getFormContext();
var lookupValue = formContext.getAttribute("parentcustomerid").getValue();

if (lookupValue) {
    var id = lookupValue[0].id;
        var name = lookupValue[0].name;
            var entityType = lookupValue[0].entityType;
            }
            
            // Setting a lookup value
            formContext.getAttribute("parentcustomerid").setValue([
                {
                        id: "00000000-0000-0000-0000-000000000000",
                                name: "Contoso Ltd",
                                        entityType: "account"
                                            }
                                            ]);

Filtering a Lookup's Results

By default a lookup searches the whole entity. To narrow it — say, only show contacts belonging to the account selected elsewhere on the form — use addPreSearch with a custom filter:

function onLoad(executionContext) {
    var formContext = executionContext.getFormContext();
        formContext.getControl("primarycontactid").addPreSearch(filterContactsByAccount);
        }
        
        function filterContactsByAccount(executionContext) {
            var formContext = executionContext.getFormContext();
                var accountValue = formContext.getAttribute("parentaccountid").getValue();
                
                    if (accountValue) {
                            var accountId = accountValue[0].id.replace("{", "").replace("}", "");
                                    var filter = "<filter type='and'>" +
                                                "<condition attribute='parentcustomerid' operator='eq' value='" + accountId + "' />" +
                                                            "</filter>";
                                                                    formContext.getControl("primarycontactid").addCustomFilter(filter, "contact");
                                                                        }
                                                                        }

One gotcha: addCustomFilter only applies to the next search, so it has to run inside addPreSearch every time, not just once on load.

Working with Option Sets

Option sets (choice fields) store and return the underlying numeric value, not the label. Use getOption or getOptions when you need the label or want to build the list dynamically:

var attribute = formContext.getAttribute("new_prioritylevel");

// Read/write the numeric value
var currentValue = attribute.getValue(); // e.g. 2
attribute.setValue(1);

// Get the label for the current value
var options = attribute.getOptions();
var selected = options.find(function (o) { return o.value === currentValue; });
console.log(selected ? selected.text : "none selected");

// Remove an option a user shouldn't be able to pick
var control = formContext.getControl("new_prioritylevel");
control.removeOption(3);

Refreshing a Subgrid

Subgrids don't automatically know when related data changes elsewhere on the form. Refresh one explicitly after an action that affects its rows:

var gridControl = formContext.getControl("Subgrid_Orders");
if (gridControl) {
    gridControl.refresh();
    
        // Reading the currently loaded rows
            var grid = gridControl.getGrid();
                var rows = grid.getRows();
                    console.log("Row count: " + rows.getLength());
                    }

Common Pitfalls

  • Treating a lookup value as a single object. It's always an array — forgetting the [0] is one of the most common form-script bugs.
  • Comparing option set values as strings. They're numbers; "2" === 2 is false in JavaScript.
  • Custom filters that don't reset. If you conditionally apply a filter, make sure the "else" branch clears it, or users will get a stale filter after changing the related field.
  • Calling grid methods before the subgrid has loaded. On OnLoad, wrap subgrid access in the control's onLoad event rather than assuming it's ready immediately.

What's Next

Next post: calling the Dataverse Web API directly from a form script — reading related records, creating child rows, and handling the async responses properly.

Field-Level Logic in Dynamics 365 Forms: Show/Hide, Enable/Disable, and Required Levels with JavaScript

In the first post of this series, we got a form script loading and reacting to a status change. This post goes deeper into the four operations you'll use in almost every form script: showing/hiding, enabling/disabling, setting required levels, and setting values — plus when you should reach for a Business Rule instead of writing code at all.

The Four Core Operations

Every field-level customization comes down to some combination of these, called on an attribute or its control via formContext:

var formContext = executionContext.getFormContext();
var control = formContext.getControl("new_discountreason");
var attribute = formContext.getAttribute("new_discountreason");

// 1. Show or hide a field
control.setVisible(true);

// 2. Enable or disable a field
control.setDisabled(false);

// 3. Set the required level
attribute.setRequiredLevel("required"); // "none", "recommended", or "required"

// 4. Set a value
attribute.setValue("Loyalty discount");

Business Rule vs. JavaScript: When to Use Which

Business Rules are the right first choice for simple, single-form or cross-form logic — they're faster to build, easier for other admins to maintain, and don't need a developer to change later. Reach for JavaScript when you need:

  • Logic that depends on more than the handful of conditions a Business Rule can cleanly express
  • Real-time reaction to typing, not just on change/save
  • Calls to the Web API, external services, or anything Business Rules simply can't do
  • Custom UI behavior — tab/section visibility, notifications, ribbon interaction

A good rule of thumb: if you can describe the logic in one sentence with "if this field equals X, then that field is required," use a Business Rule. If you're combining three or four conditions across multiple fields, JavaScript will be far easier to read and maintain.

A Practical Example: Multi-Field Conditional Logic

Here's a script that shows a discount reason field only when a discount is applied above a threshold, and only for certain customer types — logic that would get unwieldy fast in a Business Rule:

function onLoad(executionContext) {
    var formContext = executionContext.getFormContext();
        formContext.getAttribute("new_discountpercent").addOnChange(toggleDiscountReason);
            formContext.getAttribute("new_customertype").addOnChange(toggleDiscountReason);
                toggleDiscountReason(executionContext);
                }
                
                function toggleDiscountReason(executionContext) {
                    var formContext = executionContext.getFormContext();
                        var discount = formContext.getAttribute("new_discountpercent").getValue() || 0;
                            var customerType = formContext.getAttribute("new_customertype").getValue();
                                var reasonControl = formContext.getControl("new_discountreason");
                                    var reasonAttr = formContext.getAttribute("new_discountreason");
                                    
                                        var needsReason = discount > 10 && (customerType === 1 || customerType === 2);
                                        
                                            reasonControl.setVisible(needsReason);
                                                reasonAttr.setRequiredLevel(needsReason ? "required" : "none");
                                                    if (!needsReason) {
                                                            reasonAttr.setValue(null);
                                                                }
                                                                }

Section and Tab Visibility

The same show/hide pattern applies to whole sections and tabs, which is handy for progressively revealing parts of a long form:

var tab = formContext.ui.tabs.get("tab_shipping");
tab.setVisible(true);

var section = tab.sections.get("section_shippingdetails");
section.setVisible(true);

Common Pitfalls

  • setRequiredLevel takes a string, not a boolean. Use "none", "recommended", or "required" — not true/false.
  • Disabled fields still have values. setDisabled(true) stops editing but doesn't clear the value; clear it explicitly if that's what you need.
  • Hidden required fields block save. If a field is required but hidden, users get stuck. Always pair setVisible(false) with setRequiredLevel("none").
  • Re-run your logic on load, not just on change. If a record is opened with values already set, onChange handlers won't fire automatically — call your function once during OnLoad too, as in the example above.

What's Next

Next up: working with lookups, option sets, and subgrids from JavaScript — including how to filter a lookup's results based on another field on the form.

Packaging and Deploying Web Resources: A Practical ALM Workflow for Dynamics 365 JavaScript

You've written the script. You've unit-tested the logic and stepped through it in dev tools. It works perfectly on your form. Now co...