---
# === IDENTITY ===
id: business/erp-integration/business-central-api-v2/2026
canonical_question: "What are the Microsoft Business Central API v2.0 OData-based REST capabilities and limits?"
aliases:
  - "Business Central API v2.0 rate limits and OData endpoints"
  - "Dynamics 365 Business Central REST API capabilities and throttling"
  - "BC API v2.0 authentication, webhooks, and operational limits"
  - "How to integrate with Business Central API v2.0 using OData"
entity_type: erp_integration
domain: business > erp-integration > business-central-api-v2
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === SYSTEM PROFILE ===
systems:
  - name: "Microsoft Dynamics 365 Business Central"
    vendor: "Microsoft"
    version: "API v2.0"
    edition: "Essentials, Premium (SaaS)"
    deployment: "cloud"
    api_surface: "OData v4 (REST)"

# === VERIFICATION ===
last_verified: 2026-03-02
confidence: 0.91
version: 1.0
first_published: 2026-03-02

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: volatile
  last_breaking_change: "2021 — API v2.0 introduced, v1.0 deprecated"
  next_review: 2026-08-29
  change_sensitivity: high

# === CONSTRAINTS ===
constraints:
  - "Rate limit: 6,000 OData requests per user per 5-minute sliding window (production and sandbox)"
  - "Speed limit: 600 requests/minute per user (production), 300/minute (sandbox)"
  - "Max concurrent OData requests processing: 5 per user (queued requests wait up to 8 minutes)"
  - "Max simultaneous OData connections: 100 per user (processed + queued)"
  - "Max OData page size: 20,000 entities per response"
  - "Operation timeout: 8 minutes per OData request (504 Gateway Timeout at 10 minutes)"
  - "Max $batch size: 100 operations per batch request"
  - "Max request body size: 350 MB"
  - "SOAP endpoints deprecated — migrate to OData/REST API"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs Dynamics 365 Dataverse/CRM API (Sales, Service, Marketing)"
    use_instead: "business/erp-integration/dynamics-365-rest-api/2026"
  - condition: "User needs Business Central authentication setup specifically"
    use_instead: "business/erp-integration/dynamics-365-authentication/2026"
  - condition: "User needs Dynamics 365 Finance & Operations API"
    use_instead: "business/erp-integration/dynamics-365-fo-api/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 notifications on entity changes)"
      - "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 Business Central)"
      - "outbound (reading from Business Central)"
      - "bidirectional sync"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/business/erp-integration/business-central-api-v2/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-03-02)"

# === RELATED UNITS ===
related_kos:
  depends_on:
    - id: "business/erp-integration/dynamics-365-authentication/2026"
      label: "Dynamics 365 authentication — Entra ID OAuth 2.0, S2S setup"
  related_to:
    - id: "business/erp-integration/dynamics-365-rest-api/2026"
      label: "Dynamics 365 Dataverse Web API capabilities and rate limits"
  solves:
    - id: "business/erp-integration/netsuite-business-central-integration/2026"
      label: "NetSuite to Business Central integration playbook"
  alternative_to:
    - id: "business/erp-integration/business-central-soap-api/2026"
      label: "Business Central SOAP API (deprecated — use OData/REST instead)"
  often_confused_with:
    - id: "business/erp-integration/dynamics-365-rest-api/2026"
      label: "Dynamics 365 Dataverse Web API (different product, different API surface)"

# === SOURCES ===
sources:
  - id: src1
    title: "Operational Limits for Business Central Online"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/operational-limits-online
    type: official_docs
    published: 2025-04-08
    reliability: authoritative
  - id: src2
    title: "Working with API Limits in Dynamics 365 Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-rate-limits
    type: official_docs
    published: 2024-11-13
    reliability: authoritative
  - id: src3
    title: "Using Service to Service Authentication"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/automation-apis-using-s2s-authentication
    type: official_docs
    published: 2025-06-20
    reliability: authoritative
  - id: src4
    title: "Working with Webhooks in Business Central API v2.0"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-subscriptions
    type: official_docs
    published: 2024-09-27
    reliability: authoritative
  - id: src5
    title: "API (v2.0) for Dynamics 365 Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/
    type: official_docs
    published: 2024-10-28
    reliability: authoritative
  - id: src6
    title: "Troubleshooting REST API/OData Calls"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/webservices/dynamics-error-codes
    type: official_docs
    published: 2024-02-06
    reliability: authoritative
  - id: src7
    title: "Endpoints for the APIs for Microsoft Dynamics 365 Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/endpoints-apis-for-dynamics
    type: official_docs
    published: 2024-10-28
    reliability: authoritative
  - id: src8
    title: "Lessons Learned: Working with Webhooks in Business Central"
    author: Fredborg
    url: https://fredborg.org/?p=914
    type: technical_blog
    published: 2024-06-15
    reliability: moderate_high
