Microsoft Business Central API v2.0: OData REST Capabilities, Rate Limits, and Integration Patterns

Type: ERP Integration System: Microsoft Dynamics 365 Business Central (API v2.0) Confidence: 0.91 Sources: 8 Verified: 2026-03-02 Freshness: 2026-03-02

TL;DR

System Profile

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.

PropertyValue
VendorMicrosoft
SystemDynamics 365 Business Central (API v2.0)
API SurfaceOData v4 (REST/JSON)
Current API Versionv2.0 (GA since version 18.3, 2021 wave 2)
Editions CoveredEssentials, Premium (SaaS)
DeploymentCloud (Business Central Online)
API DocsBusiness Central API v2.0 Reference
StatusGA

API Surfaces & Capabilities

API SurfaceProtocolBest ForMax Records/RequestRate LimitReal-time?Bulk?
Standard API v2.0OData v4 / HTTPS/JSONCRUD on standard entities, queries, webhooks20,000 (page size)6,000/5min per userYesVia $batch (100 ops)
Custom API PagesOData v4 / HTTPS/JSONCustom entities via AL extensions20,000 (page size)Shared with standardYesVia $batch
Automation APIOData v4 / HTTPS/JSONTenant setup, company creation, extension mgmt20,000Shared with standardYesNo
SOAP Web ServicesSOAP/XMLLegacy integrations (deprecated)65,536 KB max message6,000/5min per userYesNo
Webhooks (Subscriptions)Push/JSONEvent-driven notifications on entity changesN/A (push)200 subscriptions/envYesN/A

Rate Limits & Quotas

Per-Request Limits

Limit TypeValueApplies ToNotes
Max page size20,000 entitiesOData v4 queryUse @odata.nextLink for pagination
Max request body size350 MBOData v4Larger payloads should use file/image column upload
Max $batch operations100OData v4 $batchEach sub-operation counts toward rate limits
Operation timeout8 minutesOData v4 requestReturns 408 Request Timeout
Gateway timeout10 minutesAny requestReturns 504 Gateway Timeout
Max file upload size350 MBFile uploadUpload timeout is 65 seconds

Rolling / Per-User Limits

Limit TypeValueWindowEdition Differences
OData speed (rate)6,000 requests5-minute sliding window per userSame for all editions; sandbox: 300/min, production: 600/min (legacy per-env metric)
Max concurrent OData requests5 per userPer user (processing)Excess requests queue for up to 8 min, then 503
Max OData connections100 per userSimultaneous (processed + queued)Exceeded returns 429
Max OData queue size95 per userQueued requestsExceeded returns 429
SOAP speed (rate)6,000 requests5-minute sliding window per userSame as OData — SOAP is deprecated
Max webhook subscriptions200Per environmentSubscriptions expire after 3 days if not renewed

Throughput Scaling

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]

Authentication

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]

FlowUse WhenToken LifetimeRefresh?Notes
Client Credentials (S2S)Server-to-server, no user context, unattended integrationsAccess: ~60-75 minNo refresh token; request new before expiryRequires API.ReadWrite.All + Automation.ReadWrite.All permissions. Must create Entra application card in BC.
Authorization CodeUser-context operations, interactive appsAccess: ~60-75 min; Refresh: up to 90 daysYesUser must have BC license. Supports MFA.
Authorization Code + PKCESPAs and mobile appsAccess: ~60-75 minYes (limited for SPA)Required for public clients.

Authentication Gotchas

Constraints

Integration Pattern Decision Tree

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

Quick Reference

