This card covers the OData v4 Web API surfaces for two distinct Microsoft Dynamics 365 products: Finance & Operations (F&O, also called Finance & Supply Chain Management / F&SCM) and Business Central (BC). While both use OData v4, they have different API architectures, endpoint structures, rate limits, and throttling behaviors. F&O exposes data entities at /data/ endpoints with server-driven paging. BC exposes standard API v2.0 entities at /api/v2.0/ and OData web services at /ODataV4/. This card does not cover the Dataverse Web API, Virtual Entities, or Dual Write. [src1, src2, src3]
| Property | Dynamics 365 F&O | Dynamics 365 Business Central |
|---|---|---|
| Vendor | Microsoft | Microsoft |
| System | Finance & Operations 10.0.x | Business Central (SaaS) |
| API Surface | OData v4 (/data/) | OData v4 (/api/v2.0/, /ODataV4/) |
| Current API Version | Continuous release (10.0.x) | API v2.0 (stable) |
| Editions Covered | All cloud editions | Essentials, Premium |
| Deployment | Cloud | Cloud (SaaS only) |
| API Docs | F&O OData docs | BC API v2.0 docs |
| Status | GA | GA |
| API Surface | Product | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|---|
| OData v4 Data Entities | F&O | HTTPS/JSON | CRUD on individual records, queries | 10,000 (server paging) | 6,000 req/5min/user/server | Yes | Limited |
| OData $batch | F&O | HTTPS/JSON multipart | Atomic multi-record operations | Multiple changesets | Counts toward 6,000 limit | Yes | Moderate |
| Custom Service Endpoints | F&O | HTTPS/JSON | Custom X++ business logic | N/A | Counts toward 6,000 limit | Yes | No |
| Data Management REST API | F&O | HTTPS/JSON + file | Package-based import/export | Millions (file-based) | Exempt | No (async) | Yes |
| Recurring Integrations API | F&O | HTTPS/JSON + file | Scheduled file-based sync | Unlimited (file-based) | Exempt | No (queued) | Yes |
| Standard API v2.0 | BC | HTTPS/JSON | CRUD on ~55 standard entities | 20,000 (max page size) | 6,000 req/5min/user | Yes | Limited |
| Custom API Pages (AL) | BC | HTTPS/JSON | Custom tables/codeunits via AL | 20,000 (max page size) | 6,000 req/5min/user | Yes | No |
| OData Web Services | BC | HTTPS/JSON | Published pages/queries/codeunits | 20,000 (max page size) | 6,000 req/5min/user | Yes | No |
| Limit Type | Value | Window | Notes |
|---|---|---|---|
| Request count | 6,000 | 5-min sliding window | Per user per app ID per web server |
| Combined execution time | 1,200 seconds (20 min) | 5-min sliding window | Aggregate of all request durations |
| Concurrent requests | 52 | Instantaneous | Across all endpoints for that user |
Important: As of version 10.0.36, user-based limits are disabled by default and the option to enable them has been removed. Resource-based limits remain mandatory. [src1]
| Limit Type | Trigger | Scope | Notes |
|---|---|---|---|
| CPU utilization | Exceeds threshold | All users on web server | Returns 429 with resource message |
| Memory utilization | Exceeds threshold | All users on web server | Prioritized throttling applies |
| Aggregate load | Combined resource pressure | Environment-wide | Can throttle even low-usage users |
[src1]
The following are exempt from OData service protection limits: DIXF/DMF, Recurring Integrations, Power Platform Virtual Tables (when PP integration enabled), F&O Connector, Warehouse Mobile App, Retail Server API, Office Integration, Document Routing Agent. [src1]
| Limit Type | Value | Window | HTTP Error |
|---|---|---|---|
| OData rate limit | 6,000 requests | 5-min sliding window | 429 Too Many Requests |
| Max concurrent OData requests | 5 processing | Instantaneous | 503 (queued then timeout) |
| Max OData connections | 100 simultaneous | Instantaneous | 429 Too Many Requests |
| Max request queue | 95 queued requests | Instantaneous | 429 Too Many Requests |
| Request execution timeout | 10 minutes | Per request | 504 Gateway Timeout |
| Limit Type | Value | HTTP Error |
|---|---|---|
| Max OData page size | 20,000 entities | 413 Request Entity Too Large |
| Max $batch operations | 100 per batch | N/A |
| Max request body size | 350 MB | 413 Request Entity Too Large |
| OData operation timeout | 8 minutes | 408 Request Timeout |
| Max webhook subscriptions | 200 | N/A |
[src3]
| Feature | Supported | Notes |
|---|---|---|
| $filter | Yes | eq, ne, gt, ge, lt, le, and, or, not, add, sub, mul, div, mod |
| $orderby | Yes | Standard sorting |
| $top / $skip | Yes | Pagination |
| $select | Yes | Project specific fields |
| $count | Yes | Include total count |
| $expand | First-level only | No nested expansion |
| Cross-company queries | Yes | Use ?cross-company=true |
| $batch | Yes | Changesets for atomic operations |
| Bound actions | Yes | Custom X++ methods on entities |
has / in operators | No | Not supported in $filter |
| Array fields | No | Not supported in OData entities |
[src2]
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 Authorization Code | User-context operations, interactive apps | Access: 1h, Refresh: 90 days | Yes | User must sign in; requires redirect URI |
| OAuth 2.0 Client Credentials | Server-to-server, daemon apps, integrations | Access: 1h | New token per request | Recommended for integrations; no user context |
| OAuth 2.0 On-Behalf-Of | Middle-tier services acting as user | Access: 1h | Yes | Preserves user identity through service chain |
$expand supports first-level only -- nested $expand queries will fail. [src2]$search, the has operator, or the in operator in $filter. [src2]$batch limited to 100 operations per request. [src3]START -- Integrate with Dynamics 365 F&O or Business Central
|-- Which D365 product?
| |-- Finance & Operations (F&O)
| | |-- What volume?
| | | |-- < 1,000 records/day
| | | | |-- Real-time needed?
| | | | | |-- YES -> OData v4 data entities (individual CRUD)
| | | | | +-- NO -> OData v4 with scheduled polling
| | | |-- 1,000-100,000 records/day
| | | | |-- Need atomic transactions?
| | | | | |-- YES -> OData $batch with changesets
| | | | | +-- NO -> Data Management REST API (package-based)
| | | +-- > 100,000 records/day
| | | +-- Data Management Framework (DMF) package import
| | | (exempt from service protection limits)
| | +-- Need event-driven notifications?
| | |-- YES -> Business Events + Azure Service Bus / Event Grid
| | +-- NO -> OData polling with $filter on ModifiedDateTime
| +-- Business Central (BC)
| |-- What volume?
| | |-- < 1,000 records/day -> Standard API v2.0 (direct CRUD)
| | |-- 1,000-10,000 records/day -> API v2.0 with $batch (max 100 ops)
| | +-- > 10,000 records/day -> Distribute across multiple users/SPs
| +-- Custom business logic needed?
| |-- YES -> Custom API pages (AL) or OData unbound actions
| +-- NO -> Standard API v2.0 endpoints (~55 entities)
|-- Which direction?
| |-- Inbound (writing to D365) -> check concurrent request limits
| |-- Outbound (reading from D365) -> check page size + execution timeout
| +-- Bidirectional -> design conflict resolution + use ModifiedDateTime tracking
+-- Error tolerance?
|-- Zero-loss required -> implement idempotency + Retry-After logic + DLQ
+-- Best-effort acceptable -> exponential backoff on 429 responses
| Operation | Method | Endpoint | Notes |
|---|---|---|---|
| List entities | GET | {baseUrl}/data/{EntityCollection} | Server-driven paging, max 10K/page |
| Get single entity | GET | {baseUrl}/data/{EntityCollection}("{key}") | All key fields required |
| Create record | POST | {baseUrl}/data/{EntityCollection} | Returns created entity |
| Update record | PATCH | {baseUrl}/data/{EntityCollection}("{key}") | Partial update |
| Delete record | DELETE | {baseUrl}/data/{EntityCollection}("{key}") | Returns 204 No Content |
| Cross-company query | GET | {baseUrl}/data/{Entity}?cross-company=true | Returns data across legal entities |
| Batch request | POST | {baseUrl}/data/$batch | Changesets for atomicity |
| Entity metadata | GET | {baseUrl}/data/$metadata | Read-only, includes EnumType |
| Bound action | POST | {baseUrl}/data/{Entity}("{key}")/.../ {ActionName} | Custom X++ methods |
[src2]
| Operation | Method | Endpoint | Notes |
|---|---|---|---|
| List entities | GET | {baseUrl}/api/v2.0/companies({companyId})/{entity} | ~55 standard entities |
| Get single entity | GET | {baseUrl}/api/v2.0/companies({companyId})/{entity}({id}) | By system ID |
| Create record | POST | {baseUrl}/api/v2.0/companies({companyId})/{entity} | Returns created entity |
| Update record | PATCH | {baseUrl}/api/v2.0/companies({companyId})/{entity}({id}) | Requires If-Match ETag |
| Delete record | DELETE | {baseUrl}/api/v2.0/companies({companyId})/{entity}({id}) | Returns 204 |
| Batch request | POST | {baseUrl}/api/v2.0/$batch | Max 100 operations |
| Custom API | GET/POST | {baseUrl}/api/{publisher}/{group}/{version}/{entity} | Defined in AL code |
[src7]
Create an app registration in Azure Portal. For F&O, add the Dynamics ERP API permission. For BC, add Dynamics 365 Business Central API permission. [src1, src7]
# Azure CLI: Create app registration
az ad app create \
--display-name "D365-Integration-App" \
--sign-in-audience AzureADMyOrg
# Create a client secret
az ad app credential reset --id <app-id> --append
Verify: az ad app show --id <app-id> -> returns app registration details.
Use Client Credentials flow for server-to-server integration. The scope differs between F&O and BC. [src1, src7]
# F&O: Get access token
curl -X POST "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token" \
-d "client_id={app-id}&client_secret={secret}&scope=https://{env}.operations.dynamics.com/.default&grant_type=client_credentials"
# BC: Get access token
curl -X POST "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token" \
-d "client_id={app-id}&client_secret={secret}&scope=https://api.businesscentral.dynamics.com/.default&grant_type=client_credentials"
Verify: Response contains access_token field. Decode at jwt.ms to confirm audience and scopes.
Use the access token to make authenticated OData requests. [src2, src7]
# F&O: List customers
curl -H "Authorization: Bearer {token}" \
"https://{env}.operations.dynamics.com/data/CustomersV3?$top=10&cross-company=true"
# BC: List customers
curl -H "Authorization: Bearer {token}" \
"https://api.businesscentral.dynamics.com/v2.0/{tenant}/{env}/api/v2.0/companies({id})/customers?$top=10"
Verify: Response returns 200 OK with value array containing entity records.
When the service returns 429, read the Retry-After header and wait before retrying. [src1, src5]
import requests, time
def d365_api_call(url, headers, max_retries=5):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
elif response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 30))
time.sleep(retry_after)
elif response.status_code == 503:
time.sleep(10 * (attempt + 1))
else:
response.raise_for_status()
raise Exception(f"Max retries exceeded for {url}")
Verify: Call succeeds on retry; Retry-After header value should be respected.
# Input: F&O environment URL, entity name, OAuth token
# Output: All records across pages as a list of dicts
import requests, time
def fetch_all_fo_records(base_url, entity, token, filter_expr=None):
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
url = f"{base_url}/data/{entity}?cross-company=true&$count=true"
if filter_expr:
url += f"&$filter={filter_expr}"
all_records = []
while url:
response = requests.get(url, headers=headers)
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", 30)))
continue
response.raise_for_status()
data = response.json()
all_records.extend(data.get("value", []))
url = data.get("@odata.nextLink") # Server-driven paging
return all_records
// Input: BC environment URL, company ID, OAuth token
// Output: Customer records with proper ETag concurrency
const axios = require('axios'); // v1.6+
async function updateBCCustomer(baseUrl, companyId, customerId, data, token) {
// Step 1: Get current record to obtain ETag
const current = await axios.get(
`${baseUrl}/api/v2.0/companies(${companyId})/customers(${customerId})`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
const etag = current.data['@odata.etag'];
// Step 2: Update with If-Match header
return await axios.patch(
`${baseUrl}/api/v2.0/companies(${companyId})/customers(${customerId})`,
data,
{ headers: { 'Authorization': `Bearer ${token}`, 'If-Match': etag } }
);
}
# Input: F&O access token, entity data for batch create
# Output: Batch response with individual operation results
curl -X POST "https://{env}.operations.dynamics.com/data/$batch" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: multipart/mixed; boundary=batch_boundary" \
-H "OData-Version: 4.0" \
--data-binary '
--batch_boundary
Content-Type: multipart/mixed; boundary=changeset_boundary
--changeset_boundary
Content-Type: application/http
Content-ID: 1
POST /data/CustomersV3 HTTP/1.1
Content-Type: application/json
{"CustomerAccount":"CUST001","OrganizationName":"Contoso Ltd","dataAreaId":"usmf"}
--changeset_boundary--
--batch_boundary--'
| Concept | Description | Gotcha |
|---|---|---|
| Data Entity | De-normalized view of underlying tables | Not all table fields are exposed; check IsPublic property |
| dataAreaId | Legal entity (company) identifier | Required for cross-company queries; defaults to user's company |
| Composite key | Multi-field keys common in F&O | Must provide ALL key fields in URL |
| Enum fields | Integer values in JSON | Use $metadata to map enum integer to label |
| Navigation properties | Links between entities | Only first-level $expand supported |
| Date/Time fields | UTC datetime strings | Always UTC; no timezone conversion in OData layer |
[src2]
| Concept | Description | Gotcha |
|---|---|---|
| System ID | GUID identifier for each record | Use systemId for API operations, not the No. field |
| ETag | Optimistic concurrency control | Required in If-Match header for PATCH/DELETE |
| Company scoping | All entities scoped to a company | Must include companies({companyId}) in URL path |
| DateTime | Edm.DateTimeOffset | ISO 8601 format with timezone offset |
[src7]
@odata.etag in If-Match header for every PATCH/DELETE. Omitting returns 412 Precondition Failed. [src7]systemId (GUID) is the API identifier, distinct from the human-readable number field. [src7]| Code | Meaning | Product | Resolution |
|---|---|---|---|
| 429 | Too Many Requests | F&O, BC | Read Retry-After header, wait, then retry |
| 503 | Service Temporarily Unavailable | BC | Queue timeout; wait 10s, distribute across users |
| 504 | Gateway Timeout | BC | Exceeded 10-min timeout; optimize query, paginate |
| 408 | Request Timeout | BC | Exceeded 8-min OData timeout; break into smaller ops |
| 413 | Request Entity Too Large | BC | Exceeded 20K entities or 350 MB; use $top + paginate |
| 412 | Precondition Failed | BC | Stale ETag; re-fetch record, get current ETag, retry |
| 400 | Bad Request | F&O | Missing key fields or invalid $filter; check $metadata |
Enable OData metadata warmup in AOS startup configuration. [src2]Filter by dataAreaId to target specific companies. [src2]Implement optimistic concurrency retry loop: fetch -> update -> on 412, re-fetch and re-apply. [src7]Schedule integrations outside peak hours; use throttling prioritization. [src1]Monitor via GET /api/v2.0/subscriptions; implement renewal logic. [src3]Wrap related operations in changesets; implement idempotency checks. [src2]# BAD -- Fetches all records every time to find what changed
all_customers = fetch_all_fo_records(base_url, "CustomersV3", token)
# Compare each against local cache... wastes API calls, hits rate limits
# GOOD -- Only fetches records modified since last sync
filter_expr = f"ModifiedDateTime gt {last_sync_utc}"
changed = fetch_all_fo_records(base_url, "CustomersV3", token, filter_expr)
# BAD -- All requests share one user's 6,000/5min budget
sp_token = get_token(client_id="single-app-id", ...)
for batch in all_batches:
call_api(batch, token=sp_token) # Hits 429 quickly at scale
# GOOD -- Each SP has its own 6,000/5min budget per web server
sps = [{"client_id": "app-1", ...}, {"client_id": "app-2", ...}]
for i, batch in enumerate(all_batches):
token = get_token(**sps[i % len(sps)])
call_api(batch, token=token) # 2x+ throughput
// BAD -- Missing If-Match header causes 412 errors
await axios.patch(`${baseUrl}/customers(${id})`, data, {
headers: { 'Authorization': `Bearer ${token}` }
}); // Returns 412 Precondition Failed
// GOOD -- Fetch current ETag, include in update
const current = await axios.get(`${baseUrl}/customers(${id})`, { headers });
const etag = current.data['@odata.etag'];
await axios.patch(`${baseUrl}/customers(${id})`, data, {
headers: { ...headers, 'If-Match': etag }
});
Load-test in a sandbox matching production web server count. [src1]Check $metadata for entity key fields before building URLs. [src2]Design for per-user limits; distribute across multiple users/SPs. [src3]Make separate API calls for nested entities. [src2]Migrate to API v2.0 or custom API pages. [src7]Use SaveChangesOptions.PostOnlySetProperties. [src2]# F&O: Check OData service availability
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer {token}" \
"https://{env}.operations.dynamics.com/data/$metadata"
# Expected: 200
# F&O: List all available data entities
curl -s -H "Authorization: Bearer {token}" \
"https://{env}.operations.dynamics.com/data/"
# F&O: Test simple query
curl -s -H "Authorization: Bearer {token}" \
"https://{env}.operations.dynamics.com/data/CustomersV3?$top=1&cross-company=true"
# BC: Check API availability
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer {token}" \
"https://api.businesscentral.dynamics.com/v2.0/{tenant}/{env}/api/v2.0/companies"
# BC: List companies
curl -s -H "Authorization: Bearer {token}" \
"https://api.businesscentral.dynamics.com/v2.0/{tenant}/{env}/api/v2.0/companies"
# BC: Check webhook subscriptions
curl -s -H "Authorization: Bearer {token}" \
"https://api.businesscentral.dynamics.com/v2.0/{tenant}/{env}/api/v2.0/subscriptions"
| Version / Release | Date | Status | Key Changes | Migration Notes |
|---|---|---|---|---|
| F&O 10.0.36+ | 2024 Wave 1 | Current | User-based limits disabled by default | Resource-based limits remain mandatory |
| F&O 10.0.33 | 2023 Wave 1 | Previous | User-based limits announced mandatory | Was rolled back; now optional only |
| F&O 10.0.19 | 2021 | Historical | Resource-based protection introduced | First version with API throttling |
| BC API v2.0 | 2022+ | Current | 55 standard endpoints, custom API pages | Preferred over v1.0 and OData services |
| BC API v1.0 | 2020 | Supported | Original API version | Migrate to v2.0 for new integrations |
| BC OData page endpoints | 2015+ | Deprecating 2027 | Published pages/queries at /ODataV4/ | Migrate to API v2.0 or custom API pages |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Real-time CRUD on individual records (<1K) | Bulk data migration >10K records | Data Management Framework (DMF) -- exempt from rate limits |
| Interactive apps needing instant response | Large scheduled ETL jobs | Recurring Integrations API (F&O) or multi-SP distribution (BC) |
| OData queries with filters and pagination | Cross-system real-time sync <1s latency | Dual Write (F&O to Dataverse) |
| Custom business logic via bound actions | Accessing F&O data from Power Platform | Virtual Entities |
| Standard entity operations | Complex multi-object transactions | Custom X++ services (F&O) or codeunit APIs (BC) |