---

# Microsoft Business Central API v2.0: OData REST Capabilities, Rate Limits, and Integration Patterns

## TL;DR

- **Bottom line**: Business Central API v2.0 is an OData v4 REST API exposing ~55 standard business entities (customers, vendors, items, sales orders, GL entries). Use it for all new integrations; SOAP is deprecated. Rate limits are per-user, not per-environment. [src1]
- **Key limit**: 6,000 OData requests per user per 5-minute sliding window; max 5 concurrent requests processing at once; 100 simultaneous connections per user. Production gets 600 req/min, sandbox gets 300 req/min. [src1]
- **Watch out for**: Rate limits are strictly per-user — a single service principal hitting limits throttles only that user. Spread workload across multiple application users to increase throughput. [src1, src2]
- **Best for**: Real-time CRUD on individual Business Central entities, webhook-driven event notifications, and moderate-volume batch operations via $batch. [src2, src5]
- **Authentication**: OAuth 2.0 via Microsoft Entra ID. Client credentials (S2S) for unattended integrations with scope `https://api.businesscentral.dynamics.com/.default`. Requires Entra ID app with `API.ReadWrite.All` permission. [src3]

## 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 (SMBs). This card does NOT cover: Dynamics 365 Finance & Operations (different product, different API), Dynamics 365 Dataverse/CRM Web API (different platform), Business Central on-premises (different endpoint patterns), or custom API pages (AL-developed extensions). [src5, src7]

| 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 URL** | [Business Central API v2.0 Reference](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/) |
| **Status** | GA |

## API Surfaces & Capabilities

Business Central exposes multiple API surfaces. The standard API v2.0 is the recommended surface for all new integrations. [src5, src7]

| 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 |

[src1, src4, src5]

## Rate Limits & Quotas

<!-- This is the highest-value section. Agents hallucinate rate limits constantly.
     Every number must be cited. -->

### Per-Request Limits

| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max page size | 20,000 entities | OData v4 query | Use `@odata.nextLink` for pagination [src1] |
| Max request body size | 350 MB | OData v4 | Larger payloads should use file/image column upload [src1] |
| Max $batch operations | 100 | OData v4 $batch | Each sub-operation counts toward rate limits [src1] |
| Operation timeout | 8 minutes | OData v4 request | Returns 408 Request Timeout [src1] |
| Gateway timeout | 10 minutes | Any request | Returns 504 Gateway Timeout [src2] |
| Max file upload size | 350 MB | File upload | Upload timeout is 65 seconds [src1] |

[src1]

### Rolling / Per-User Limits

| 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) [src1] |
| Max concurrent OData requests | 5 per user | Per user (processing) | Excess requests queue for up to 8 min, then 503 [src1] |
| Max OData connections | 100 per user | Simultaneous (processed + queued) | Exceeded returns 429 [src1] |
| Max OData queue size | 95 per user | Queued requests | Exceeded returns 429 [src1] |
| SOAP speed (rate) | 6,000 requests | 5-minute sliding window per user | Same as OData — SOAP is deprecated [src1] |
| Max webhook subscriptions | 200 | Per environment | Subscriptions expire after 3 days if not renewed [src1, src4] |

[src1, src2]

### 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 token endpoint is `https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token`. 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 token before expiry | Requires `API.ReadWrite.All` + `Automation.ReadWrite.All` permissions. Must create Entra application card in BC. [src3] |
| Authorization Code | User-context operations, interactive apps | Access: ~60-75 min; Refresh: up to 90 days | Yes | User must have BC license. Supports MFA. [src3] |
| Authorization Code + PKCE | SPAs and mobile apps | Access: ~60-75 min | Yes (limited for SPA) | Required for public clients [src3] |

[src3]

### Authentication Gotchas

