Dynamics 365 Dataverse API Capabilities

Type: ERP Integration System: Microsoft Dataverse (Web API v9.2, OData v4.0) Confidence: 0.93 Sources: 8 Verified: 2026-03-01 Freshness: 2026-03-01

TL;DR

System Profile

Microsoft Dataverse is the unified data platform underlying Dynamics 365 customer engagement apps (Sales, Customer Service, Field Service, Marketing) and the entire Power Platform (Power Apps, Power Automate, Power BI, Copilot Studio). The Web API implements OData v4.0 and is the primary integration surface for external applications. This card covers the Dataverse Web API as used by Dynamics 365 CE apps and Power Platform. It does NOT cover the Dynamics 365 Finance & Operations (F&O) data entities API.

PropertyValue
VendorMicrosoft
SystemMicrosoft Dataverse (Dynamics 365 / Power Platform)
API SurfaceOData v4.0 REST (Web API)
Current API Versionv9.2
Editions CoveredAll Dynamics 365 CE editions, Power Apps, Power Automate
DeploymentCloud (Dataverse environments)
API DocsDataverse Web API Overview
StatusGA

API Surfaces & Capabilities

API SurfaceProtocolBest ForMax Records/RequestRate LimitReal-time?Bulk?
Web API (OData v4.0)HTTPS/JSONIndividual CRUD, queries, custom actions5,000 rows/query6,000 req/5min/user/serverYesNo
Web API $batchHTTPS/multipartMulti-operation transactions1,000 ops/batchShared with Web APIYesPartial
SDK for .NET (ExecuteMultiple)HTTPS/SOAP.NET apps, high-throughput bulk1,000 ops/request6,000 req/5min/user/serverYesYes
FetchXML (via Web API)HTTPS/XML-in-URLComplex aggregations, cross-entity joins5,000 rows/pageShared with Web APIYesNo
Change Tracking (delta)HTTPS/OData deltaIncremental sync to external systems5,000 rows/pageShared with Web APINo (polling)Yes
Elastic Tables (Cosmos DB)HTTPS/JSONHigh-volume IoT, logs, time-series500 rows/queryShared with Web APIYesYes
Dataverse Search APIHTTPS/JSONFull-text search across tablesConfigurable1 req/sec/userYesNo

Rate Limits & Quotas

Service Protection Limits (Per-User, Per-Web-Server)

Limit TypeValueWindowNotes
Number of requests6,0005-minute slidingCumulative requests from all connections for one user
Combined execution time1,200 seconds (20 minutes)5-minute slidingTotal server-side compute; batch operations increase this
Concurrent requests52 (or higher)InstantaneousExceeding returns error immediately

Returns HTTP 429 with Retry-After header. Plug-in execution does NOT count against service protection limits but adds to execution time of triggering request. [src1]

Entitlement Limits (Per-License, Per-24-Hours)

License TypeRequests per 24 HoursNotes
Dynamics 365 Enterprise apps (Sales, CS, FS, Finance, SCM)40,000Per user; base license only in attach model
Dynamics 365 Professional (Sales Pro, CS Pro)40,000Per user
Power Apps per user plan40,000Per user
Power Automate per user (Premium)40,000Per user
Dynamics 365 Team Member6,000Per user
Power Apps per app plan6,000Per user
Microsoft 365 licenses6,000Per user
Power Automate per flow plan250,000Per flow (not per user)

Multiple licenses stack. Each CRUD inside $batch counts individually toward entitlement. [src2]

Non-Licensed User Pool (Tenant-Level)

Tenant TypePool Size
Dynamics 365 Enterprise/Professional tenants500,000 base + 5,000/user license (max 10M)
Power Apps tenants25,000 base (no per-license accrual)
Power Automate tenants25,000 base (no per-license accrual)

Per-Request Limits

