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.
| Property | Value |
|---|---|
| Vendor | Microsoft |
| System | Microsoft Dataverse (Dynamics 365 / Power Platform) |
| API Surface | OData v4.0 REST (Web API) |
| Current API Version | v9.2 |
| Editions Covered | All Dynamics 365 CE editions, Power Apps, Power Automate |
| Deployment | Cloud (Dataverse environments) |
| API Docs | Dataverse Web API Overview |
| Status | GA |
| API Surface | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| Web API (OData v4.0) | HTTPS/JSON | Individual CRUD, queries, custom actions | 5,000 rows/query | 6,000 req/5min/user/server | Yes | No |
| Web API $batch | HTTPS/multipart | Multi-operation transactions | 1,000 ops/batch | Shared with Web API | Yes | Partial |
| SDK for .NET (ExecuteMultiple) | HTTPS/SOAP | .NET apps, high-throughput bulk | 1,000 ops/request | 6,000 req/5min/user/server | Yes | Yes |
| FetchXML (via Web API) | HTTPS/XML-in-URL | Complex aggregations, cross-entity joins | 5,000 rows/page | Shared with Web API | Yes | No |
| Change Tracking (delta) | HTTPS/OData delta | Incremental sync to external systems | 5,000 rows/page | Shared with Web API | No (polling) | Yes |
| Elastic Tables (Cosmos DB) | HTTPS/JSON | High-volume IoT, logs, time-series | 500 rows/query | Shared with Web API | Yes | Yes |
| Dataverse Search API | HTTPS/JSON | Full-text search across tables | Configurable | 1 req/sec/user | Yes | No |
| Limit Type | Value | Window | Notes |
|---|---|---|---|
| Number of requests | 6,000 | 5-minute sliding | Cumulative requests from all connections for one user |
| Combined execution time | 1,200 seconds (20 minutes) | 5-minute sliding | Total server-side compute; batch operations increase this |
| Concurrent requests | 52 (or higher) | Instantaneous | Exceeding 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]
| License Type | Requests per 24 Hours | Notes |
|---|---|---|
| Dynamics 365 Enterprise apps (Sales, CS, FS, Finance, SCM) | 40,000 | Per user; base license only in attach model |
| Dynamics 365 Professional (Sales Pro, CS Pro) | 40,000 | Per user |
| Power Apps per user plan | 40,000 | Per user |
| Power Automate per user (Premium) | 40,000 | Per user |
| Dynamics 365 Team Member | 6,000 | Per user |
| Power Apps per app plan | 6,000 | Per user |
| Microsoft 365 licenses | 6,000 | Per user |
| Power Automate per flow plan | 250,000 | Per flow (not per user) |
Multiple licenses stack. Each CRUD inside $batch counts individually toward entitlement. [src2]
| Tenant Type | Pool Size |
|---|---|
| Dynamics 365 Enterprise/Professional tenants | 500,000 base + 5,000/user license (max 10M) |
| Power Apps tenants | 25,000 base (no per-license accrual) |
| Power Automate tenants | 25,000 base (no per-license accrual) |
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max rows per query (standard) | 5,000 | OData GET, FetchXML | Use @odata.nextLink for pagination |
| Max rows per query (elastic) | 500 | Elastic tables (Cosmos DB) | Lower limit for horizontal-scale tables |
| Max requests per $batch | 1,000 | Web API $batch | Cannot nest batch inside batch |
| Max URL length (standard) | ~2,048 chars | OData query URLs | Use $batch for longer FetchXML queries |
| Max URL in $batch body | 65,536 chars (64 KB) | FetchXML in batch | Main reason to use $batch for FetchXML |
| Max ops per ExecuteMultiple | 1,000 | SDK for .NET | Equivalent to $batch for SDK |
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 Client Credentials (certificate) | Server-to-server, background | ~60-75 min | New token per MSAL call | Recommended for production S2S |
| OAuth 2.0 Client Credentials (secret) | Server-to-server (simpler) | ~60-75 min | New token per MSAL call | Secrets expire (max 2 years) |
| OAuth 2.0 Authorization Code | User-context web apps | Access: ~60 min | Yes (refresh token) | Supports MFA; requires interactive sign-in |
| Username/Password (ROPC) | Legacy, testing only | ~60-75 min | Via MSAL cache | No MFA support; deprecated pattern |
<env-url>/user_impersonation; confidential clients use <env-url>/.default. Wrong scope = authentication failure. [src6]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
| Operation | Method | Endpoint | Payload | Notes |
|---|---|---|---|---|
| Create record | POST | /api/data/v9.2/{entitysetname} | JSON | Returns OData-EntityId header |
| Read record | GET | /api/data/v9.2/{entitysetname}({id}) | N/A | Use $select, $expand |
| Update record | PATCH | /api/data/v9.2/{entitysetname}({id}) | JSON (changed fields) | Use If-Match for concurrency |
| Delete record | DELETE | /api/data/v9.2/{entitysetname}({id}) | N/A | Returns 204 on success |
| Query records | GET | /api/data/v9.2/{entitysetname}?$filter=... | N/A | Max 5,000 rows; paginate via @odata.nextLink |
| FetchXML query | GET | /api/data/v9.2/{entitysetname}?fetchXml=... | N/A | Supports aggregates, linked entities |
| Batch request | POST | /api/data/v9.2/$batch | multipart/mixed | Max 1,000 ops; change sets for atomicity |
| Execute action | POST | /api/data/v9.2/{actionname} | JSON (params) | Custom and built-in OData actions |
| Upsert | PATCH | /api/data/v9.2/{entitysetname}({id}) | JSON | Creates if not exists, updates if exists |
| Change tracking | GET | /api/data/v9.2/{entitysetname}?$select=... | N/A | Prefer: odata.track-changes header |
| Associate records | POST | /api/data/v9.2/{entity}({id})/{navprop}/$ref | JSON @odata.id | Links via navigation property |
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.
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.
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.
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.
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.
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.
# 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
// 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}"` }
});
# 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"
| Dataverse Type | OData Type | JSON Format | Gotcha |
|---|---|---|---|
| Lookup (EntityReference) | Edm.Guid | "_parentid_value": "guid" | Read: _field_value; Write: [email protected] |
| OptionSet (Choice) | Edm.Int32 | "statuscode": 1 | Integer, not label; use FormattedValue annotation |
| Money | Edm.Decimal | "revenue": 50000.00 | Currency depends on transactioncurrencyid |
| DateTime | Edm.DateTimeOffset | "createdon": "...Z" | Always UTC; UI applies user timezone |
| MultiSelect OptionSet | Collection(Edm.Int32) | "choices": "1,3,5" | Comma-separated integers as string |
| File/Image | Edm.Binary | Separate endpoints | Max 128 MB; chunked upload for large files |
| Polymorphic Lookup | Edm.Guid | "_customerid_value": "guid" | Includes @Microsoft.Dynamics.CRM.lookuplogicalname |
_fieldname_value; write uses [email protected] with a URI. [src3]| Code / Hex | Meaning | Cause | Resolution |
|---|---|---|---|
| 429 | Too Many Requests | Service protection limit exceeded | Read Retry-After header; exponential backoff |
| 0x80072322 | Request count exceeded | >6,000 requests in 5-min window | Reduce rate; use batching |
| 0x80072321 | Execution time exceeded | >1,200s combined in 5-min window | Smaller batches; simplify queries |
| 0x80072326 | Concurrent requests exceeded | >52 simultaneous requests | Limit MaxDegreeOfParallelism |
| 0x80040265 | Privilege error | Missing security role | Assign role to application user |
| 0x80048306 | Record not found | Invalid GUID or deleted record | Verify record exists; handle 404 |
| 0x80044331 | Validation error | Field value exceeds max length | Check field metadata constraints |
| 0x80060903 | Duplicate detected | Duplicate detection rule triggered | Handle duplicate or merge |
x-ms-ratelimit-burst-remaining for flow control. [src1]Gradually increase batch size while monitoring 429 responses. [src1]Implement fallback to full extraction with new initial delta. [src5]Implement recursion guards in plug-in code. [src1]Use MSAL AcquireTokenSilent or delegating handler pattern. [src6]# 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)
# 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))
# 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)
# 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)))
# BAD -- Full query with modifiedon filter misses deletes, wastes quota
requests.get(f"{url}/api/data/v9.2/contacts?$filter=modifiedon gt {last_sync}")
# 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)
Add Prefer: odata.continue-on-error header or use change sets. [src4]Send If-Match: W/"version" on PATCH. [src3]Use FetchXML for complex cross-entity joins. [src3]# 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"
| API Version | Release | Status | Key Changes | Notes |
|---|---|---|---|---|
| v9.2 | 2023+ | Current | Elastic tables, enhanced FetchXML, improved batch | Recommended for all new development |
| v9.1 | 2019-2023 | Supported | Change tracking, enhanced query | Still functional; no new features |
| v9.0 | 2018-2019 | Supported | Initial modern Web API | Minimum for most features |
| v8.2 | 2016-2018 | Deprecated | Pre-Dataverse CRM API | Avoid; missing modern features |
OrganizationServiceProxy (SOAP) is deprecated in favor of ServiceClient. ADAL was end-of-life June 2022; use MSAL. [src3, src6]
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Real-time individual record CRUD (<5,000 records) | Data migration >100,000 records | Azure Data Factory, SSIS, or SDK with parallel threads |
| OData queries with standard filters | Complex aggregations needing SQL-level control | Dataverse Synapse Link or direct SQL |
| Batch operations up to 1,000 per request | File-based bulk import (CSV/XML) | Dataverse data import or Azure Data Factory |
| Change tracking for incremental sync | Real-time push notifications | Webhooks + Azure Service Bus |
| Power Platform integration | D365 Finance & Operations entities | F&O data entities API / DMF |