- **S2S requires TWO setup steps**: (1) Register app in Entra ID with `API.ReadWrite.All` permission AND (2) create a Microsoft Entra application card in Business Central (search for "Microsoft Entra applications" page) and assign permission sets. Missing step 2 causes 401/403 errors even with a valid token. [src3]
- **Application users cannot have SUPER permission set**: Business Central explicitly blocks assigning SUPER to application users. Use `D365 AUTOMATION` or custom permission sets with least-privilege. [src3]
- **Scope must be `https://api.businesscentral.dynamics.com/.default`**: Using the wrong scope (e.g., the org-specific URL) causes authentication failures. The scope is the same for all tenants and environments. [src3]
- **On-premises requires `ValidAudiences` configuration**: If using BC on-premises with Entra ID auth, the server's `ValidAudiences` must include `https://api.businesscentral.dynamics.com`. Missing this causes `Authentication_InvalidCredentials`. [src3]
- **Admin consent may be required**: For multi-tenant apps, admin consent must be granted in each target tenant. Consider pre-consenting via the Adminagents group in the partner tenant. [src3]

## Constraints
<!-- Agents: read this section before recommending any integration approach from this unit.
     These are hard constraints that must not be violated. -->

- **Per-user rate limits are strictly enforced**: Unlike the legacy per-environment limits, the current per-user limits (6,000 req/5 min, 5 concurrent) are enforced with 429 responses. A single integration user can bottleneck your entire integration. [src1]
- **No bulk/batch API beyond $batch (100 ops max)**: Business Central has no equivalent to Salesforce Bulk API. For high-volume data loads, you must chunk into $batch requests of 100 operations each, respecting rate limits. [src1, src2]
- **Webhook subscriptions expire after 3 days**: Subscriptions must be renewed via PATCH before expiry. No automatic renewal. Build a timer to refresh subscriptions every 2 days. [src4]
- **API v2.0 cannot be extended with additional fields**: You cannot add custom fields to standard API v2.0 entities. You must copy the AL code and create a custom API page instead. [src5]
- **SOAP is deprecated**: Microsoft will reduce SOAP throughput and eventually deprecate it. All new integrations must use OData v4 / REST API. [src1]
- **Environment-level limits still apply**: Max 300 companies per environment, 3 TB database size, 10-minute SQL command timeout. These affect API operations indirectly. [src1]
- **Webhook collection notifications break Power Automate**: If >1,000 records change within 30 seconds, BC sends a `collection` notification instead of individual ones. Power Automate cannot process collection notifications, so the flow won't trigger. [src4]

## 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 on individual entities
|   |   |   |-- NO --> $batch requests (chunk into 100 ops per batch) [src2]
|   |   |-- Need notifications on entity changes?
|   |       |-- YES --> Webhooks (POST /api/v2.0/subscriptions) [src4]
|   |       |-- 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 on 408/429/5xx) [src4]
|   |   |   |-- NO --> Webhook with basic error handling
|   |   |-- More than 1,000 changes in 30 seconds?
|   |       |-- YES --> Will receive 'collection' notification — re-query with filter [src4]
|   |       |-- NO --> Individual created/updated/deleted notifications
|   |-- File-based (CSV/XML)
|       |-- Use RapidStart Services or configuration packages in BC
|       |-- Not covered by API v2.0 — use BC client or custom API pages
|-- Which direction?
|   |-- Inbound (writing to BC) --> POST/PATCH, check write rate limits, use If-Match for concurrency
|   |-- Outbound (reading from BC) --> GET with $filter/$select/$expand, respect page size [src2]
|   |-- Bidirectional --> Use lastModifiedDateTime for conflict detection, design resolution strategy FIRST
|-- Error tolerance?
    |-- Zero-loss required --> Implement idempotency + dead letter queue + concurrency tokens
    |-- Best-effort --> Fire-and-forget with exponential backoff retry on 429
```

## Quick Reference

| Operation | Method | Endpoint | Payload | Notes |
|---|---|---|---|---|
| List companies | GET | `/api/v2.0/companies` | N/A | Returns all companies the user has access to [src7] |
| Get customers | GET | `/api/v2.0/companies({id})/customers` | N/A | Supports $filter, $select, $expand, $orderby [src5] |
| Create customer | POST | `/api/v2.0/companies({id})/customers` | JSON | Returns created entity with system-generated ID [src5] |
| Update customer | PATCH | `/api/v2.0/companies({id})/customers({id})` | JSON | Requires If-Match header with ETag for concurrency [src5] |
| Delete customer | DELETE | `/api/v2.0/companies({id})/customers({id})` | N/A | Requires If-Match header [src5] |
| Batch operations | POST | `/api/v2.0/$batch` | Multipart/JSON | Max 100 operations per batch [src1] |
| Create webhook | POST | `/api/v2.0/subscriptions` | JSON | Requires notificationUrl + resource path [src4] |
| Renew webhook | PATCH | `/api/v2.0/subscriptions({id})` | JSON | Must renew before 3-day expiry [src4] |
| Get metadata | GET | `/api/v2.0/$metadata` | N/A | Full OData metadata document [src5] |
| Deep insert | POST | `/api/v2.0/companies({id})/salesQuotes` | Nested JSON | Lines can be included in body [src2] |

[src2, src5, src7]

## 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]

```bash
# 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
# This must be done in the 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 for [tenant]".