Limit TypeValueApplies ToNotes
Max rows per query (standard)5,000OData GET, FetchXMLUse @odata.nextLink for pagination
Max rows per query (elastic)500Elastic tables (Cosmos DB)Lower limit for horizontal-scale tables
Max requests per $batch1,000Web API $batchCannot nest batch inside batch
Max URL length (standard)~2,048 charsOData query URLsUse $batch for longer FetchXML queries
Max URL in $batch body65,536 chars (64 KB)FetchXML in batchMain reason to use $batch for FetchXML
Max ops per ExecuteMultiple1,000SDK for .NETEquivalent to $batch for SDK

Authentication

FlowUse WhenToken LifetimeRefresh?Notes
OAuth 2.0 Client Credentials (certificate)Server-to-server, background~60-75 minNew token per MSAL callRecommended for production S2S
OAuth 2.0 Client Credentials (secret)Server-to-server (simpler)~60-75 minNew token per MSAL callSecrets expire (max 2 years)
OAuth 2.0 Authorization CodeUser-context web appsAccess: ~60 minYes (refresh token)Supports MFA; requires interactive sign-in
Username/Password (ROPC)Legacy, testing only~60-75 minVia MSAL cacheNo MFA support; deprecated pattern

Authentication Gotchas

Constraints

Integration Pattern Decision Tree

START -- User needs to integrate with Dataverse
|-- What's the integration pattern?
|   |-- Real-time (individual records, <1s)
|   |   |-- <200 records/op? --> Web API single CRUD or Composite
|   |   |-- >200 records/op? --> Web API $batch with change sets (max 1,000)
|   |   +-- Need notifications? --> Webhooks or Service Bus integration
|   |-- Batch/Bulk (scheduled, high volume)
|   |   |-- <5,000 records? --> Web API $batch
|   |   |-- <100,000 records? --> SDK ExecuteMultiple with parallel threads
|   |   +-- >100,000 records? --> Azure Data Factory, SSIS, or Dataverse data export
|   |-- Event-driven (CDC, webhooks)
|   |   |-- Guaranteed delivery? --> Azure Service Bus + webhook registration
|   |   +-- Incremental sync? --> Change tracking (delta links)
|   +-- File-based (CSV/XML) --> Dataverse data import or Azure Data Factory
|-- Direction?
|   |-- Inbound --> check entitlement limits
|   |-- Outbound --> check service protection limits
|   +-- Bidirectional --> design conflict resolution FIRST
+-- Error tolerance?
    |-- Zero-loss --> $batch change sets + dead letter queue
    +-- Best-effort --> $batch + odata.continue-on-error + retry on 429

Quick Reference

OperationMethodEndpointPayloadNotes
Create recordPOST/api/data/v9.2/{entitysetname}JSONReturns OData-EntityId header
Read recordGET/api/data/v9.2/{entitysetname}({id})N/AUse $select, $expand
Update recordPATCH/api/data/v9.2/{entitysetname}({id})JSON (changed fields)Use If-Match for concurrency
Delete recordDELETE/api/data/v9.2/{entitysetname}({id})N/AReturns 204 on success
Query recordsGET/api/data/v9.2/{entitysetname}?$filter=...N/AMax 5,000 rows; paginate via @odata.nextLink
FetchXML queryGET/api/data/v9.2/{entitysetname}?fetchXml=...N/ASupports aggregates, linked entities
Batch requestPOST/api/data/v9.2/$batchmultipart/mixedMax 1,000 ops; change sets for atomicity
Execute actionPOST/api/data/v9.2/{actionname}JSON (params)Custom and built-in OData actions
UpsertPATCH/api/data/v9.2/{entitysetname}({id})JSONCreates if not exists, updates if exists
Change trackingGET/api/data/v9.2/{entitysetname}?$select=...N/APrefer: odata.track-changes header
Associate recordsPOST/api/data/v9.2/{entity}({id})/{navprop}/$refJSON @odata.idLinks via navigation property

Step-by-Step Integration Guide

1. Register Azure AD app and create application user

Register an app in Microsoft Entra ID with a client certificate or secret. Create an application user in Dataverse bound to that app registration with a custom security role. [src6]

