Almost every piece of software that talks to Dynamics 365 CE today does it through the Dataverse Web API — Dataverse being the database and platform underneath Dynamics 365. Whether it's a script pulling data into a spreadsheet, a website's background code fetching related records, or an automated flow's underlying calls, it's all going through the same doorway. That doorway is a standard REST API (a common, well-understood way for one piece of software to ask another for data over the web) built on OData v4, a standard set of conventions for describing those requests. That means a lot of what you may already know about calling APIs applies directly here, but there are a handful of Dataverse-specific habits worth learning before you start. This tutorial covers the practical mechanics — from basic reading and writing through bundling many changes together and connecting real external systems.
What the Web API is, and what it replaced
Before the Web API existed, the main way to talk to Dynamics 365 from outside a plugin was the Organization Service — an older, more rigid style of connection (SOAP-based, for those who know the term) that mostly required a heavy .NET toolkit (an SDK, or software development kit) just to get started. The Web API is the modern replacement: a web address like https://yourorg.crm.dynamics.com/api/data/v9.2/ that any programming language capable of making a basic web request can talk to — not just .NET.
That matters in practice because it opened Dynamics 365 up to a much wider range of tools — scripts written in Python, small services in Node.js, automated cloud workflows, mobile apps — without anyone needing to install a specialized toolkit first. The older Organization Service connection still exists and still gets used, especially inside plugins where the platform already hands your code an authenticated connection for free, but for anything outside the platform, the Web API is the standard route today.
Core actions, mapped to a restaurant order
The Web API uses four familiar types of web requests, and they map neatly onto ordering food at a restaurant:
| Request type | What it does | Restaurant equivalent |
|---|---|---|
| GET | Read a record, or run a search | Looking at the menu, or asking what's in the kitchen |
| POST | Create a new record | Placing a brand-new order |
| PATCH | Update part of an existing record | Asking the waiter to change one item on an order you already placed |
| DELETE | Remove a record | Cancelling the order |
A couple of Dataverse-specific habits are worth knowing early. To connect one record to another — say, setting which Account a Contact belongs to — you don't just write a plain ID into a field; you write it using a special "@odata.bind" syntax that points at the other record's own web address, something like "customerid_account@odata.bind": "/accounts(00000000-0000-0000-0000-000000000000)". And the web addresses for record types are usually just the plural of their name (accounts, contacts, opportunities), which occasionally surprises people the first time they hit a record type with an unusual plural.
Asking for exactly what you need: query options
When you're reading data with a GET request, a handful of extra instructions — tacked onto the web address itself — control exactly what comes back. Using them well is the difference between an API call that returns exactly what a screen needs, and one that drags back far more than necessary, like ordering the entire menu when you only wanted a sandwich:
- $select — pick specific columns only. Skipping this returns every single column on the record, which is wasteful and, on records with a lot of fields, noticeably slower.
- $filter — narrow down which records come back, using comparisons like equals, greater than, or "contains this text."
- $expand — pull in a related record's details in the same response, saving a second trip for simple lookups.
- $orderby — sort the results, smallest-to-largest by default, or reversed.
- $top — cap how many records come back in one page.
GET [Organization URI]/api/data/v9.2/accounts
?$select=name,revenue,telephone1
&$filter=statecode eq 0 and revenue gt 500000
&$expand=primarycontactid($select=fullname,emailaddress1)
&$orderby=revenue desc
&$top=25
One behavior worth knowing up front: if a search would return a lot of records, the Web API automatically splits the results into pages and hands back a link (@odata.nextLink) to fetch the next page, rather than returning everything at once. Code that assumes one response contains the full answer will quietly under-report results once the data grows — always check for that link and follow it if the complete set matters.
Three languages, one destination: FetchXML, QueryExpression, and Web API queries
Dataverse lets you ask the same question three different ways — a bit like asking for directions in three different languages that all get you to the same place. Which one you reach for depends on where your code is running:
| FetchXML | QueryExpression | Web API (OData) | |
|---|---|---|---|
| Looks like | A block of XML | Objects built up in .NET code | A web address with query text tacked on |
| Typically used in | Saved views, reports, or embedded inside other calls | .NET code such as plugins | External apps, websites, anything outside .NET |
| Can it total or group things up? | Yes — sums, counts, averages, group-by | Yes, with its own aggregate settings | Limited — possible but less commonly used |
| Works outside its home tool? | Yes — the same XML works in several places | No — tied to .NET | Yes — standard OData, works from any web request tool |
In practice: if you're already writing .NET code inside a plugin, QueryExpression tends to read more naturally and catches typos before you even run it. If you need a query that has to work as a saved view, a report, and possibly get reused elsewhere, FetchXML is the common language everything understands — and handily, the Web API will accept a FetchXML query directly as a parameter on a GET request, which is a useful escape hatch when a plain web-address query would otherwise get unreasonably complicated (deep nested filters, certain totals). For anything living outside Dynamics 365 entirely, plain Web API query options are usually the simplest choice and need no XML at all.
Batch requests: bundling many actions into one trip
The Web API lets you bundle several actions into a single request instead of sending them one at a time — a bit like handing the waiter a full list of changes at once instead of calling them over individually, using the $batch endpoint. This helps for two reasons: it cuts down on back-and-forth trips when you have many small operations to do, and, when bundled a certain way (as a "changeset"), all of the actions inside succeed or fail together as one unit — if one part fails, none of it is kept.
This is the right tool when related records need to go in together and stay consistent — an order and its line items, for example — without writing custom server-side code just to enforce that they rise or fall as one. It's overkill for a single one-off update, and most toolkits used to call the Web API already come with a built-in helper for building these bundles, so you rarely have to construct one by hand.
Calling the Web API from an outside application
A common setup is a standalone program or scheduled job that signs in (through Azure AD, also known as Microsoft Entra ID — Microsoft's identity service) and then talks to the Web API directly over the web to read or write Dynamics 365 records — no plugin, no special toolkit, just a basic web request with a security token attached. Here's a minimal, illustrative example that updates a contact's phone number:
using var client = new HttpClient { BaseAddress = new Uri(orgUrl) };
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Add("OData-MaxVersion", "4.0");
client.DefaultRequestHeaders.Add("OData-Version", "4.0");
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
var body = new StringContent(
"{ \"telephone1\": \"555-0148\" }",
Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(
HttpMethod.Patch,
$"api/data/v9.2/contacts({contactId})")
{
Content = body
};
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
Strip away the specifics and the pattern is simple: build a request with the right action type and web address, attach the standard headers, include a small chunk of JSON (a common, simple text format for structured data) describing what's changing, and send it. Most real integrations wrap this in a small reusable helper rather than writing raw requests everywhere, but underneath, the mechanics are exactly this straightforward.
Authentication: proving who you are without a human logging in
Server-to-server connections — one system talking to another with nobody sitting at a keyboard — authenticate using an Azure AD app registration: essentially, an identity created for the piece of software itself rather than for a person, paired with a matching "application user" inside Dynamics 365. The software proves who it is with a client ID and a secret (or a certificate), not a username and password, following what's called the OAuth 2.0 client credentials flow. That app registration gets a security role in Dynamics 365 just like a real employee's keycard would, controlling exactly what the integration is and isn't allowed to touch — following the same access rules covered in the fundamentals tutorial.
The practical benefit over signing in as a real person is stability: a connection tied to a human's own login breaks the moment that person changes their password or leaves the company, while an app registration's credentials are managed completely independently of any one person — a detail that matters more than it sounds, the first time an integration mysteriously stops working after an unrelated HR change.
Common integration patterns
A handful of shapes come up again and again across Dynamics 365 CE integration projects, regardless of industry:
- ERP syncing — orders, invoices, or pricing data flowing between Dynamics 365 and a back-office accounting and operations system (an ERP), often in both directions, with Dynamics 365 owning the customer-facing relationship and the ERP owning the financial and fulfillment side. This usually needs a clear decision, field by field, about which system is the "source of truth" — otherwise the two systems end up quietly disagreeing with each other.
- Middleware-driven sync — using a dedicated integration platform to handle the translating and routing between Dynamics 365 and other systems, rather than writing point-to-point custom code for every connection, which tends to hold up better as more systems get added over time.
- Line-of-business app integration — a custom internal application (a booking tool, a portal, an industry-specific system) reading or writing Dynamics 365 records directly, treating it as a shared backend rather than a walled-off CRM.
- Event-driven syncing — using plugins or automated flows triggered by record changes to push updates outward the moment they happen, rather than relying purely on checking for changes on a schedule.
Choosing between "check for changes on a schedule" and "push updates the moment something happens" is one of the more consequential integration decisions on a project. Checking on a schedule is simpler to build and reason about, but adds delay and does unnecessary work when nothing has actually changed. Pushing updates the instant something happens is more responsive, but adds moving parts — queues, retry logic, and making sure the same update never gets applied twice — that need to be planned for deliberately rather than patched in after something breaks in production.