### 2. Create Entra application card in Business Central

In Business Central, search for "Microsoft Entra applications". Create a new card with the client ID from step 1. Set State to Enabled. Assign permission sets (e.g., `D365 AUTOMATION` for automation, or custom sets for specific entity access). [src3]

```plaintext
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 Description (e.g., "Integration App - [Partner Name]")
5. Set State = Enabled
6. Assign permission sets (NOT SUPER — it's blocked for app users)
7. Click "Grant Consent" if you didn't do it in Azure Portal
```

**Verify**: The application card shows State = Enabled and has permission sets assigned.

### 3. Acquire access token and test with WhoAmI-equivalent

Acquire an access token using client credentials flow, then call the companies endpoint to verify connectivity. [src3]

```bash
# 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 (equivalent to WhoAmI)
curl -s "https://api.businesscentral.dynamics.com/v2.0/YOUR_TENANT_ID/production/api/v2.0/companies" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" | jq .
```

**Verify**: Response contains `value` array with at least one company object containing `id`, `name`, `displayName`.

### 4. Query entities with OData filters

Use `$filter`, `$select`, `$expand`, and `$orderby` to query efficiently. Always use `$select` to minimize payload size. [src2, src5]

```bash
# Get customers modified since a specific date, with selected fields
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,lastModifiedDateTime&\$orderby=displayName" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" | 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. If more exist, the response includes `@odata.nextLink`. Follow it until no more pages. [src1]

```python
# Input:  access_token, base_url, company_id
# Output: All customer records across all pages

import requests

def get_all_pages(url, headers):
    """Follow @odata.nextLink until all pages are fetched."""
    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")  # None if no more pages
    return all_records

headers = {
    "Authorization": f"Bearer {access_token}",
    "Accept": "application/json"
}
base_url = f"https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/production/api/v2.0"
customers = get_all_pages(
    f"{base_url}/companies({company_id})/customers?$select=id,displayName",
    headers
)
print(f"Total customers: {len(customers)}")
```

**Verify**: `len(customers)` 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 (queue full) and 504 (gateway timeout). [src2]

```python
# Input:  API request function
# Output: Response with automatic retry on 429/503/504

import time
import requests

def bc_request(method, url, headers, json=None, max_retries=5):
    """Make BC API request with exponential backoff on rate limits."""
    for attempt in range(max_retries):
        response = requests.request(method, url, headers=headers, json=json)

        if response.status_code == 429:
            # Rate limited — back off exponentially
            retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
            print(f"Rate limited (429). Retrying in {retry_after}s...")
            time.sleep(retry_after)
            continue
        elif response.status_code == 503:
            # Queue full — wait longer
            wait = 2 ** (attempt + 2)  # Start at 4 seconds
            print(f"Queue full (503). Retrying in {wait}s...")
            time.sleep(wait)
            continue
        elif response.status_code == 504:
            # Gateway timeout — request took too long
            print("Gateway timeout (504). Consider splitting request.")
            raise TimeoutError("Request exceeded 10-minute gateway timeout")

        response.raise_for_status()
        return response

    raise Exception(f"Max retries ({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

```python
# Input:  tenant_id, client_id, client_secret, environment_name
# Output: Create, read, update, delete customer in Business Central

import msal
import requests
import time

tenant_id = "YOUR_TENANT_ID"
client_id = "YOUR_CLIENT_ID"
client_secret = "YOUR_CLIENT_SECRET"
env_name = "production"
base_url = f"https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{env_name}/api/v2.0"

# Acquire token via MSAL (handles caching automatically)
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"
}

# Get first company
companies = requests.get(f"{base_url}/companies", headers=headers).json()
company_id = companies["value"][0]["id"]
company_url = f"{base_url}/companies({company_id})"

# CREATE customer
new_customer = requests.post(
    f"{company_url}/customers",
    headers=headers,
    json={"displayName": "Acme Corp", "email": "info@acme.com"}
).json()
customer_id = new_customer["id"]
etag = new_customer["@odata.etag"]

# READ customer
customer = requests.get(
    f"{company_url}/customers({customer_id})",
    headers=headers
).json()

# UPDATE customer (requires If-Match with ETag)
headers_update = {**headers, "If-Match": etag}
updated = requests.patch(
    f"{company_url}/customers({customer_id})",
    headers=headers_update,
    json={"phoneNumber": "+1-555-0100"}
).json()

# DELETE customer
requests.delete(
    f"{company_url}/customers({customer_id})",
    headers={**headers, "If-Match": updated["@odata.etag"]}
)
```

### JavaScript/Node.js: Webhook Subscription Management

```javascript
// Input:  tenantId, clientId, clientSecret, environmentName, notificationUrl
// Output: Created webhook subscription for customer changes

// npm install @azure/identity node-fetch@2
const { ClientSecretCredential } = require("@azure/identity");
const fetch = require("node-fetch");

const tenantId = process.env.BC_TENANT_ID;
const clientId = process.env.BC_CLIENT_ID;
const clientSecret = process.env.BC_CLIENT_SECRET;
const envName = "production";
const baseUrl = `https://api.businesscentral.dynamics.com/v2.0/${tenantId}/${envName}/api/v2.0`;

async function manageWebhooks() {
  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",
    Accept: "application/json",
  };

  // Get company ID
  const companiesResp = await fetch(`${baseUrl}/companies`, { headers });
  const companies = await companiesResp.json();
  const companyId = companies.value[0].id;

  // Create webhook subscription for customer changes
  const subscription = await fetch(`${baseUrl}/subscriptions`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      notificationUrl: "https://your-webhook-endpoint.com/bc-notifications",
      resource: `/api/v2.0/companies(${companyId})/customers`,
      clientState: "your-shared-secret-for-verification",
    }),
  });
  const sub = await subscription.json();
  console.log("Subscription created:", sub.subscriptionId);
  console.log("Expires:", sub.expirationDateTime);

  // Renew subscription (must call PATCH before 3-day expiry)
  // Note: PATCH also requires validationToken handshake
  return sub;
}