# Get token using client credentials
curl -X POST "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" \
  -d "client_id={app_client_id}" \
  -d "scope=https://{org}.crm.dynamics.com/.default" \
  -d "client_secret={secret}" \
  -d "grant_type=client_credentials"

Verify: Response contains access_token field; decode JWT to confirm aud matches environment URL.

2. Execute a basic CRUD operation

Use the access token to create, read, update, or delete a record. [src3]

# Create an account
curl -X POST "https://{org}.crm.dynamics.com/api/data/v9.2/accounts" \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0" \
  -d '{"name": "Contoso Inc", "telephone1": "555-0100"}'

Verify: HTTP 204 with OData-EntityId header.

3. Query with OData filters and pagination

Retrieve records using OData query options. [src3]

curl -X GET "https://{org}.crm.dynamics.com/api/data/v9.2/accounts?\$select=name,telephone1&\$filter=contains(name,'Contoso')&\$top=10" \
  -H "Authorization: Bearer {access_token}" \
  -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0"

Verify: HTTP 200 with value array. Check @odata.nextLink for pagination.

4. Batch operation with change set (atomic transaction)

Group write operations into an all-or-nothing transaction using change sets. [src4]

POST /api/data/v9.2/$batch HTTP/1.1
Content-Type: multipart/mixed; boundary="batch_unique123"

--batch_unique123
Content-Type: multipart/mixed; boundary="changeset_abc"

--changeset_abc
Content-Type: application/http
Content-Transfer-Encoding: binary
Content-ID: 1

POST /api/data/v9.2/accounts HTTP/1.1
Content-Type: application/json; type=entry

{"name": "New Account"}
--changeset_abc
Content-Type: application/http
Content-Transfer-Encoding: binary
Content-ID: 2

POST /api/data/v9.2/contacts HTTP/1.1
Content-Type: application/json; type=entry

{"firstname":"Jane","[email protected]":"$1"}
--changeset_abc--
--batch_unique123--

Verify: HTTP 200; each sub-response shows 204. Failure in any = full rollback.

5. Set up change tracking for incremental sync

Enable change tracking on the table, then use delta links to sync only changes. [src5]

# Initial request with change tracking
curl -X GET "https://{org}.crm.dynamics.com/api/data/v9.2/accounts?\$select=name" \
  -H "Authorization: Bearer {token}" \
  -H "Prefer: odata.track-changes" \
  -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0"
# Response includes @odata.deltaLink -- store for next sync

Verify: Response includes @odata.deltaLink. Subsequent calls return only changed/deleted records.

6. Implement retry logic for 429 responses

Handle service protection limits with exponential backoff using the Retry-After header. [src1]

import requests, time

def dataverse_request(url, headers, method="GET", json_body=None, max_retries=5):
    for attempt in range(max_retries):
        resp = getattr(requests, method.lower())(url, headers=headers, json=json_body)
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)
            continue
        return resp
    raise Exception(f"Max retries exceeded for {url}")

Verify: On 429, function waits per Retry-After header and retries automatically.

Code Examples

Python: Paginated query with rate limit handling

# Input:  MSAL token, Dataverse org URL
# Output: All account records with pagination and 429 retry

from msal import ConfidentialClientApplication  # msal==1.28.0
import requests, time

app = ConfidentialClientApplication(
    "client-id", authority="https://login.microsoftonline.com/tenant-id",
    client_credential={"thumbprint": "THUMB", "private_key": open("key.pem").read()}
)
token = app.acquire_token_for_client(["https://org.crm.dynamics.com/.default"])
headers = {"Authorization": f"Bearer {token['access_token']}",
           "OData-MaxVersion": "4.0", "OData-Version": "4.0"}

def get_all(url):
    records = []
    while url:
        for attempt in range(5):
            r = requests.get(url, headers=headers)
            if r.status_code == 429:
                time.sleep(int(r.headers.get("Retry-After", 2**attempt)))
                continue
            r.raise_for_status(); break
        data = r.json()
        records.extend(data.get("value", []))
        url = data.get("@odata.nextLink")
    return records

