---
# === IDENTITY ===
id: business/erp-integration/dynamics-365-dataverse-api-capabilities/2026
canonical_question: "What are the Dataverse API capabilities - service protection limits, entitlement limits per license?"
aliases:
  - "Dataverse API rate limits and service protection limits"
  - "Dynamics 365 Dataverse Web API capabilities and OData limits"
  - "Dataverse entitlement limits per license type"
  - "How many Dataverse API requests per day by license?"
  - "Dataverse batch operations change tracking elastic tables"
  - "Microsoft Dataverse service protection 6000 requests 5 minutes"
entity_type: erp_integration
domain: business > erp-integration > Dynamics 365 Dataverse API
region: global
jurisdiction: global
temporal_scope: 2025-2026

# === SYSTEM PROFILE ===
systems:
  - name: "Microsoft Dynamics 365 / Power Platform (Dataverse)"
    vendor: "Microsoft"
    version: "Web API v9.2 (OData v4.0)"
    edition: "All Dynamics 365 and Power Platform editions"
    deployment: cloud
    api_surface: "OData v4.0 REST"

# === VERIFICATION ===
last_verified: 2026-03-01
confidence: 0.93
version: 1.0
first_published: 2026-03-01

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: volatile
  last_breaking_change: "2021 - Entitlement limits substantially increased; enforcement still in transition period"
  next_review: 2026-08-28
  change_sensitivity: high

# === CONSTRAINTS ===
constraints:
  - "Service protection: 6,000 requests per user per 5-minute sliding window per web server"
  - "Service protection: 20 minutes (1,200 seconds) combined execution time per 5-minute window per web server"
  - "Service protection: 52 concurrent requests per user per web server (may be higher)"
  - "Entitlement: 40,000 requests/24h for Dynamics 365 Enterprise/Professional and Power Apps per user licenses"
  - "Entitlement: 6,000 requests/24h for Team Member, Power Apps per app, and Microsoft 365 licenses"
  - "Batch operations: max 1,000 individual requests per $batch request"
  - "Standard table query: max 5,000 rows returned without pagination"
  - "Elastic table query: max 500 rows returned without pagination"
  - "Change tracking: delta tokens expire after 7 days (configurable via ExpireChangeTrackingInDays)"
  - "FetchXML in batch: URL limit is 64 KB (65,536 characters) for requests inside $batch body"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs Dynamics 365 Finance & Operations (F&O) data entities or DMF"
    use_instead: "business/erp-integration/dynamics-365-dmf-import-export/2026"
  - condition: "User needs Dual Write or virtual entities specifically"
    use_instead: "business/erp-integration/dynamics-365-dual-write-capabilities/2026"
  - condition: "User needs Dynamics 365 authentication patterns only"
    use_instead: "business/erp-integration/dynamics-365-authentication/2026"
  - condition: "User is integrating Salesforce, not Dynamics 365"
    use_instead: "business/erp-integration/salesforce-rest-api-capabilities/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: integration_pattern
    question: "What integration pattern do you need?"
    type: choice
    options:
      - "real-time sync (individual records, <1s latency)"
      - "batch/bulk (scheduled, high volume)"
      - "event-driven (webhook, CDC, change tracking)"
      - "file-based (CSV/XML import/export)"
  - key: data_volume
    question: "What's your daily data volume?"
    type: choice
    options:
      - "< 1,000 records/day"
      - "1,000-100,000 records/day"
      - "> 100,000 records/day"
  - key: direction
    question: "What's the data flow direction?"
    type: choice
    options:
      - "inbound (writing to Dataverse)"
      - "outbound (reading from Dataverse)"
      - "bidirectional sync"
  - key: license_type
    question: "Which Dynamics 365/Power Platform license do you have?"
    type: choice
    options:
      - "Dynamics 365 Enterprise (Sales, CS, Field Service, Finance, SCM)"
      - "Dynamics 365 Professional (Sales/CS Professional)"
      - "Dynamics 365 Team Member"
      - "Power Apps per user"
      - "Power Apps per app"
      - "Power Automate per user or per flow"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/business/erp-integration/dynamics-365-dataverse-api-capabilities/2026"
suggested_citation: "Source: knowledgelib.io - AI Knowledge Library (verified 2026-03-01)"

# === RELATED UNITS ===
related_kos:
  depends_on:
    - id: "business/erp-integration/dynamics-365-authentication/2026"
      label: "Dynamics 365 Authentication Methods (OAuth 2.0, Azure AD)"
  related_to:
    - id: "business/erp-integration/dynamics-365-dual-write-capabilities/2026"
      label: "Dynamics 365 Dual Write Capabilities"
    - id: "business/erp-integration/dynamics-365-virtual-entities/2026"
      label: "Dynamics 365 Virtual Entities"
    - id: "business/erp-integration/dynamics-365-dmf-import-export/2026"
      label: "Dynamics 365 DMF Import/Export"
  alternative_to:
    - id: "business/erp-integration/salesforce-rest-api-capabilities/2026"
      label: "Salesforce REST API Capabilities (alternative CRM platform)"
  often_confused_with:
    - id: "business/erp-integration/dynamics-365-web-api-capabilities/2026"
      label: "Dynamics 365 F&O Web API (different API surface for Finance & Operations)"