manageWebhooks().catch(console.error);
```

### cURL: Quick API test and entity queries

```bash
# Input:  tenant_id, client_id, client_secret
# Output: Token + customer list

# Step 1: Acquire token
TOKEN=$(curl -s -X POST \
  "https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token" \
  -d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_SECRET&scope=https://api.businesscentral.dynamics.com/.default&grant_type=client_credentials" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  | jq -r '.access_token')

# Step 2: List companies
curl -s "https://api.businesscentral.dynamics.com/v2.0/YOUR_TENANT_ID/production/api/v2.0/companies" \
  -H "Authorization: Bearer $TOKEN" | jq '.value[] | {id, displayName}'

# Step 3: Query customers with $expand (include paymentTerm)
COMPANY_ID="your-company-guid"
curl -s "https://api.businesscentral.dynamics.com/v2.0/YOUR_TENANT_ID/production/api/v2.0/companies(${COMPANY_ID})/customers?\$top=5&\$select=displayName,email&\$expand=paymentTerm" \
  -H "Authorization: Bearer $TOKEN" | jq .

# Step 4: Create item via POST
curl -s -X POST \
  "https://api.businesscentral.dynamics.com/v2.0/YOUR_TENANT_ID/production/api/v2.0/companies(${COMPANY_ID})/items" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"number": "ITEM-001", "displayName": "Test Item", "type": "Inventory"}' | jq .
```

## Data Mapping

### API URL Structure Reference

| Component | Format | Example |
|---|---|---|
| Base URL (SaaS) | `https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/v2.0` | `https://api.businesscentral.dynamics.com/v2.0/abc123/production/api/v2.0` |
| Company-scoped entity | `{baseUrl}/companies({companyId})/{entity}` | `.../companies(guid)/customers` |
| Specific entity | `{baseUrl}/companies({companyId})/{entity}({entityId})` | `.../companies(guid)/customers(guid)` |
| Automation API | `https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/microsoft/automation/v2.0` | For tenant/company management [src3] |
| Custom API | `https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/{publisher}/{group}/{version}` | `.../api/contoso/app1/v1.0/customEntities` [src5] |

### Standard API v2.0 Entity Categories