JavaScript/Node.js: Batch upsert with change set

// Input:  Azure AD token, contact records array
// Output: Atomic batch upsert result

import { ClientCertificateCredential } from "@azure/identity"; // @azure/[email protected]
const cred = new ClientCertificateCredential("tenant", "client", "cert.pem");
const token = await cred.getToken("https://org.crm.dynamics.com/.default");

const batch = "batch_" + crypto.randomUUID();
const cs = "changeset_" + crypto.randomUUID();
let body = `--${batch}\r\nContent-Type: multipart/mixed; boundary="${cs}"\r\n\r\n`;
contacts.forEach((c, i) => {
  body += `--${cs}\r\nContent-Type: application/http\r\nContent-Transfer-Encoding: binary\r\nContent-ID: ${i+1}\r\n\r\n`;
  body += `PATCH /api/data/v9.2/contacts(${c.id}) HTTP/1.1\r\nContent-Type: application/json\r\n\r\n`;
  body += JSON.stringify(c) + "\r\n";
});
body += `--${cs}--\r\n--${batch}--\r\n`;

const resp = await fetch("https://org.crm.dynamics.com/api/data/v9.2/$batch", {
  method: "POST", body,
  headers: { Authorization: `Bearer ${token.token}`,
    "Content-Type": `multipart/mixed; boundary="${batch}"` }
});

cURL: Quick connectivity test

# Test auth and connectivity
curl -s "https://{org}.crm.dynamics.com/api/data/v9.2/WhoAmI" \
  -H "Authorization: Bearer {token}" \
  -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0" | python3 -m json.tool
# Expected: {"BusinessUnitId":"guid","UserId":"guid","OrganizationId":"guid"}

# Check rate limit headers (debug)
curl -v "https://{org}.crm.dynamics.com/api/data/v9.2/accounts?\$top=1" \
  -H "Authorization: Bearer {token}" 2>&1 | grep "x-ms-ratelimit"

Data Mapping

Dataverse-Specific Data Type Mapping

Dataverse TypeOData TypeJSON FormatGotcha
Lookup (EntityReference)Edm.Guid"_parentid_value": "guid"Read: _field_value; Write: [email protected]
OptionSet (Choice)Edm.Int32"statuscode": 1Integer, not label; use FormattedValue annotation
MoneyEdm.Decimal"revenue": 50000.00Currency depends on transactioncurrencyid
DateTimeEdm.DateTimeOffset"createdon": "...Z"Always UTC; UI applies user timezone
MultiSelect OptionSetCollection(Edm.Int32)"choices": "1,3,5"Comma-separated integers as string
File/ImageEdm.BinarySeparate endpointsMax 128 MB; chunked upload for large files
Polymorphic LookupEdm.Guid"_customerid_value": "guid"Includes @Microsoft.Dynamics.CRM.lookuplogicalname

Data Type Gotchas

Error Handling & Failure Points

Common Error Codes

Code / HexMeaningCauseResolution
429Too Many RequestsService protection limit exceededRead Retry-After header; exponential backoff
0x80072322Request count exceeded>6,000 requests in 5-min windowReduce rate; use batching
0x80072321Execution time exceeded>1,200s combined in 5-min windowSmaller batches; simplify queries
0x80072326Concurrent requests exceeded>52 simultaneous requestsLimit MaxDegreeOfParallelism
0x80040265Privilege errorMissing security roleAssign role to application user
0x80048306Record not foundInvalid GUID or deleted recordVerify record exists; handle 404
0x80044331Validation errorField value exceeds max lengthCheck field metadata constraints
0x80060903Duplicate detectedDuplicate detection rule triggeredHandle duplicate or merge

Failure Points in Production

Anti-Patterns

Wrong: Sending individual requests for bulk operations

# BAD -- 10,000 individual POSTs will trigger service protection limits
for record in records:
    requests.post(f"{url}/api/data/v9.2/contacts", json=record, headers=headers)