# === SOURCES ===
sources:
  - id: src1
    title: "Service protection API limits (Microsoft Dataverse)"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/api-limits
    type: official_docs
    published: 2026-01-09
    reliability: authoritative
  - id: src2
    title: "Requests limits and allocations - Power Platform"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/power-platform/admin/api-request-limits-allocations
    type: official_docs
    published: 2025-09-24
    reliability: authoritative
  - id: src3
    title: "Use the Microsoft Dataverse Web API"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/overview
    type: official_docs
    published: 2026-01-09
    reliability: authoritative
  - id: src4
    title: "Execute batch operations using the Web API"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/execute-batch-operations-using-web-api
    type: official_docs
    published: 2023-11-17
    reliability: authoritative
  - id: src5
    title: "Use change tracking to synchronize data with external systems"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/use-change-tracking-synchronize-data-external-systems
    type: official_docs
    published: 2023-06-23
    reliability: authoritative
  - id: src6
    title: "Use OAuth authentication with Microsoft Dataverse"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/authenticate-oauth
    type: official_docs
    published: 2026-01-07
    reliability: authoritative
  - id: src7
    title: "API limits overview (Microsoft Dataverse)"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/power-apps/maker/data-platform/api-limits-overview
    type: official_docs
    published: 2023-01-30
    reliability: authoritative
  - id: src8
    title: "Navigating API Rate Limits for High-load Integrations with Microsoft Dataverse"
    author: UDS Systems
    url: https://uds.systems/blog/navigating-api-rate-limits-for-high-load-integrations-with-microsoft-dataverse/
    type: technical_blog
    published: 2024-06-15
    reliability: moderate_high
---

# Dynamics 365 Dataverse API Capabilities

## TL;DR

- **Bottom line**: Dataverse uses an OData v4.0 REST API (Web API v9.2) with two separate limit systems -- service protection limits (per-user, per-5-minute-window) and entitlement limits (per-license, per-24-hours). Both must be understood for successful integration. [src1, src7]
- **Key limit**: 6,000 requests per user per 5-minute sliding window per web server, with 20 minutes combined execution time in that same window. Entitlement: 40,000 requests/24h for Dynamics 365 Enterprise licenses. [src1, src2]
- **Watch out for**: Service protection limits and entitlement limits are evaluated independently -- batch operations ($batch) reduce request count but increase execution time, and each CRUD operation inside a batch still counts toward entitlement limits. [src1, src7]
- **Best for**: Real-time CRUD operations under 5,000 records, OData queries, change tracking for incremental sync, and batch operations up to 1,000 requests per $batch call. [src3, src4]
- **Authentication**: OAuth 2.0 via Microsoft Entra ID (Azure AD) -- use client credentials with certificate for server-to-server; authorization code flow for user-context operations. [src6]

## 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. It exposes full CRUD operations, custom actions, functions, metadata operations, and batch processing. [src3]

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, which has its own separate service protection limits and different endpoints. For F&O integration, see the DMF Import/Export card.

| 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 URL** | [Dataverse Web API Overview](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/overview) |
| **Status** | GA |

## API Surfaces & Capabilities

| 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 (standard) | 6,000 req/5min/user/server | Yes | No |
| Web API $batch | HTTPS/multipart | Multi-operation transactions, reducing HTTP round-trips | 1,000 operations/batch | Shared with Web API | Yes | Partial |
| SDK for .NET (ExecuteMultiple) | HTTPS/SOAP | .NET apps, high-throughput bulk operations | 1,000 operations/request | 6,000 req/5min/user/server | Yes | Yes |
| FetchXML (via Web API) | HTTPS/XML-in-URL | Complex aggregations, cross-entity queries without nav properties | 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 + ExecuteCosmosSqlQuery | High-volume IoT, logs, time-series data | 500 rows/query | Shared with Web API | Yes | Yes |
| Dataverse Search API | HTTPS/JSON (/api/search) | Full-text search across multiple tables | Configurable | 1 req/sec/user | Yes | No |

[src1, src3, src4, src5]

## Rate Limits & Quotas

<!-- This is the highest-value section. Agents hallucinate rate limits constantly.
     Every number must be cited. -->

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

These limits use a 5-minute (300 second) sliding window and apply per authenticated user per web server. Most environments have multiple web servers. [src1]

| 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 time; batch operations increase this |
| Concurrent requests | 52 (or higher) | Instantaneous | Exceeding returns error immediately (no sliding window) |

[src1]