| Category | Entities | Key Fields | Notes |
|---|---|---|---|
| **Financial** | accounts, generalLedgerEntries, journals, journalLines, taxGroups, taxAreas | id, number, displayName, lastModifiedDateTime | GL entries are read-only [src5] |
| **Sales** | customers, salesOrders, salesOrderLines, salesInvoices, salesInvoiceLines, salesCreditMemos, salesQuotes | id, number, customerNumber, totalAmount | Deep insert supported for order+lines [src2] |
| **Purchasing** | vendors, purchaseInvoices, purchaseInvoiceLines | id, number, vendorNumber | Similar pattern to sales entities [src5] |
| **Inventory** | items, itemCategories, unitsOfMeasure | id, number, displayName, inventory | inventory field is read-only (calculated) [src5] |
| **Reference** | currencies, paymentMethods, paymentTerms, shipmentMethods, countriesRegions, dimensions | id, code, displayName | Used as lookups for other entities [src5] |
| **HR** | employees | id, number, displayName, email | Limited HR fields in standard API [src5] |
| **Company** | companies, companyInformation | id, name, displayName | Companies endpoint is not company-scoped [src7] |

### Data Type Gotchas

- **All IDs are GUIDs**: Business Central uses GUIDs (not sequential integers) for all entity IDs. When creating records, the ID is system-generated — you cannot specify your own. [src5]
- **ETags are mandatory for updates**: Every PATCH and DELETE request requires an `If-Match` header with the entity's `@odata.etag` value. Without it, you get a `BadRequest_InvalidToken` error. The ETag changes with every update. [src6]
- **DateTime is UTC with Edm.DateTimeOffset**: All datetime fields use OData `Edm.DateTimeOffset` format (ISO 8601 with timezone). Business Central stores in UTC but may display in user timezone. [src5]
- **Decimal precision varies by field**: Currency amounts use the precision configured in BC (typically 2 decimal places). Quantity fields may use different precision. Sending excess precision silently truncates. [src5]
- **Multi-level $expand is supported**: You can expand nested navigational properties, e.g., `salesOrders?$expand=salesOrderLines($expand=item($expand=itemCategory))`. This reduces API calls significantly. [src2]

## Error Handling & Failure Points

### Common Error Codes

| Code | OData Error | Cause | Resolution |
|---|---|---|---|
| 400 | BadRequest_NotFound | Invalid OData query syntax or entity not found | Check $filter syntax; verify entity name casing [src6] |
| 400 | BadRequest_InvalidToken | Missing or wrong If-Match ETag header on PATCH/DELETE | Re-fetch entity to get current @odata.etag before update [src6] |
| 400 | BadRequest_RequiredParamNotProvided | Required field missing in POST body | Check which fields are mandatory for the entity [src6] |
| 401 | Authentication_InvalidCredentials | Invalid or expired token, wrong scope | Verify scope is `https://api.businesscentral.dynamics.com/.default`; refresh token [src3, src6] |
| 403 | Authorization | Insufficient permissions for application user | Assign correct permission sets in BC Entra application card [src3] |
| 408 | Request Timeout | Operation exceeded 8-minute timeout | Split into smaller requests; optimize query filters [src1] |
| 409 | Request_EntityChanged | Another user modified the record (ETag mismatch) | Re-read entity, merge changes, retry with new ETag [src6] |
| 413 | Request Entity Too Large | Request body > 350 MB or page size > 20,000 | Reduce payload size; use pagination [src1] |
| 429 | Too Many Requests | Rate limit or connection limit exceeded | Implement exponential backoff; respect Retry-After header; distribute across users [src1, src2] |
| 503 | Service Temporarily Unavailable | Request queue full (>95 queued requests) | Back off and retry; redistribute load across users [src1] |
| 504 | Gateway Timeout | Request exceeded 10-minute execution time | Refactor into smaller queries; add $filter to reduce data [src2] |

[src1, src2, src6]

### Failure Points in Production

- **Single service principal bottleneck**: All API calls go through one user, hitting the 5-concurrent/6,000-per-5-min limit. Fix: `Create multiple Entra ID app registrations and BC application users. Distribute requests round-robin across them.` [src1]
- **Webhook subscriptions silently expire**: No notification when a subscription expires after 3 days. Integration stops receiving events with no error. Fix: `Run a scheduled job every 2 days to PATCH all subscriptions. Log subscription expiry times and alert if renewal fails.` [src4, src8]
- **ETag stale on concurrent updates**: Two integrations try to update the same record — second one gets 409 conflict. Fix: `Always re-fetch the entity immediately before PATCH. Implement optimistic concurrency: retry with fresh ETag on 409.` [src6]
- **$batch partial failures are silent**: A $batch request returns 200 even if individual operations inside fail. Fix: `Parse each sub-response in the $batch response body. Check individual HTTP status codes. Log and retry failed operations.` [src2]
- **Webhook validation handshake fails**: Subscription creation returns error because the notificationUrl doesn't return the validationToken within the timeout. Fix: `Ensure your webhook endpoint responds within 5 seconds with 200 OK and the validationToken in the response body (plain text).` [src4]
- **Token not refreshed in long-running batch**: Access token expires (~60 min) during a multi-hour data sync. Fix: `Use MSAL's token cache — acquire_token_for_client() returns cached token or automatically refreshes. Call it before each batch of requests.` [src3]