Correct: Parallel requests with throttle-aware retry

# GOOD -- Parallel with concurrency limit + 429 handling
import concurrent.futures, time
def upsert(rec):
    for attempt in range(5):
        r = requests.patch(f"{url}/api/data/v9.2/contacts({rec['contactid']})",
                          json=rec, headers=headers)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", 2**attempt)))
            continue
        return r.status_code
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
    results = list(ex.map(upsert, records))

Wrong: Max-size $batch to minimize request count

# BAD -- 1,000-op batch exhausts execution time limit
batch_body = build_batch(all_1000_records)
requests.post(f"{url}/api/data/v9.2/$batch", data=batch_body, headers=headers)

Correct: Small batches with high parallelism

# GOOD -- Microsoft recommends small batches (10-50), increase gradually
for chunk in chunks(records, 10):
    batch_body = build_batch(chunk)
    resp = requests.post(f"{url}/api/data/v9.2/$batch", data=batch_body, headers=headers)
    if resp.status_code == 429:
        time.sleep(int(resp.headers.get("Retry-After", 5)))

Wrong: Polling all records to detect changes

# BAD -- Full query with modifiedon filter misses deletes, wastes quota
requests.get(f"{url}/api/data/v9.2/contacts?$filter=modifiedon gt {last_sync}")

Correct: Use change tracking with delta links

# GOOD -- Delta link returns only changed/deleted records
resp = requests.get(delta_link_url, headers=headers)
for item in resp.json().get("value", []):
    if "$deletedEntity" in item.get("@odata.context", ""):
        handle_delete(item["id"])
    else:
        handle_upsert(item)

Common Pitfalls

Diagnostic Commands

# Check API connectivity and current user
curl -s "https://{org}.crm.dynamics.com/api/data/v9.2/WhoAmI" \
  -H "Authorization: Bearer {token}" -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0"

# Check remaining rate limit (response headers)
curl -v "https://{org}.crm.dynamics.com/api/data/v9.2/accounts?$top=1" \
  -H "Authorization: Bearer {token}" 2>&1 | grep "x-ms-ratelimit"

# Verify table change tracking status
curl -s "https://{org}.crm.dynamics.com/api/data/v9.2/EntityDefinitions(LogicalName='account')?$select=ChangeTrackingEnabled" \
  -H "Authorization: Bearer {token}" -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0"

# List all tables with change tracking enabled
curl -s "https://{org}.crm.dynamics.com/api/data/v9.2/EntityDefinitions?$select=LogicalName&$filter=ChangeTrackingEnabled eq true" \
  -H "Authorization: Bearer {token}" -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0"

# Check application user status
curl -s "https://{org}.crm.dynamics.com/api/data/v9.2/systemusers?$filter=applicationid eq {app_guid}&$select=fullname,isdisabled" \
  -H "Authorization: Bearer {token}" -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0"

Version History & Compatibility

API VersionReleaseStatusKey ChangesNotes
v9.22023+CurrentElastic tables, enhanced FetchXML, improved batchRecommended for all new development
v9.12019-2023SupportedChange tracking, enhanced queryStill functional; no new features
v9.02018-2019SupportedInitial modern Web APIMinimum for most features
v8.22016-2018DeprecatedPre-Dataverse CRM APIAvoid; missing modern features

OrganizationServiceProxy (SOAP) is deprecated in favor of ServiceClient. ADAL was end-of-life June 2022; use MSAL. [src3, src6]

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Real-time individual record CRUD (<5,000 records)Data migration >100,000 recordsAzure Data Factory, SSIS, or SDK with parallel threads
OData queries with standard filtersComplex aggregations needing SQL-level controlDataverse Synapse Link or direct SQL
Batch operations up to 1,000 per requestFile-based bulk import (CSV/XML)Dataverse data import or Azure Data Factory
Change tracking for incremental syncReal-time push notificationsWebhooks + Azure Service Bus
Power Platform integrationD365 Finance & Operations entitiesF&O data entities API / DMF

Important Caveats

Related Units