**Key behaviors**:
- Returns HTTP 429 Too Many Requests (Web API) or OrganizationServiceFault (SDK) with `Retry-After` header. [src1]
- Plug-in and workflow execution does NOT count against service protection limits (runs in sandbox service), but adds to execution time of the triggering request. [src1]
- Dataverse Search API has its own separate limit: 1 request/second/user. [src1]
- These limits can change and vary between environments; the values above are defaults. [src1]

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

Entitlement limits count all CRUD, assign, and share operations -- including those inside $batch, plug-ins, and workflows. [src2, src7]

| License Type | Requests per 24 Hours | Notes |
|---|---|---|
| Dynamics 365 Enterprise apps (Sales, CS, FS, Finance, SCM, etc.) | 40,000 | Per user; base license only in attach model |
| Dynamics 365 Professional apps (Sales Pro, CS Pro) | 40,000 | Per user |
| Power Apps per user plan | 40,000 | Per user |
| Power Automate per user plan (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 (with Power Platform) | 6,000 | Per user |
| Power Automate per flow plan | 250,000 | Per flow (not per user) |
| Power Apps pay-as-you-go | 6,000 | Per user |

[src2]

**Multiple licenses stack**: If a user has both D365 CS Enterprise (40,000) and Power Apps per user (40,000), they get 80,000 total. [src2]

### Non-Licensed User Pool (Tenant-Level)

Application users, non-interactive users, and service principals share a tenant-level pool. [src2]

| Tenant Type | Pool Size |
|---|---|
| Dynamics 365 Enterprise/Professional tenants | 500,000 base + 5,000 per user license (max 10M) |
| Power Apps tenants (all licenses) | 25,000 base (no per-license accrual) |
| Power Automate tenants (all licenses) | 25,000 base (no per-license accrual) |

[src2]

### Per-Request Limits

| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max rows per query (standard tables) | 5,000 | OData GET, FetchXML | Use pagination ($skiptoken or paging cookie) for more |
| Max rows per query (elastic tables) | 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 length (inside $batch body) | 65,536 chars (64 KB) | FetchXML in batch body | Main reason to use $batch for FetchXML |
| Max operations per ExecuteMultiple | 1,000 | SDK for .NET | Equivalent to $batch for SDK |

[src1, src3, src4]

## Authentication

| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 Client Credentials (certificate) | Server-to-server integrations, background processes | ~60-75 minutes | New token per MSAL call | Recommended for unattended integration; requires Azure AD app + application user in Dataverse |
| OAuth 2.0 Client Credentials (client secret) | Server-to-server (simpler setup) | ~60-75 minutes | New token per MSAL call | Client secrets expire (max 2 years); certificates preferred for production |
| OAuth 2.0 Authorization Code | User-context web applications | Access: ~60 min, Refresh: until revoked | Yes | Requires interactive sign-in; supports MFA |
| OAuth 2.0 Username/Password (ROPC) | Legacy, testing only | ~60-75 minutes | Via MSAL cache | Does NOT support MFA; deprecated pattern |

[src6]

### Authentication Gotchas

- **Application user required**: Client credentials flow requires creating an "application user" record in Dataverse bound to the Azure AD app registration, with a custom security role defining its permissions. Without this, authentication succeeds but all API calls fail with privilege errors. [src6]
- **Access token default expiry is ~60 minutes**: Do not hardcode token lifetime. Use MSAL libraries which handle caching and refresh automatically. The ServiceClient and CrmServiceClient SDK classes handle retry on token refresh. [src6]
- **ADAL is deprecated**: Microsoft Authentication Library (MSAL) replaced Azure AD Authentication Library (ADAL) which was end-of-life in June 2022. All new integrations must use MSAL. [src6]
- **Client secret expiration**: Client secrets have a maximum lifetime of 2 years. Use certificates for long-running production integrations to avoid secret rotation failures. [src6]
- **Scope format matters**: Public clients use `<environment-url>/user_impersonation`; confidential clients (S2S) use `<environment-url>/.default`. Using the wrong scope causes authentication failures. [src6]

## Constraints
<!-- Agents: read this section before recommending any integration approach from this unit.
     These are hard constraints that must not be violated. -->

- **Service protection limits are per web server**: Environments have multiple web servers. The 6,000 request limit applies per server. With affinity cookies removed, requests distribute across servers, effectively multiplying the limit -- but this is not guaranteed and should not be relied upon. [src1]
- **Batch operations do NOT bypass entitlement limits**: Each CRUD operation in a $batch counts individually toward the 24h entitlement quota. Batching only reduces the service protection request count. [src1, src7]
- **Change tracking delta tokens expire in 7 days by default**: If you don't poll within 7 days, the delta token expires and you must perform a full re-sync. The retention period is configurable via the `ExpireChangeTrackingInDays` organization setting. [src5]
- **Change tracking does not support $filter, $orderby, $expand, or $top**: When using the `Prefer: odata.track-changes` header, these OData query options return an error. [src5]
- **Elastic tables have a 500-row default limit**: Versus 5,000 for standard tables. Design pagination accordingly when querying IoT or log data. [src3]
- **Entitlement limits are in transition period**: As of early 2026, Microsoft has not yet started strict enforcement of 24h entitlement limits for Dataverse (enforcement pending GA of usage reporting + 6 months). Higher transition limits apply but build to official limits. [src2]
- **Non-licensed user pool is shared across ALL application users in the tenant**: A runaway integration user can consume the entire pool, starving other integrations. Implement rate limiting per service principal. [src2]
- **Plug-in execution time counts toward the triggering request**: While plug-in API calls don't count against service protection limits directly, their computation time adds to the execution time of the original request. Complex plug-in chains can trigger the 1,200s execution time limit. [src1]

## Integration Pattern Decision Tree

```
START -- User needs to integrate with Dataverse
|-- What's the integration pattern?
|   |-- Real-time (individual records, <1s)
|   |   |-- Data volume < 200 records/operation?
|   |   |   |-- YES --> Web API: single CRUD or Composite requests
|   |   |   +-- NO --> Web API $batch with change sets (max 1,000/batch)
|   |   +-- Need notifications/webhooks?
|   |       |-- YES --> Webhooks (push to HTTP endpoint) or Service Bus integration
|   |       +-- NO --> Web API polling with change tracking
|   |-- Batch/Bulk (scheduled, high volume)
|   |   |-- Data volume < 5,000 records?
|   |   |   |-- YES --> Web API $batch (single or multiple batches)
|   |   |   +-- NO |
|   |   |-- Data volume < 100,000 records?
|   |   |   |-- YES --> SDK ExecuteMultiple with parallel threads (optimize for throughput)
|   |   |   +-- NO --> Consider Azure Data Factory, SSIS, or Dataverse data export
|   |   +-- Need transactional atomicity?
|   |       |-- YES --> $batch with change sets (all-or-nothing)
|   |       +-- NO --> $batch without change sets + odata.continue-on-error
|   |-- Event-driven (CDC, webhooks)
|   |   |-- Need guaranteed delivery?
|   |   |   |-- YES --> Azure Service Bus + Dataverse webhook registration
|   |   |   +-- NO --> Webhooks to HTTP endpoint with retry policy
|   |   +-- Need incremental data sync?
|   |       |-- YES --> Change tracking (Prefer: odata.track-changes + delta links)
|   |       +-- NO --> Standard Web API queries with modifiedon filter
|   +-- File-based (CSV/XML)
|       +-- Use Dataverse data import feature or Azure Data Factory
|-- Which direction?
|   |-- Inbound (writing to Dataverse) --> check entitlement limits for user/app
|   |-- Outbound (reading from Dataverse) --> check service protection limits
|   +-- Bidirectional --> design conflict resolution (last-write-wins or modifiedon comparison)
+-- Error tolerance?
    |-- Zero-loss --> $batch with change sets + dead letter queue + idempotent keys
    +-- Best-effort --> $batch with odata.continue-on-error + retry on 429
```

## Quick Reference

| Operation | Method | Endpoint | Payload | Notes |
|---|---|---|---|---|
| Create record | POST | `/api/data/v9.2/{entitysetname}` | JSON | Returns `OData-EntityId` header with new record URI |
| Read record | GET | `/api/data/v9.2/{entitysetname}({id})` | N/A | Use `$select` to limit columns, `$expand` for related |
| Update record | PATCH | `/api/data/v9.2/{entitysetname}({id})` | JSON (changed fields only) | Use `If-Match` with ETag for concurrency |
| Delete record | DELETE | `/api/data/v9.2/{entitysetname}({id})` | N/A | Returns 204 No Content on success |
| Query records | GET | `/api/data/v9.2/{entitysetname}?$filter=...&$select=...` | N/A | Max 5,000 rows; use `@odata.nextLink` for pagination |
| FetchXML query | GET | `/api/data/v9.2/{entitysetname}?fetchXml={encoded}` | N/A | Supports aggregates, linked entities without nav properties |
| Batch request | POST | `/api/data/v9.2/$batch` | multipart/mixed | Max 1,000 operations; use change sets for atomicity |
| Execute action | POST | `/api/data/v9.2/{actionname}` | JSON (parameters) | Custom actions and built-in OData actions |
| Execute function | GET | `/api/data/v9.2/{functionname}(params)` | N/A | Read-only operations; parameterized |
| Change tracking | GET | `/api/data/v9.2/{entitysetname}?$select=...` | N/A | Add `Prefer: odata.track-changes` header; returns delta link |
| Upsert | PATCH | `/api/data/v9.2/{entitysetname}({id})` | JSON | Creates if not exists, updates if exists |
| Associate records | POST | `/api/data/v9.2/{entity}({id})/{navprop}/$ref` | JSON `@odata.id` | Links related records via navigation property |

[src3, src4, src5]

## Step-by-Step Integration Guide

### 1. Register Azure AD application and create application user

Register an app in Microsoft Entra ID (Azure AD) with a client certificate or secret. Then create an application user in Dataverse's admin center bound to that app registration, and assign a custom security role with appropriate table privileges. [src6]

```bash
# Input:  Azure AD tenant ID, app client ID, Dataverse environment URL
# Output: OAuth 2.0 access token

# Get token using client credentials (certificate)
curl -X POST "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id={app_client_id}" \
  -d "scope=https://{org}.crm.dynamics.com/.default" \
  -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
  -d "client_assertion={signed_jwt}" \
  -d "grant_type=client_credentials"
```

**Verify**: Check response for `access_token` field; decode JWT to confirm `aud` matches your environment URL.

### 2. Execute a basic CRUD operation

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

```bash
# Input:  Valid access token, Dataverse environment URL
# Output: New account record created

# 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**: Response HTTP 204 with `OData-EntityId` header containing the new record URI.

### 3. Query records with OData filters

Retrieve records using OData query options. [src3]

```bash
# Input:  Access token, table name, filter criteria
# Output: JSON array of matching records

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" \
  -H "Accept: application/json"
```

**Verify**: Response HTTP 200 with `value` array containing matching records. Check `@odata.nextLink` for pagination.

### 4. Execute a batch operation with change set

Group multiple write operations into an atomic transaction. [src4]

```http
POST /api/data/v9.2/$batch HTTP/1.1
Authorization: Bearer {access_token}
Content-Type: multipart/mixed; boundary="batch_unique123"
OData-MaxVersion: 4.0
OData-Version: 4.0

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

--changeset_abc456
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_abc456
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", "lastname": "Doe", "parentcustomerid_account@odata.bind": "$1"}
--changeset_abc456--
--batch_unique123--
```

**Verify**: Response HTTP 200 with batch response body; each sub-response shows 204 No Content on success. If any fails in the change set, all roll back.

### 5. Set up change tracking for incremental sync

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

```bash
# Input:  Access token, table with change tracking enabled
# Output: Initial data + delta link for subsequent syncs

# Initial request with change tracking preference
curl -X GET "https://{org}.crm.dynamics.com/api/data/v9.2/accounts?\$select=name,telephone1" \
  -H "Authorization: Bearer {access_token}" \
  -H "Prefer: odata.track-changes" \
  -H "OData-MaxVersion: 4.0" \
  -H "OData-Version: 4.0"

# Response includes @odata.deltaLink -- store this URI
# On subsequent syncs, call the delta link directly to get only changes
```

**Verify**: Response includes `@odata.deltaLink` in the last page. Subsequent calls to the delta link return only created, updated, or deleted records since the last sync.

### 6. Implement retry logic for 429 responses

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

```python
# Input:  Access token, Dataverse environment URL
# Output: Resilient API call with automatic retry

import requests
import time

def dataverse_request(url, headers, method="GET", json_body=None, max_retries=5):
    for attempt in range(max_retries):
        if method == "GET":
            response = requests.get(url, headers=headers)
        elif method == "POST":
            response = requests.post(url, headers=headers, json=json_body)
        elif method == "PATCH":
            response = requests.patch(url, headers=headers, json=json_body)

        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
            print(f"Rate limited. Waiting {retry_after}s (attempt {attempt + 1})")
            time.sleep(retry_after)
            continue
        return response

    raise Exception(f"Max retries ({max_retries}) exceeded for {url}")
```

**Verify**: When rate-limited, function waits per `Retry-After` header and retries automatically.

## Code Examples

### Python: Query accounts with pagination and rate limit handling

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

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

# --- Authentication ---
app = ConfidentialClientApplication(
    client_id="your-app-client-id",
    authority="https://login.microsoftonline.com/your-tenant-id",
    client_credential={"thumbprint": "CERT_THUMBPRINT", "private_key": open("key.pem").read()}
)
token = app.acquire_token_for_client(scopes=["https://yourorg.crm.dynamics.com/.default"])
headers = {
    "Authorization": f"Bearer {token['access_token']}",
    "OData-MaxVersion": "4.0",
    "OData-Version": "4.0",
    "Accept": "application/json"
}

# --- Paginated query with retry ---
def get_all_records(base_url, headers):
    records = []
    url = base_url
    while url:
        for attempt in range(5):
            resp = requests.get(url, headers=headers)
            if resp.status_code == 429:
                wait = int(resp.headers.get("Retry-After", 2 ** attempt))
                time.sleep(wait)
                continue
            resp.raise_for_status()
            break
        data = resp.json()
        records.extend(data.get("value", []))
        url = data.get("@odata.nextLink")  # None when no more pages
    return records

accounts = get_all_records(
    "https://yourorg.crm.dynamics.com/api/data/v9.2/accounts?$select=name,telephone1&$top=5000",
    headers
)
print(f"Retrieved {len(accounts)} accounts")
```

### JavaScript/Node.js: Batch upsert with change set

```javascript
// Input:  Azure AD token, array of contact records
// Output: Batch upsert result with atomic transaction

// requires: @azure/identity@4.x, node-fetch@3.x
import { ClientCertificateCredential } from "@azure/identity";  // @azure/identity@4.5.0

const credential = new ClientCertificateCredential(
  "your-tenant-id", "your-client-id", "path/to/cert.pem"
);
const token = await credential.getToken("https://yourorg.crm.dynamics.com/.default");

const contacts = [
  { contactid: "guid-1", firstname: "Jane", lastname: "Doe" },
  { contactid: "guid-2", firstname: "John", lastname: "Smith" }
];

// Build batch body
const batchBoundary = "batch_" + crypto.randomUUID();
const changesetBoundary = "changeset_" + crypto.randomUUID();
let body = "";

body += `--${batchBoundary}\r\n`;
body += `Content-Type: multipart/mixed; boundary="${changesetBoundary}"\r\n\r\n`;

contacts.forEach((c, i) => {
  body += `--${changesetBoundary}\r\n`;
  body += `Content-Type: application/http\r\nContent-Transfer-Encoding: binary\r\n`;
  body += `Content-ID: ${i + 1}\r\n\r\n`;
  body += `PATCH /api/data/v9.2/contacts(${c.contactid}) HTTP/1.1\r\n`;
  body += `Content-Type: application/json\r\n\r\n`;
  body += JSON.stringify({ firstname: c.firstname, lastname: c.lastname }) + "\r\n";
});

body += `--${changesetBoundary}--\r\n`;
body += `--${batchBoundary}--\r\n`;

const resp = await fetch("https://yourorg.crm.dynamics.com/api/data/v9.2/$batch", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token.token}`,
    "Content-Type": `multipart/mixed; boundary="${batchBoundary}"`,
    "OData-MaxVersion": "4.0",
    "OData-Version": "4.0"
  },
  body
});
console.log("Batch status:", resp.status); // 200 = success
```

### cURL: Quick API connectivity test

```bash
# Input:  Access token, Dataverse org URL
# Output: Current user info (WhoAmI), confirming authentication works