OperationMethodEndpointPayloadNotes
List companiesGET/api/v2.0/companiesN/AReturns all accessible companies
Get customersGET/api/v2.0/companies({id})/customersN/ASupports $filter, $select, $expand, $orderby
Create customerPOST/api/v2.0/companies({id})/customersJSONReturns created entity with system-generated ID
Update customerPATCH/api/v2.0/companies({id})/customers({id})JSONRequires If-Match header with ETag
Delete customerDELETE/api/v2.0/companies({id})/customers({id})N/ARequires If-Match header
Batch operationsPOST/api/v2.0/$batchMultipart/JSONMax 100 operations per batch
Create webhookPOST/api/v2.0/subscriptionsJSONRequires notificationUrl + resource path
Renew webhookPATCH/api/v2.0/subscriptions({id})JSONMust renew before 3-day expiry
Get metadataGET/api/v2.0/$metadataN/AFull OData metadata document
Deep insertPOST/api/v2.0/companies({id})/salesQuotesNested JSONLines can be included in body

Step-by-Step Integration Guide

1. Register Entra ID application and configure permissions

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".

2. Create Entra application card in Business Central

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.

3. Acquire access token and test connectivity

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.

4. Query entities with OData filters

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.

5. Handle pagination with @odata.nextLink

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.

6. Implement error handling and rate limit retries

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.

Code Examples

Python: CRUD Operations with Rate Limit Handling

# 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"})

JavaScript/Node.js: Webhook Subscription Management

// 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

cURL: Quick API test

# 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}'

Data Mapping

API URL Structure Reference

ComponentFormatExample
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.0For tenant/company management
Custom API.../api/{publisher}/{group}/{version}.../api/contoso/app1/v1.0/customEntities

Data Type Gotchas

Error Handling & Failure Points

Common Error Codes

CodeMeaningCauseResolution
400BadRequest_NotFoundInvalid OData query syntax or entity not foundCheck $filter syntax; verify entity name casing
400BadRequest_InvalidTokenMissing or wrong If-Match ETag headerRe-fetch entity to get current @odata.etag
401Authentication_InvalidCredentialsInvalid/expired token or wrong scopeVerify scope is api.businesscentral.dynamics.com/.default
403AuthorizationInsufficient permissions for application userAssign correct permission sets in BC
408Request TimeoutOperation exceeded 8-minute timeoutSplit into smaller requests; add $filter
409Request_EntityChangedConcurrent modification (ETag mismatch)Re-read entity, merge changes, retry
429Too Many RequestsRate or connection limit exceededExponential backoff; distribute across users
503Service Temporarily UnavailableRequest queue full (>95 queued)Back off and retry; redistribute load
504Gateway TimeoutRequest exceeded 10 minutesRefactor into smaller queries

Failure Points in Production

Anti-Patterns

Wrong: Polling all records to detect changes

# 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)

Correct: Use $filter on lastModifiedDateTime or webhooks

# 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

Wrong: One API call per record in a loop

# BAD — 1,000 items = 1,000 API calls from rate limit
for item in items: requests.post(f"{url}/items", headers=h, json=item)

Correct: Use $batch to group operations

# 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)

Wrong: Ignoring ETag concurrency control

# BAD — no If-Match header, may overwrite concurrent changes
requests.patch(f"{url}/customers({id})", headers=h, json=data)

Correct: Always include If-Match with current ETag

# 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)

Common Pitfalls

Diagnostic Commands

# 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

Version History & Compatibility

API VersionAvailabilityStatusBreaking ChangesMigration Notes
API v2.0BC version 18.3 (2021 wave 2)Current / GAEntity changes from v1.0Recommended for all new integrations
API v1.0BC version 14 (2019)DeprecatedN/ATransition guide available; v2.0 has different entity names
SOAPBC version 1+Deprecated (warning)Throughput will be reducedMigrate to OData v4 / API v2.0 immediately
Automation API v2.0BC version 22Current / GAv1.0 deprecatedUse for tenant/extension management

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Real-time CRUD on standard BC entitiesHigh-volume data migration (>100K records)iPaaS with managed queuing or RapidStart Services
Webhook-driven event syncNeed Dataverse/CRM dataDynamics 365 Dataverse Web API
Moderate-volume scheduled syncNeed custom fields on standard entitiesCustom API pages (AL extension)
Unattended S2S integrationsInteractive user-facing app with MFAAuthorization Code + PKCE flow
Querying financial dataReporting across large datasetsOData connected services in Excel/Power BI

Important Caveats

Related Units