## Anti-Patterns

### Wrong: Polling all records to detect changes

```python
# BAD — fetches ALL customers every sync cycle, wastes API calls and rate limit budget
all_customers = get_all_pages(f"{company_url}/customers", headers)
for customer in all_customers:
    if customer_changed_since_last_sync(customer):
        process(customer)
```

### Correct: Use $filter on lastModifiedDateTime or webhooks

```python
# GOOD — only fetch records modified since last sync
last_sync = "2026-03-01T00:00:00Z"
url = f"{company_url}/customers?$filter=lastModifiedDateTime gt {last_sync}&$select=id,displayName,lastModifiedDateTime"
changed_customers = get_all_pages(url, headers)

# EVEN BETTER — use webhooks for push notifications instead of polling
```

### Wrong: One API call per record in a loop

```python
# BAD — creates 1,000 items one at a time, uses 1,000 API calls from rate limit
for item in items_to_create:
    requests.post(f"{company_url}/items", headers=headers, json=item)
```

### Correct: Use $batch to group operations

```python
# GOOD — batch up to 100 operations per request, reducing API call count by 100x
import json

def create_batch(items, company_url, headers, batch_size=100):
    for i in range(0, len(items), batch_size):
        chunk = items[i:i + batch_size]
        batch_body = {
            "requests": [
                {
                    "method": "POST",
                    "url": f"companies({company_id})/items",
                    "headers": {"Content-Type": "application/json"},
                    "body": item
                }
                for item in chunk
            ]
        }
        resp = requests.post(
            f"{company_url}/$batch",
            headers={**headers, "Content-Type": "application/json"},
            json=batch_body
        )
        # Check individual responses in batch
        for sub_resp in resp.json().get("responses", []):
            if sub_resp["status"] >= 400:
                print(f"Failed: {sub_resp['body']}")
```

### Wrong: Ignoring ETag concurrency control

```python
# BAD — updates without If-Match header; may overwrite concurrent changes silently
requests.patch(
    f"{company_url}/customers({customer_id})",
    headers=headers,
    json={"phoneNumber": "+1-555-0100"}
)
```

### Correct: Always include If-Match with current ETag

```python
# GOOD — fetch current ETag, include in If-Match header
customer = requests.get(f"{company_url}/customers({customer_id})", headers=headers).json()
etag = customer["@odata.etag"]

response = requests.patch(
    f"{company_url}/customers({customer_id})",
    headers={**headers, "If-Match": etag},
    json={"phoneNumber": "+1-555-0100"}
)
if response.status_code == 409:
    # Another user changed the record — re-fetch and retry
    print("Conflict detected, re-fetching and retrying...")
```

## Common Pitfalls

- **Testing in sandbox, deploying to production with different rate limits**: Sandbox allows 300 req/min vs production's 600 req/min. But the per-user 5-minute window (6,000) is the same. Integration that works in sandbox may behave differently under production load. Fix: `Test with realistic data volumes. Use the production rate limit (600/min) as your baseline, but plan for the 5-minute sliding window as the true constraint.` [src1]
- **Not specifying company in URL**: Business Central is multi-company. Omitting the company scope returns `Internal_CompanyNotFound`. Fix: `Always include companies({companyId}) in the URL path. Fetch the company ID first via GET /api/v2.0/companies.` [src6]
- **Using $top without $orderby**: OData does not guarantee consistent ordering without $orderby. Paginated results may skip or duplicate records. Fix: `Always combine $top with $orderby (e.g., $orderby=lastModifiedDateTime asc) for deterministic pagination.` [src2]
- **Not handling the webhook validation handshake**: POST to /subscriptions triggers a validation callback. If your endpoint doesn't return the validationToken, the subscription is never created — with a cryptic error. Fix: `Implement a handler that checks for validationToken query parameter on your notificationUrl. Return it as plain text with 200 OK.` [src4]
- **Assuming SUPER permission works for app users**: Business Central explicitly prevents assigning SUPER to application users. Trying results in an error. Fix: `Use D365 AUTOMATION or create custom permission sets with exactly the tables and pages your integration needs.` [src3]
- **Not parsing $batch sub-responses**: A $batch request returns HTTP 200 even when individual operations fail. Developers assume success and miss errors. Fix: `Iterate through the responses array in the $batch response body and check each sub-response status code individually.` [src2]