# Test authentication and connectivity
curl -s "https://{org}.crm.dynamics.com/api/data/v9.2/WhoAmI" \
  -H "Authorization: Bearer {access_token}" \
  -H "OData-MaxVersion: 4.0" \
  -H "OData-Version: 4.0" | python3 -m json.tool

# Expected output:
# {
#   "BusinessUnitId": "guid",
#   "UserId": "guid",
#   "OrganizationId": "guid"
# }

# Check remaining service protection limits (debug only)
# Look for these headers in any API response:
# x-ms-ratelimit-burst-remaining-xrm-requests
# x-ms-ratelimit-time-remaining-xrm-requests
```

## Data Mapping

### Dataverse-Specific Data Type Mapping

| Dataverse Type | OData Type | JSON Representation | Gotcha |
|---|---|---|---|
| Lookup (EntityReference) | Edm.Guid | `"_parentid_value": "guid"` | Read returns `_fieldname_value`; write uses `fieldname@odata.bind` format |
| OptionSet (Choice) | Edm.Int32 | `"statuscode": 1` | Integer value, not label; query labels via `@OData.Community.Display.V1.FormattedValue` |
| Money | Edm.Decimal | `"revenue": 50000.00` | Currency conversion depends on record's transactioncurrencyid |
| DateTime | Edm.DateTimeOffset | `"createdon": "2026-01-15T10:30:00Z"` | Always stored/returned as UTC; UI applies user timezone |
| MultiSelect OptionSet | Collection(Edm.Int32) | `"choices": "1,3,5"` | Comma-separated integers as string in Web API |
| File/Image | Edm.Binary | Upload/download via separate endpoints | Max 128 MB for file columns; requires chunked upload for large files |
| Polymorphic Lookup | Edm.Guid | `"_customerid_value": "guid"` | Includes `@Microsoft.Dynamics.CRM.lookuplogicalname` to indicate entity type |

### Data Type Gotchas

- **Lookup fields have asymmetric read/write syntax**: Read returns `_fieldname_value` (underscore prefix, `_value` suffix); write uses `fieldname@odata.bind` with a URI like `"/accounts(guid)"`. Using the wrong format silently fails or returns confusing errors. [src3]
- **DateTime is always UTC**: Dataverse stores all datetime values in UTC. Client applications must handle timezone conversion. The `UserLocalTimeZone` function can retrieve the user's configured timezone. [src3]
- **OptionSet integers vary by environment**: Option set integer values for custom choices may differ between dev and prod environments if not solution-managed. Always use the metadata API to discover values programmatically. [src3]
- **Money fields require currency context**: If the record's transaction currency differs from the org base currency, the `_transactioncurrencyid_value` must be set correctly or amounts will be converted incorrectly. [src3]

## Error Handling & Failure Points

### Common Error Codes

| Code | Hex | Meaning | Cause | Resolution |
|---|---|---|---|---|
| 429 | -- | Too Many Requests | Service protection limit exceeded | Read `Retry-After` header; exponential backoff |
| -2147015902 | 0x80072322 | Request count exceeded | >6,000 requests in 5-minute window | Reduce request rate; use batching to lower request count |
| -2147015903 | 0x80072321 | Execution time exceeded | >1,200s combined execution in 5-minute window | Reduce batch sizes; simplify queries; lower parallelism |
| -2147015898 | 0x80072326 | Concurrent requests exceeded | >52 simultaneous requests | Limit `MaxDegreeOfParallelism`; reduce thread count |
| -2147220891 | 0x80040265 | Privilege error | Application user lacks required security role | Assign correct security role to application user in admin center |
| -2147088238 | 0x80048306 | Record not found | Invalid GUID or deleted record | Verify record exists before update/delete; handle 404 |
| 0x80044331 | -- | Validation error | Field value exceeds max length or invalid | Check field metadata for maxlength constraints |
| -2147204733 | 0x80060903 | Duplicate detected | Duplicate detection rule triggered | Handle duplicate, merge, or deactivate rule |

[src1, src4]

### Failure Points in Production

- **Affinity cookie removal causes limit reset confusion**: When the `x-ms-session-id` affinity cookie is absent, requests distribute across web servers, resetting rate limit headers. Don't depend on `x-ms-ratelimit-burst-remaining-xrm-requests` for flow control. [src1]
- **Large batches hit execution time before request count**: A $batch with 1,000 operations may each be fast individually, but combined execution time easily exceeds the 1,200s limit. Start with batch size 10 and increase gradually. [src1]
- **Change tracking delta token expiration causes full re-sync**: If your sync job hasn't run in 7+ days (configurable), the delta token expires with an error. Implement a fallback to full extraction with a new initial delta. [src5]
- **Plug-in recursion exhausts execution time**: If a plug-in on entity A triggers an update on entity B, which triggers a plug-in back on entity A, the total execution time compounds against the original request. Implement recursion guards in plug-in code. [src1]
- **OAuth token cached beyond expiry**: If using a custom HTTP client without MSAL's automatic token refresh, a cached token expires (~60 min) causing 401 errors mid-batch. Use MSAL's `AcquireTokenSilent` or a delegating handler pattern. [src6]

## Anti-Patterns

### Wrong: Sending individual requests for bulk operations

```python
# BAD -- 10,000 individual POST requests will take 50+ minutes
# and likely trigger service protection limits repeatedly
for record in records:
    requests.post(f"{url}/api/data/v9.2/contacts", json=record, headers=headers)
