Dynamics 365 Dataverse API Capabilities
What are the Dataverse API capabilities - service protection limits, entitlement limits per license?
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. 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 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 | 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 |
Rate Limits & Quotas
Service Protection Limits (Per-User, Per-Web-Server)
| 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]
Entitlement Limits (Per-License, Per-24-Hours)
| 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]
Non-Licensed User Pool (Tenant-Level)
| 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) |
Per-Request Limits
| 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 |
Authentication
| 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 |
Authentication Gotchas
- Application user required: Client credentials flow requires creating an "application user" in Dataverse bound to the Azure AD app registration with a custom security role. [src6]
- ADAL is deprecated (EOL June 2022). All new integrations must use MSAL. [src6]
- Scope format: Public clients use
<env-url>/user_impersonation; confidential clients use<env-url>/.default. Wrong scope = authentication failure. [src6] - Client secrets have a 2-year maximum lifetime. Use certificates for long-running production integrations. [src6]
Constraints
- Service protection limits are per web server: The 6,000 request limit applies per server. With affinity cookies removed, requests distribute -- but this is not guaranteed. [src1]
- Batch operations do NOT bypass entitlement limits: Each CRUD in $batch counts individually toward the 24h entitlement quota. [src1, src7]
- Change tracking delta tokens expire in 7 days by default (configurable via ExpireChangeTrackingInDays). [src5]
- Change tracking does not support $filter, $orderby, $expand, or $top. [src5]
- Elastic tables: 500-row default query limit vs 5,000 for standard tables. [src3]
- Entitlement limits in transition period: Enforcement pending GA of usage reporting + 6 months. Build to official limits. [src2]
- Non-licensed user pool is shared across ALL application users in the tenant. [src2]
- Plug-in execution time adds to the triggering request's execution time budget. [src1]
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
| 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 |
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 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 |
Data Type Gotchas
- Lookup fields have asymmetric read/write syntax: read returns
_fieldname_value; write uses[email protected]with a URI. [src3] - DateTime is always UTC in the API. Client applications must handle timezone conversion. [src3]
- OptionSet integer values for custom choices may differ between dev and prod if not solution-managed. [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 |
| 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 |
Failure Points in Production
- Affinity cookie removal: Rate limit headers reset across servers. Don't use
x-ms-ratelimit-burst-remainingfor flow control. [src1] - Large batches hit execution time: A $batch with 1,000 ops may hit the 1,200s limit. Start with batch size 10. Fix:
Gradually increase batch size while monitoring 429 responses. [src1] - Delta token expiration: Change tracking tokens expire after 7 days. Fix:
Implement fallback to full extraction with new initial delta. [src5] - Plug-in recursion: Cascading plug-in triggers compound execution time against the original request. Fix:
Implement recursion guards in plug-in code. [src1] - OAuth token expiry mid-batch: Custom HTTP clients without MSAL refresh get 401 errors. Fix:
Use MSAL AcquireTokenSilent or delegating handler pattern. [src6]
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
- Sandbox limits differ from production: Sandbox environments may have fewer web servers, lowering effective limits. Load-test against a similarly-configured sandbox. [src1]
- No odata.continue-on-error on $batch: Without change sets AND without this header, failure on request N stops processing of N+1 through 1000. Fix:
Add Prefer: odata.continue-on-error header or use change sets. [src4] - Relying on transition period limits: Current enforcement allows higher limits, but official 40K/day/user will be enforced after GA + 6 months. [src2]
- Not setting If-Match for concurrent updates: Without optimistic concurrency (ETag), last-write-wins silently overwrites changes. Fix:
Send If-Match: W/"version" on PATCH. [src3] - Using nested $expand with N:N relationships: OData doesn't support this. Fix:
Use FetchXML for complex cross-entity joins. [src3] - Confusing Dataverse CE API with F&O API: Dynamics 365 Finance & Operations has completely separate endpoints, rate limits, and auth. This card covers CE/Power Platform only. [src1]
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 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]
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 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 |
Important Caveats
- Service protection limits are defaults and can vary. Trial environments get only one web server; production gets more based on licensed users. [src1]
- Entitlement limits are NOT strictly enforced yet (transition period). Build to official limits (40K/user/day for Enterprise) to avoid future disruption. [src2]
- Non-licensed user pool is shared across ALL application users in a tenant. A single misconfigured integration can starve all others. [src2]
- Batch operations save request count but not entitlement: each CRUD inside $batch counts toward 24h quota. [src1, src7]
- Rate limit numbers in this card are from January 2026 documentation. Microsoft may adjust values. Verify against current docs before production deployment. [src1, src2]