## Diagnostic Commands

```bash
# Acquire token for Business Central
TOKEN=$(curl -s -X POST \
  "https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token" \
  -d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_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/YOUR_TENANT_ID/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/YOUR_TENANT_ID/production/api/v2.0/\$metadata" \
  -H "Authorization: Bearer $TOKEN" | head -100

# List webhook-supported resources
curl -s "https://api.businesscentral.dynamics.com/v2.0/YOUR_TENANT_ID/production/api/microsoft/runtime/beta/companies(COMPANY_ID)/webhookSupportedResources?\$filter=resource eq 'v2.0*'" \
  -H "Authorization: Bearer $TOKEN" | jq '.value[].resource'

# Check existing webhook subscriptions
curl -s "https://api.businesscentral.dynamics.com/v2.0/YOUR_TENANT_ID/production/api/v2.0/subscriptions" \
  -H "Authorization: Bearer $TOKEN" | jq '.value[] | {subscriptionId, resource, expirationDateTime}'

# Verify Entra ID app registration (Azure CLI)
az ad app show --id YOUR_CLIENT_ID --query "{name:displayName,appId:appId}" -o table

# Check app credential expiry dates
az ad app credential list --id YOUR_CLIENT_ID -o table
```

## Version History & Compatibility

| API Version | Availability | Status | Breaking Changes | Migration Notes |
|---|---|---|---|---|
| API v2.0 | BC version 18.3 (2021 wave 2) | Current / GA | Introduced entity-level changes from v1.0 | Recommended for all new integrations [src5] |
| API v1.0 | BC version 14 (2019) | Deprecated | N/A | Transition guide available; v2.0 has different entity names and relationships [src5] |
| SOAP | BC version 1+ | Deprecated (warning) | Throughput will be reduced | Migrate to OData v4 / API v2.0 immediately [src1] |
| Automation API v2.0 | BC version 22 | Current / GA | v1.0 deprecated | Use for tenant/extension management [src3] |

[src1, src5]

### Deprecation Policy

Microsoft follows a release-based deprecation model for Business Central. API versions are deprecated with at least 12 months notice. SOAP endpoints have been flagged with a deprecation warning since 2023 and will have reduced throughput. API v1.0 is functionally deprecated — use v2.0 for all new development. Custom API pages created by extensions follow the extension's own versioning. [src1, src5]

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| Real-time CRUD on standard BC entities (customers, items, orders) | High-volume data migration (>100K records per run) | iPaaS with managed queuing (MuleSoft, Boomi) or RapidStart Services |
| Webhook-driven event sync on entity changes | Need Dataverse/CRM data (Sales, Service, Marketing) | Dynamics 365 Dataverse Web API (different product) |
| Moderate-volume scheduled sync (<6K operations per 5 min per user) | Need custom fields on standard entities | Custom API pages (AL extension required) |
| Unattended server-to-server integrations | Interactive user-facing app with MFA | Authorization Code + PKCE flow |
| Querying financial data (GL entries, accounts) | Reporting across large datasets | Use OData connected services in Excel or Power BI instead |

## Important Caveats

- Rate limits are per-user and strictly enforced since late 2023. Legacy documentation showing per-environment limits is outdated. The current limits are 6,000 OData requests per user per 5-minute sliding window, with 5 concurrent processing slots per user. [src1]
- Business Central API v2.0 cannot be extended with additional fields on standard entities. If you need custom fields exposed, you must create a custom API page in an AL extension. This is a fundamental architectural difference from Dataverse/D365 CRM. [src5]
- Webhook subscriptions expire after 3 days with no automatic renewal and no expiry notification. Build renewal into your integration from day one. [src4]
- SOAP endpoints are on a deprecation path. Microsoft has warned that SOAP throughput will be reduced. All new integrations must use OData v4 / REST API v2.0. [src1]
- Sandbox environments have lower rate limits (300 req/min) than production (600 req/min). Always validate performance against production limits before go-live. [src1]
- Microsoft reserves the right to introduce daily entitlement quotas in the future. Current limits are operational (speed/concurrency) only, not volume-based. Plan for potential future changes. [src1]

## Related Units

- [Dynamics 365 Authentication: Azure AD/Entra ID OAuth 2.0, S2S, and App Registration](/business/erp-integration/dynamics-365-authentication/2026)
- [Dynamics 365 Dataverse Web API capabilities and rate limits](/business/erp-integration/dynamics-365-rest-api/2026)