```

### Correct: Use parallel requests with throttle-aware retry

```python
# GOOD -- Parallel requests with concurrency limit and 429 handling
import concurrent.futures

def upsert_with_retry(record):
    for attempt in range(5):
        resp = requests.patch(
            f"{url}/api/data/v9.2/contacts({record['contactid']})",
            json=record, headers=headers
        )
        if resp.status_code == 429:
            time.sleep(int(resp.headers.get("Retry-After", 2 ** attempt)))
            continue
        return resp.status_code
    return 429

# Start with low parallelism, let Retry-After guide throughput
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(upsert_with_retry, records))
```

### Wrong: Using $batch with max size to minimize request count

```python
# BAD -- One $batch with 1,000 operations exhausts execution time
# even though it's technically a single "request"
batch_body = build_batch(all_1000_records)
requests.post(f"{url}/api/data/v9.2/$batch", data=batch_body, headers=headers)
```

### Correct: Use small batches with high parallelism

```python
# GOOD -- Small batches (10-50) sent in parallel
# Microsoft recommendation: start small, increase until hitting limits
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

```python
# BAD -- Querying all records with modifiedon filter is expensive
# and misses deleted records
all_records = requests.get(
    f"{url}/api/data/v9.2/contacts?$filter=modifiedon gt {last_sync}"
)
```

