https://api.businesscentral.dynamics.com/.default. Requires Entra ID app with API.ReadWrite.All permission. [src3]This card covers the standard Microsoft Dynamics 365 Business Central API v2.0 for Business Central Online (SaaS). The API v2.0 provides OData v4 REST endpoints for ~55 standard business entities across financials, sales, purchasing, and inventory. Business Central is Microsoft's cloud ERP for small-to-mid-size businesses. This card does NOT cover: Dynamics 365 Finance & Operations, Dynamics 365 Dataverse/CRM Web API, Business Central on-premises, or custom API pages.
| Property | Value |
|---|---|
| Vendor | Microsoft |
| System | Dynamics 365 Business Central (API v2.0) |
| API Surface | OData v4 (REST/JSON) |
| Current API Version | v2.0 (GA since version 18.3, 2021 wave 2) |
| Editions Covered | Essentials, Premium (SaaS) |
| Deployment | Cloud (Business Central Online) |
| API Docs | Business Central API v2.0 Reference |
| Status | GA |
| API Surface | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| Standard API v2.0 | OData v4 / HTTPS/JSON | CRUD on standard entities, queries, webhooks | 20,000 (page size) | 6,000/5min per user | Yes | Via $batch (100 ops) |
| Custom API Pages | OData v4 / HTTPS/JSON | Custom entities via AL extensions | 20,000 (page size) | Shared with standard | Yes | Via $batch |
| Automation API | OData v4 / HTTPS/JSON | Tenant setup, company creation, extension mgmt | 20,000 | Shared with standard | Yes | No |
| SOAP Web Services | SOAP/XML | Legacy integrations (deprecated) | 65,536 KB max message | 6,000/5min per user | Yes | No |
| Webhooks (Subscriptions) | Push/JSON | Event-driven notifications on entity changes | N/A (push) | 200 subscriptions/env | Yes | N/A |
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max page size | 20,000 entities | OData v4 query | Use @odata.nextLink for pagination |
| Max request body size | 350 MB | OData v4 | Larger payloads should use file/image column upload |
| Max $batch operations | 100 | OData v4 $batch | Each sub-operation counts toward rate limits |
| Operation timeout | 8 minutes | OData v4 request | Returns 408 Request Timeout |
| Gateway timeout | 10 minutes | Any request | Returns 504 Gateway Timeout |
| Max file upload size | 350 MB | File upload | Upload timeout is 65 seconds |
| Limit Type | Value | Window | Edition Differences |
|---|---|---|---|
| OData speed (rate) | 6,000 requests | 5-minute sliding window per user | Same for all editions; sandbox: 300/min, production: 600/min (legacy per-env metric) |
| Max concurrent OData requests | 5 per user | Per user (processing) | Excess requests queue for up to 8 min, then 503 |
| Max OData connections | 100 per user | Simultaneous (processed + queued) | Exceeded returns 429 |
| Max OData queue size | 95 per user | Queued requests | Exceeded returns 429 |
| SOAP speed (rate) | 6,000 requests | 5-minute sliding window per user | Same as OData — SOAP is deprecated |
| Max webhook subscriptions | 200 | Per environment | Subscriptions expire after 3 days if not renewed |
Business Central rate limits are per-user. More users = more throughput per environment. If a single service principal is hitting limits, distribute workload across multiple service principals in a round-robin pattern. There are no per-environment daily quotas currently enforced, but Microsoft reserves the right to add entitlement quotas in the future. [src1]
All Business Central API v2.0 authentication goes through Microsoft Entra ID (formerly Azure AD) using OAuth 2.0. The scope is https://api.businesscentral.dynamics.com/.default. [src3]
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| Client Credentials (S2S) | Server-to-server, no user context, unattended integrations | Access: ~60-75 min | No refresh token; request new before expiry | Requires API.ReadWrite.All + Automation.ReadWrite.All permissions. Must create Entra application card in BC. |
| Authorization Code | User-context operations, interactive apps | Access: ~60-75 min; Refresh: up to 90 days | Yes | User must have BC license. Supports MFA. |
| Authorization Code + PKCE | SPAs and mobile apps | Access: ~60-75 min | Yes (limited for SPA) | Required for public clients. |
https://api.businesscentral.dynamics.com/.default: Using the wrong scope causes authentication failures. The scope is the same for all tenants and environments. [src3]https://api.businesscentral.dynamics.com in ValidAudiences. [src3]START — User needs to integrate with Business Central API v2.0
|-- What's the integration pattern?
| |-- Real-time (individual records, <1s)
| | |-- Data volume < 100 records/operation?
| | | |-- YES --> Standard API v2.0: GET/POST/PATCH/DELETE
| | | |-- NO --> $batch requests (chunk into 100 ops per batch)
| | |-- Need notifications on entity changes?
| | |-- YES --> Webhooks (POST /api/v2.0/subscriptions)
| | |-- NO --> REST API polling with $filter=lastModifiedDateTime gt {timestamp}
| |-- Batch/Bulk (scheduled, high volume)
| | |-- Data volume < 6,000 records per 5 min?
| | | |-- YES --> Sequential API calls within rate limit window
| | | |-- NO --> Distribute across multiple service principals (round-robin)
| | |-- Can tolerate overnight processing?
| | |-- YES --> Schedule during off-peak, use multiple users, $batch
| | |-- NO --> Consider iPaaS middleware for managed queuing
| |-- Event-driven (webhooks)
| | |-- Need guaranteed delivery?
| | | |-- YES --> Webhook + dead letter queue (BC retries for 36h)
| | | |-- NO --> Webhook with basic error handling
| | |-- More than 1,000 changes in 30 seconds?
| | |-- YES --> Will receive 'collection' notification — re-query with filter
| | |-- NO --> Individual created/updated/deleted notifications
| |-- File-based (CSV/XML)
| |-- Use RapidStart Services or configuration packages in BC
|-- Which direction?
| |-- Inbound (writing to BC) --> POST/PATCH, use If-Match for concurrency
| |-- Outbound (reading from BC) --> GET with $filter/$select/$expand
| |-- Bidirectional --> Use lastModifiedDateTime for conflict detection
|-- Error tolerance?
|-- Zero-loss required --> Implement idempotency + dead letter queue
|-- Best-effort --> Fire-and-forget with exponential backoff on 429
| Operation | Method | Endpoint | Payload | Notes |
|---|---|---|---|---|
| List companies | GET | /api/v2.0/companies | N/A | Returns all accessible companies |
| Get customers | GET | /api/v2.0/companies({id})/customers | N/A | Supports $filter, $select, $expand, $orderby |
| Create customer | POST | /api/v2.0/companies({id})/customers | JSON | Returns created entity with system-generated ID |
| Update customer | PATCH | /api/v2.0/companies({id})/customers({id}) | JSON | Requires If-Match header with ETag |
| Delete customer | DELETE | /api/v2.0/companies({id})/customers({id}) | N/A | Requires If-Match header |
| Batch operations | POST | /api/v2.0/$batch | Multipart/JSON | Max 100 operations per batch |
| Create webhook | POST | /api/v2.0/subscriptions | JSON | Requires notificationUrl + resource path |
| Renew webhook | PATCH | /api/v2.0/subscriptions({id}) | JSON | Must renew before 3-day expiry |
| Get metadata | GET | /api/v2.0/$metadata | N/A | Full OData metadata document |
| Deep insert | POST | /api/v2.0/companies({id})/salesQuotes | Nested JSON | Lines can be included in body |
Register a new application in Microsoft Entra ID. Add Dynamics 365 Business Central API permissions: API.ReadWrite.All (Application type) and optionally Automation.ReadWrite.All. Create a client secret or certificate. Grant admin consent. [src3]
# Using Azure CLI to register app (alternative to portal)
az ad app create --display-name "BC Integration App" --sign-in-audience AzureADMyOrg
# Note the Application (client) ID from output
# Add API permission for Business Central via Azure Portal:
# API permissions > Add > Dynamics 365 Business Central > API.ReadWrite.All
Verify: Navigate to Azure Portal > App registrations > your app > API permissions. Confirm Dynamics 365 Business Central / API.ReadWrite.All shows "Granted".
In BC, search for "Microsoft Entra applications". Create a new card with the client ID from step 1. Set State to Enabled. Assign permission sets (NOT SUPER). [src3]
Business Central steps:
1. Search for "Microsoft Entra applications" in BC
2. Click "New"
3. Enter Client ID from Entra ID app registration
4. Set State = Enabled
5. Assign permission sets (e.g., D365 AUTOMATION)
6. Click "Grant Consent" if needed
Verify: The application card shows State = Enabled and has permission sets assigned.
Acquire an access token using client credentials flow, then call the companies endpoint to verify. [src3]
# Acquire token
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "scope=https://api.businesscentral.dynamics.com/.default" \
-d "grant_type=client_credentials" \
| jq -r '.access_token')
# List companies
curl -s "https://api.businesscentral.dynamics.com/v2.0/YOUR_TENANT_ID/production/api/v2.0/companies" \
-H "Authorization: Bearer $TOKEN" | jq .
Verify: Response contains value array with company objects containing id, name, displayName.
Use $filter, $select, $expand, and $orderby to query efficiently. [src2, src5]
COMPANY_ID="your-company-id"
curl -s "https://api.businesscentral.dynamics.com/v2.0/YOUR_TENANT_ID/production/api/v2.0/companies(${COMPANY_ID})/customers?\$filter=lastModifiedDateTime gt 2026-01-01T00:00:00Z&\$select=id,displayName,email&\$orderby=displayName" \
-H "Authorization: Bearer $TOKEN" | jq .
Verify: Response contains filtered customer entities. Check @odata.nextLink if more pages exist.
Business Central returns up to 20,000 records per page. Follow @odata.nextLink until no more pages. [src1]
import requests
def get_all_pages(url, headers):
all_records = []
while url:
response = requests.get(url, headers=headers)
response.raise_for_status()
data = response.json()
all_records.extend(data.get("value", []))
url = data.get("@odata.nextLink")
return all_records
Verify: len(records) matches expected count in Business Central.
Handle 429 (Too Many Requests) with exponential backoff. Also handle 503 and 504. [src2]
import time, requests
def bc_request(method, url, headers, json=None, max_retries=5):
for attempt in range(max_retries):
response = requests.request(method, url, headers=headers, json=json)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(retry_after)
continue
elif response.status_code == 503:
time.sleep(2 ** (attempt + 2))
continue
elif response.status_code == 504:
raise TimeoutError("Request exceeded 10-minute gateway timeout")
response.raise_for_status()
return response
raise Exception(f"Max retries exceeded for {url}")
Verify: Function returns successful response or raises appropriate exception after retries.
# Input: tenant_id, client_id, client_secret, environment_name
# Output: Create, read, update, delete customer in Business Central
import msal, requests
app = msal.ConfidentialClientApplication(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential=client_secret
)
result = app.acquire_token_for_client(
scopes=["https://api.businesscentral.dynamics.com/.default"]
)
headers = {
"Authorization": f"Bearer {result['access_token']}",
"Content-Type": "application/json",
"Accept": "application/json"
}
base_url = f"https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{env}/api/v2.0"
# Get company, then CRUD on customers
companies = requests.get(f"{base_url}/companies", headers=headers).json()
company_url = f"{base_url}/companies({companies['value'][0]['id']})"
# CREATE
new = requests.post(f"{company_url}/customers", headers=headers,
json={"displayName": "Acme Corp"}).json()
# UPDATE (requires If-Match with ETag)
requests.patch(f"{company_url}/customers({new['id']})",
headers={**headers, "If-Match": new["@odata.etag"]},
json={"phoneNumber": "+1-555-0100"})
// Input: tenantId, clientId, clientSecret, notificationUrl
// Output: Created webhook subscription for customer changes
const { ClientSecretCredential } = require("@azure/identity");
const fetch = require("node-fetch");
const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
const token = await credential.getToken("https://api.businesscentral.dynamics.com/.default");
const headers = { Authorization: `Bearer ${token.token}`, "Content-Type": "application/json" };
// Create webhook subscription
const sub = await fetch(`${baseUrl}/subscriptions`, {
method: "POST", headers,
body: JSON.stringify({
notificationUrl: "https://your-endpoint.com/bc-notify",
resource: `/api/v2.0/companies(${companyId})/customers`,
clientState: "your-shared-secret"
})
}).then(r => r.json());
// Renew before 3-day expiry with PATCH
# Acquire token and list companies
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/TENANT/oauth2/v2.0/token" \
-d "client_id=ID&client_secret=SECRET&scope=https://api.businesscentral.dynamics.com/.default&grant_type=client_credentials" \
| jq -r '.access_token')
curl -s "https://api.businesscentral.dynamics.com/v2.0/TENANT/production/api/v2.0/companies" \
-H "Authorization: Bearer $TOKEN" | jq '.value[] | {id, displayName}'
| Component | Format | Example |
|---|---|---|
| Base URL (SaaS) | https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/v2.0 | .../v2.0/abc123/production/api/v2.0 |
| Company-scoped entity | {baseUrl}/companies({companyId})/{entity} | .../companies(guid)/customers |
| Automation API | .../api/microsoft/automation/v2.0 | For tenant/company management |
| Custom API | .../api/{publisher}/{group}/{version} | .../api/contoso/app1/v1.0/customEntities |
If-Match header with the entity's @odata.etag. Missing it returns BadRequest_InvalidToken. [src6]salesOrders?$expand=salesOrderLines($expand=item). [src2]| Code | Meaning | Cause | Resolution |
|---|---|---|---|
| 400 | BadRequest_NotFound | Invalid OData query syntax or entity not found | Check $filter syntax; verify entity name casing |
| 400 | BadRequest_InvalidToken | Missing or wrong If-Match ETag header | Re-fetch entity to get current @odata.etag |
| 401 | Authentication_InvalidCredentials | Invalid/expired token or wrong scope | Verify scope is api.businesscentral.dynamics.com/.default |
| 403 | Authorization | Insufficient permissions for application user | Assign correct permission sets in BC |
| 408 | Request Timeout | Operation exceeded 8-minute timeout | Split into smaller requests; add $filter |
| 409 | Request_EntityChanged | Concurrent modification (ETag mismatch) | Re-read entity, merge changes, retry |
| 429 | Too Many Requests | Rate or connection limit exceeded | Exponential backoff; distribute across users |
| 503 | Service Temporarily Unavailable | Request queue full (>95 queued) | Back off and retry; redistribute load |
| 504 | Gateway Timeout | Request exceeded 10 minutes | Refactor into smaller queries |
Create multiple Entra ID app registrations and BC application users. Distribute requests round-robin. [src1]Run scheduled job every 2 days to PATCH all subscriptions. Alert on renewal failure. [src4, src8]Parse each sub-response and check individual HTTP status codes. [src2]Ensure endpoint responds within 5 seconds with 200 OK and validationToken in response body. [src4]Use MSAL token cache — acquire_token_for_client() handles refresh automatically. [src3]# BAD — fetches ALL customers every sync, wastes API calls
all_customers = get_all_pages(f"{company_url}/customers", headers)
for customer in all_customers:
if customer_changed(customer): process(customer)
# GOOD — only fetch modified records
url = f"{company_url}/customers?$filter=lastModifiedDateTime gt {last_sync}"
changed = get_all_pages(url, headers)
# EVEN BETTER — use webhooks for push notifications
# BAD — 1,000 items = 1,000 API calls from rate limit
for item in items: requests.post(f"{url}/items", headers=h, json=item)
# GOOD — batch up to 100 operations per request
batch = {"requests": [{"method": "POST", "url": f"companies({cid})/items",
"headers": {"Content-Type": "application/json"}, "body": item}
for item in chunk]}
requests.post(f"{url}/$batch", headers=h, json=batch)
# BAD — no If-Match header, may overwrite concurrent changes
requests.patch(f"{url}/customers({id})", headers=h, json=data)
# GOOD — fetch current ETag, include in If-Match
customer = requests.get(f"{url}/customers({id})", headers=h).json()
requests.patch(f"{url}/customers({id})",
headers={**h, "If-Match": customer["@odata.etag"]}, json=data)
Test with realistic data volumes using production rate limit as baseline. [src1]Always include companies({companyId}) in URL. Fetch company ID first via GET /api/v2.0/companies. [src6]Always combine $top with $orderby for deterministic pagination. [src2]Return validationToken query param as plain text with 200 OK. [src4]Use D365 AUTOMATION or custom permission sets. [src3]Check each sub-response status code individually. [src2]# Acquire token for Business Central
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token" \
-d "client_id=CLIENT_ID&client_secret=SECRET&scope=https://api.businesscentral.dynamics.com/.default&grant_type=client_credentials" \
-H "Content-Type: application/x-www-form-urlencoded" | jq -r '.access_token')
# Test authentication - list companies
curl -s "https://api.businesscentral.dynamics.com/v2.0/TENANT/production/api/v2.0/companies" \
-H "Authorization: Bearer $TOKEN" | jq '.value[] | {id, displayName}'
# Check available API endpoints (metadata)
curl -s "https://api.businesscentral.dynamics.com/v2.0/TENANT/production/api/v2.0/\$metadata" \
-H "Authorization: Bearer $TOKEN" | head -100
# List webhook subscriptions
curl -s "https://api.businesscentral.dynamics.com/v2.0/TENANT/production/api/v2.0/subscriptions" \
-H "Authorization: Bearer $TOKEN" | jq '.value[]'
# Verify Entra ID app (Azure CLI)
az ad app show --id CLIENT_ID --query "{name:displayName,appId:appId}" -o table
| API Version | Availability | Status | Breaking Changes | Migration Notes |
|---|---|---|---|---|
| API v2.0 | BC version 18.3 (2021 wave 2) | Current / GA | Entity changes from v1.0 | Recommended for all new integrations |
| API v1.0 | BC version 14 (2019) | Deprecated | N/A | Transition guide available; v2.0 has different entity names |
| SOAP | BC version 1+ | Deprecated (warning) | Throughput will be reduced | Migrate to OData v4 / API v2.0 immediately |
| Automation API v2.0 | BC version 22 | Current / GA | v1.0 deprecated | Use for tenant/extension management |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Real-time CRUD on standard BC entities | High-volume data migration (>100K records) | iPaaS with managed queuing or RapidStart Services |
| Webhook-driven event sync | Need Dataverse/CRM data | Dynamics 365 Dataverse Web API |
| Moderate-volume scheduled sync | Need custom fields on standard entities | Custom API pages (AL extension) |
| Unattended S2S integrations | Interactive user-facing app with MFA | Authorization Code + PKCE flow |
| Querying financial data | Reporting across large datasets | OData connected services in Excel/Power BI |