### Correct: Use change tracking with delta links

```python
# GOOD -- Delta link returns only changed/deleted records since last sync
# Enable change tracking on the table first
resp = requests.get(delta_link_url, headers=headers)
data = resp.json()
for item in data.get("value", []):
    if "@odata.context" in item and "$deletedEntity" in item.get("@odata.context", ""):
        handle_delete(item["id"])
    else:
        handle_upsert(item)
# Store new delta link for next sync
new_delta_link = data.get("@odata.deltaLink")
```

## Common Pitfalls

- **Assuming sandbox limits match production**: Sandbox environments may have fewer web servers, lowering effective service protection limits. Load-test against a sandbox configured similarly to production. [src1]
- **Not handling partial success in $batch without change sets**: Without change sets, a $batch processes requests sequentially. If request 500 fails, requests 501-1000 are NOT processed (unless `Prefer: odata.continue-on-error` header is set). [src4]
- **Ignoring the transition period for entitlement limits**: Current enforcement is lenient, but building integrations that rely on 200,000 requests/day/user during transition will break when official 40,000/day limits are enforced. [src2]
- **Using ExecuteMultiple with max size (1,000)**: Microsoft explicitly recommends against large batches. Start with batch size 10 and increase concurrency instead. Large batches hit execution time limits faster. [src1]
- **Not setting `If-Match` for concurrent updates**: Without optimistic concurrency (ETag), last-write-wins silently overwrites another user's changes. Always send `If-Match: W/"version"` on PATCH requests for shared records. [src3]
- **Forgetting that $expand is limited**: You cannot use nested `$expand` with N:N relationships in OData. Use FetchXML instead for complex cross-entity joins without navigation properties. [src3]
- **Confusing Dataverse CE Web API with F&O data entities API**: Dynamics 365 Finance & Operations has completely separate API endpoints, rate limits (per-user priority, 6-per-web-server for priority 0), and authentication patterns. This card covers CE/Power Platform only. [src1]

## Diagnostic Commands

```bash
# 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 (inspect 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"
# Look for: x-ms-ratelimit-burst-remaining-xrm-requests
# Look for: x-ms-ratelimit-time-remaining-xrm-requests

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

# List 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 permissions
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"

# Monitor environment health (requires admin)
# Use Power Platform Admin Center: admin.powerplatform.microsoft.com
# Navigate to Environments > [env] > Resources > Capacity > API Request
```

## Version History & Compatibility

| API Version | Environment Release | Status | Key Changes | Notes |
|---|---|---|---|---|
| v9.2 | Current (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 version for most features |
| v8.2 | 2016-2018 | Deprecated | Pre-Dataverse CRM API | Avoid; missing modern features |

[src3]

### Deprecation Policy

Microsoft does not version-retire Dataverse Web API versions on a strict schedule, but older versions (v8.x) receive no new features and may be removed in future. The Organization service (SOAP) via OrganizationServiceProxy is deprecated in favor of ServiceClient/CrmServiceClient. ADAL authentication was end-of-life June 2022; all new development must use MSAL. [src3, src6]

## When to Use / When Not to Use

| 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 for .NET with optimized parallel threads |
| OData queries with standard filters and pagination | Complex aggregations requiring SQL-level control | Dataverse Synapse Link or direct SQL endpoint |
| Batch operations up to 1,000 ops per request | File-based bulk import (CSV/XML) | Dataverse data import wizard or Azure Data Factory |
| Change tracking for incremental external sync | Real-time push notifications | Webhooks + Azure Service Bus |
| Power Platform integration (Power Apps, Power Automate) | Dynamics 365 Finance & Operations entities | F&O data entities API / DMF |
| Cross-platform integration (any language/platform) | .NET-only applications wanting strongly-typed classes | SDK for .NET (ServiceClient) |

## Important Caveats

- **Service protection limits are defaults and can vary**: Microsoft states these limits "can change and might vary between different environments." Trial environments get only one web server. Production environments get more based on licensed users. [src1]
- **Entitlement limits are NOT strictly enforced yet**: As of early 2026, Dataverse entitlement limits are in a "transition period" with higher limits. Build to official limits (40,000/user/day for Enterprise) to avoid disruption when enforcement begins. [src2]
- **Non-licensed user pool is shared**: All application users, service principals, and non-interactive users in a tenant share one pool (500K base + 5K/license). A single misconfigured integration can starve all others. [src2]
- **Batch operations save request count but not entitlement**: Each CRUD inside a $batch counts toward the 24h entitlement. Batching only helps with the 5-minute service protection window. [src1, src7]
- **Rate limit numbers in this card are from January 2026 documentation**: Microsoft may adjust these values. Always verify against current Microsoft Learn documentation before production deployment. [src1, src2]

## Related Units

- [Dynamics 365 Authentication Methods (OAuth 2.0, Azure AD)](/business/erp-integration/dynamics-365-authentication/2026)
- [Dynamics 365 Dual Write Capabilities](/business/erp-integration/dynamics-365-dual-write-capabilities/2026)
- [Dynamics 365 Virtual Entities](/business/erp-integration/dynamics-365-virtual-entities/2026)
- [Dynamics 365 DMF Import/Export](/business/erp-integration/dynamics-365-dmf-import-export/2026)
- [Salesforce REST API Capabilities (alternative CRM platform)](/business/erp-integration/salesforce-rest-api-capabilities/2026)
