Microsoft Dynamics 365 Business Central is a cloud-first ERP for small and mid-sized businesses. The AL (Application Language) extension model replaced the legacy C/AL development model starting with Business Central v14 (2019). All customizations in Business Central SaaS must be delivered as AL extensions — direct code modification is not possible. Extensions follow an additive-only model: you can add new objects (tables, pages, codeunits, queries, reports, API pages) and extend existing objects (table extensions, page extensions, enum extensions), but you cannot delete or directly modify base application code. The event architecture (Business Events, Integration Events, Trigger Events) provides hooks into standard logic. Custom API pages let you expose extension data as OData v4 endpoints that conform to Business Central's standard REST API pattern. [src1, src2]
| Property | Value |
|---|---|
| Vendor | Microsoft |
| System | Dynamics 365 Business Central (BC27 — 2025 Release Wave 2) |
| API Surface | AL Extensions (OData v4 / Custom API Pages / Standard API v2.0) |
| Current Runtime | 15.x (BC27) |
| Editions Covered | Essentials, Premium (Premium adds Manufacturing, Service Management) |
| Deployment | Cloud (SaaS) — On-premises also supports AL extensions |
| API Docs | AL Developer Documentation |
| Status | GA |
Business Central exposes multiple API surfaces. AL extensions can create custom API pages that appear alongside the standard API v2.0 endpoints. [src1, src2, src3]
| API Surface | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| Standard API v2.0 | OData v4 / REST | Pre-built CRUD on standard entities | Configurable via $top (default 100) | 600/min Prod, 300/min Sandbox | Yes | Partial (paged) |
| Custom API Pages (AL) | OData v4 / REST | Custom entities and extended fields | Same as standard API | Shared with standard API | Yes | Partial (paged) |
| SOAP Web Services | SOAP/XML | Legacy integrations | N/A | Shared (combined limit) | Yes | No |
| OData v4 (Published Pages) | OData v4 | Ad-hoc queries on published pages/queries | Configurable | Shared | Yes | Read-oriented |
| Webhooks (Subscriptions) | HTTPS callbacks | Real-time change notifications | N/A | Max 3 subscriptions per entity per user | Yes | N/A |
| Power Platform Connectors | REST via connector | Power Automate / Power Apps | N/A | Connector-specific | Yes | N/A |
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max execution time per request | 10 minutes | All API calls | Returns HTTP 504 Gateway Timeout if exceeded [src3] |
| Max records per OData response page | Default 100, configurable via $top | API v2.0 + Custom API pages | Use @odata.nextLink for pagination |
| Max simultaneous OData/SOAP requests | Enforced per environment | All connections | HTTP 429 returned when exceeded |
| Max request body size | ~20 MB | API write operations | Varies by entity; large payloads may need chunking |
| Max page fields in API page | Recommended <100 fields | Custom API pages | Performance degrades with very wide API pages [src2] |
| Limit Type | Value | Window | Edition Differences |
|---|---|---|---|
| API requests (Production) | 600 | Per minute | Shared across OData + SOAP; same for Essentials and Premium [src3] |
| API requests (Sandbox) | 300 | Per minute | Lower limit to promote testing best practices [src3] |
| Webhook subscriptions | 3 per entity per user | Persistent | Per-entity limit; does not pool across entities |
| Published extensions per environment | 75 | Per environment | Per-tenant extensions only; AppSource apps do not count |
| Background sessions (Job Queue) | Based on environment resources | Concurrent | Shared with scheduled tasks and automation |
| Limit Type | Value | Notes |
|---|---|---|
| Max AL objects per extension | No hard limit | Recommended: keep extensions focused; split large solutions into multiple apps |
| Max table fields per table extension | No hard limit | Each field must have DataClassification property set [src7] |
| Max event subscribers per extension | No hard limit | Performance scales with subscriber count; keep subscribers lightweight |
| Extension install/upgrade timeout | 30 minutes | Schema sync + upgrade codeunit execution |
| Compile time for large extensions | Environment-dependent | Cloud compilation limits apply |
Business Central SaaS uses Microsoft Entra ID (formerly Azure AD) for all authentication. The choice of flow depends on whether the integration needs user context. [src5]
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 Client Credentials (S2S) | Server-to-server, no user context | Configurable (default ~1h) | New token per request cycle | Register in Entra ID + configure in BC [src5] |
| OAuth 2.0 Authorization Code | User-context apps, delegated permissions | Access: ~1h, Refresh: sliding | Yes | Requires redirect URI; user authenticates via Entra ID |
| Basic Auth (Web Service Access Key) | Legacy — being deprecated | Session-based | No | Not recommended; phased out in favor of OAuth |
https://api.businesscentral.dynamics.com (SaaS) or the on-premises URL. Mismatched audience causes hard-to-debug auth failures. [src5]START — User needs to integrate with or customize Business Central
|-- What's the goal?
| |-- Expose custom data as API endpoint
| | |-- Custom table? --> Create table + API page (PageType = API) [src2]
| | |-- Extend standard entity? --> Table extension + new custom API page [src2]
| | |-- Complex business logic? --> API page + codeunit with business logic
| |-- Hook into standard business process
| | |-- Before/after standard operation? --> Event subscriber (Integration/Business Event) [src4]
| | |-- Validate data before insert/modify? --> OnBeforeInsertEvent / OnBeforeModifyEvent [src4]
| | |-- React to data changes externally? --> Webhook subscription on entity
| |-- Consume external API from BC
| | |-- Synchronous call needed? --> HttpClient in AL codeunit [src1]
| | |-- Async/scheduled? --> Job Queue + HttpClient [src1]
| |-- Bulk data integration
| | |-- < 1,000 records? --> Standard API v2.0 with pagination
| | |-- > 1,000 records? --> Custom API page with optimized queries + batch processing
| | |-- > 100,000 records? --> Configuration Packages or RapidStart
|-- Which direction?
| |-- Inbound (external writes to BC) --> Custom API page or standard API v2.0
| |-- Outbound (BC pushes to external) --> Event subscriber + HttpClient callout
| |-- Bidirectional --> Design conflict resolution; use SystemModifiedAt for delta sync
|-- Error tolerance?
|-- Zero-loss --> Idempotency (check before insert) + error logging table
|-- Best-effort --> Try/catch with Notification or Error logging
| Operation | Method | Endpoint Pattern | Payload | Notes |
|---|---|---|---|---|
| Query custom API | GET | /api/{publisher}/{group}/v2.0/companies({id})/{entitySetName} | N/A | OData v4 query params: $filter, $top, $skip, $select, $expand [src2] |
| Get single record | GET | /api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}({recordId}) | N/A | recordId is SystemId (GUID) |
| Create record | POST | /api/{publisher}/{group}/v2.0/companies({id})/{entitySetName} | JSON | Standard JSON — no value wrapper needed |
| Update record | PATCH | /api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}({recordId}) | JSON | Requires If-Match header with ETag |
| Delete record | DELETE | /api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}({recordId}) | N/A | Requires If-Match header |
| Query standard API v2.0 | GET | /api/v2.0/companies({id})/{entity} | N/A | Pre-built standard entities |
| Subscribe to webhooks | POST | /api/v2.0/subscriptions | JSON | notificationUrl, resource, clientState |
| Publish extension | N/A | VS Code: Ctrl+F5 | .app package | Requires sandbox or development environment |
| Install extension via API | POST | /api/microsoft/automation/v2.0/companies({id})/extensions | JSON | Automation API for CI/CD |
Install Visual Studio Code with the AL Language extension. Create a new AL project using the AL: Go! command. Configure launch.json and app.json with correct runtime version and dependencies. [src1]
// app.json — key properties
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "My Custom Integration",
"publisher": "MyCompany",
"version": "1.0.0.0",
"runtime": "15.0",
"target": "Cloud",
"application": "25.0.0.0",
"platform": "25.0.0.0",
"dependencies": [],
"features": ["TranslationFile", "GenerateCaptions"]
}
Verify: AL: Download Symbols (Ctrl+Shift+P) completes successfully.
Define custom tables or extend existing tables with new fields using table extensions. [src1]
// Custom table for integration data
table 50100 "My Integration Data"
{
DataClassification = CustomerContent;
Caption = 'My Integration Data';
fields
{
field(1; "Entry No."; Integer)
{
DataClassification = SystemMetadata;
AutoIncrement = true;
}
field(2; "External ID"; Code[50])
{
DataClassification = CustomerContent;
}
field(3; "Description"; Text[250])
{
DataClassification = CustomerContent;
}
field(4; "Amount"; Decimal)
{
DataClassification = CustomerContent;
}
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
key(ExternalID; "External ID") { }
}
}
Verify: Extension compiles without errors (Ctrl+Shift+B).
Define an API page to expose your table as an OData v4 / REST endpoint. Set the required API properties. [src2]
page 50100 "My Integration Data API"
{
PageType = API;
APIPublisher = 'mycompany';
APIGroup = 'integration';
APIVersion = 'v2.0';
EntityName = 'integrationData';
EntitySetName = 'integrationData';
EntityCaption = 'Integration Data';
EntitySetCaption = 'Integration Data';
SourceTable = "My Integration Data";
ODataKeyFields = SystemId;
DelayedInsert = true;
Extensible = false;
layout
{
area(Content)
{
repeater(Group)
{
field(id; Rec.SystemId) { Editable = false; }
field(externalId; Rec."External ID") { }
field(description; Rec.Description) { }
field(amount; Rec.Amount) { }
}
}
}
}
Verify: After publishing, call GET /api/mycompany/integration/v2.0/companies({companyId})/integrationData to confirm the endpoint returns data.
Subscribe to Integration Events or Business Events to hook into standard processes. [src4]
codeunit 50100 "My Event Subscribers"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post",
OnAfterPostSalesDocument, '', false, false)]
local procedure OnAfterPostSalesDocument(
var SalesHeader: Record "Sales Header";
var GenJnlPostLine: Record "Gen. Journal Line";
SalesShptHdrNo: Code[20];
RetRcpHdrNo: Code[20];
SalesInvHdrNo: Code[20];
SalesCrMemoHdrNo: Code[20])
var
IntegrationData: Record "My Integration Data";
begin
IntegrationData.Init();
IntegrationData."External ID" := SalesInvHdrNo;
IntegrationData.Description := 'Posted Sales Invoice';
IntegrationData.Amount := SalesHeader."Amount Including VAT";
IntegrationData.Insert(true);
end;
}
Verify: Post a sales invoice, then query the custom API endpoint to confirm the record was created.
Register a Microsoft Entra ID application, configure it in Business Central, and obtain OAuth tokens. [src5]
# Obtain access token via Client Credentials flow
curl -X POST "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id={client_id}" \
-d "client_secret={client_secret}" \
-d "scope=https://api.businesscentral.dynamics.com/.default" \
-d "grant_type=client_credentials"
# Call custom API endpoint
curl -X GET "https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/mycompany/integration/v2.0/companies({company_id})/integrationData" \
-H "Authorization: Bearer {access_token}" \
-H "Accept: application/json"
Verify: The API returns a JSON array of records with HTTP 200.
Handle HTTP 429 (rate limit) and 504 (timeout) with exponential backoff. [src3]
import requests
import time
def bc_api_request(method, url, token, data=None, max_retries=5):
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json"
}
if method in ("PATCH", "DELETE"):
get_resp = requests.get(url, headers=headers)
if get_resp.ok:
headers["If-Match"] = get_resp.headers.get("ETag", "*")
for attempt in range(max_retries):
response = requests.request(method, url, headers=headers, json=data)
if response.status_code in (200, 201, 204):
return response.json() if response.content else None
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(retry_after)
continue
if response.status_code == 504:
time.sleep(min(2 ** attempt * 5, 120))
continue
try:
msg = response.json().get("error", {}).get("message", response.text)
except Exception:
msg = response.text
raise Exception(f"BC API error {response.status_code}: {msg}")
raise Exception("Max retries exceeded")
Verify: Test with an invalid endpoint to confirm error parsing returns a readable message.
page 50101 "Integration Data Action API"
{
PageType = API;
APIPublisher = 'mycompany';
APIGroup = 'integration';
APIVersion = 'v2.0';
EntityName = 'integrationAction';
EntitySetName = 'integrationActions';
EntityCaption = 'Integration Action';
EntitySetCaption = 'Integration Actions';
SourceTable = "My Integration Data";
ODataKeyFields = SystemId;
Extensible = false;
layout
{
area(Content)
{
repeater(Group)
{
field(id; Rec.SystemId) { Editable = false; }
field(externalId; Rec."External ID") { }
field(status; Rec.Status) { Editable = false; }
}
}
}
[ServiceEnabled]
procedure ProcessRecord(var ActionContext: WebServiceActionContext)
var
IntegrationMgt: Codeunit "My Integration Management";
begin
IntegrationMgt.ProcessIntegrationRecord(Rec);
ActionContext.SetObjectType(ObjectType::Page);
ActionContext.SetObjectId(Page::"Integration Data Action API");
ActionContext.AddEntityKey(Rec.FieldNo(SystemId), Rec.SystemId);
ActionContext.SetResultCode(WebServiceActionResultCode::Updated);
end;
}
# Install: Install-Module MSAL.PS -Scope CurrentUser
$tenantId = "your-tenant-id"
$clientId = "your-client-id"
$clientSecret = "your-client-secret" | ConvertTo-SecureString -AsPlainText -Force
$environment = "production"
$tokenParams = @{
ClientId = $clientId
TenantId = $tenantId
ClientSecret = $clientSecret
Scopes = "https://api.businesscentral.dynamics.com/.default"
}
$token = Get-MsalToken @tokenParams
$headers = @{
Authorization = "Bearer $($token.AccessToken)"
Accept = "application/json"
}
$baseUrl = "https://api.businesscentral.dynamics.com/v2.0/$tenantId/$environment"
$companies = Invoke-RestMethod -Uri "$baseUrl/api/v2.0/companies" -Headers $headers
$companyId = $companies.value[0].id
$data = Invoke-RestMethod `
-Uri "$baseUrl/api/mycompany/integration/v2.0/companies($companyId)/integrationData" `
-Headers $headers
$data.value | Format-Table externalId, description, amount, status
# Get token
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" \
-d "client_id={client_id}&client_secret={client_secret}&scope=https://api.businesscentral.dynamics.com/.default&grant_type=client_credentials" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
# List companies
curl -s "https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/v2.0/companies" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" | python3 -m json.tool
# Query custom API endpoint
curl -s "https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/mycompany/integration/v2.0/companies({company_id})/integrationData?\$top=10" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" | python3 -m json.tool
# Create a record
curl -X POST "https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/mycompany/integration/v2.0/companies({company_id})/integrationData" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"externalId": "EXT-001", "description": "Test record", "amount": 150.00}'
| AL Field Type | API JSON Type | OData Type | Transform | Gotcha |
|---|---|---|---|---|
| Code[N] | string | Edm.String | Direct; trimmed to N chars | Case-sensitive matching in filters |
| Text[N] | string | Edm.String | Direct | Max N characters; longer values cause validation error |
| Decimal | number | Edm.Decimal | Direct | Precision depends on DecimalPlaces property in AL |
| Integer | number | Edm.Int32 | Direct | Overflow at 2,147,483,647 |
| Boolean | boolean | Edm.Boolean | Direct | true/false, not 0/1 |
| DateTime | string (ISO 8601) | Edm.DateTimeOffset | UTC with offset | BC stores in UTC; returned with timezone offset |
| Date | string | Edm.Date | YYYY-MM-DD | No time component |
| Enum | string | Edm.String | Display name (not ordinal) | API uses caption value, not integer ordinal [src2] |
| GUID (SystemId) | string | Edm.Guid | Lowercase with hyphens | Auto-generated; used as primary key via ODataKeyFields |
| Option (legacy) | string | Edm.String | Caption value | Deprecated in favor of Enum |
"Open", not 0) when filtering or setting enum fields. [src2]If-Match header with the record's current ETag. Omitting it returns HTTP 412 Precondition Failed. [src2]| Code | Meaning | Cause | Resolution |
|---|---|---|---|
| 400 | Bad Request | Required field missing, wrong data type | Parse error.message for field-level details [src3] |
| 401 | Unauthorized | Expired token, wrong audience, or app not configured in BC | Re-authenticate; verify Microsoft Entra Applications page [src5] |
| 403 | Forbidden | S2S app lacks required permission sets | Add appropriate permission sets in BC [src5] |
| 404 | Not Found | Wrong API publisher/group/version or record missing | Verify endpoint URL; check extension is published [src2] |
| 409 | Conflict | Concurrent modification | Re-fetch record, get new ETag, retry |
| 412 | Precondition Failed | ETag mismatch | Fetch current ETag with GET, then retry PATCH/DELETE [src2] |
| 429 | Too Many Requests | Exceeded 600/min (Prod) or 300/min (Sandbox) | Exponential backoff; check Retry-After header [src3] |
| 504 | Gateway Timeout | Request exceeds 10-minute limit | Optimize query; reduce $expand depth; split operations [src3] |
Pin runtime version and application dependency. Test against preview environment before each release wave. [src1, src6]Verify extension is installed (not just published). Check APIPublisher/APIGroup naming conflicts. [src2]Throttle to ~500/min with backoff. Use $batch OData requests where supported. [src3]Always GET record first, extract ETag, PATCH with If-Match. On conflict, re-read and retry. [src2]Use a static boolean flag to detect re-entry. Design subscribers to be idempotent. [src4]// BAD — this is impossible in AL extensions and causes compilation errors
table 18 Customer
{
// Cannot redefine base objects
fields
{
field(50100; "My Custom Field"; Text[100]) { }
}
}
// GOOD — extends the base Customer table without modifying it
tableextension 50100 "Customer Extension" extends Customer
{
fields
{
field(50100; "My Custom Field"; Text[100])
{
DataClassification = CustomerContent;
Caption = 'My Custom Field';
}
}
}
// BAD — API pages cannot be extended; causes compilation error
pageextension 50100 "Customer API Ext" extends "Customer API"
{
// NOT ALLOWED by design
}
// GOOD — new API page that includes base + custom fields
page 50102 "Customer Extended API"
{
PageType = API;
APIPublisher = 'mycompany';
APIGroup = 'custom';
APIVersion = 'v2.0';
EntityName = 'customerExtended';
EntitySetName = 'customersExtended';
EntityCaption = 'Customer Extended';
EntitySetCaption = 'Customers Extended';
SourceTable = Customer;
ODataKeyFields = SystemId;
Extensible = false;
layout
{
area(Content)
{
repeater(Group)
{
field(id; Rec.SystemId) { Editable = false; }
field(number; Rec."No.") { Editable = false; }
field(displayName; Rec.Name) { }
field(myCustomField; Rec."My Custom Field") { }
}
}
}
}
// BAD — blocks AppSource validation and is a compliance risk
field(50100; "Customer Tax ID"; Text[20])
{
DataClassification = ToBeClassified;
}
// GOOD — explicit classification for GDPR/compliance
field(50100; "Customer Tax ID"; Text[20])
{
DataClassification = CustomerContent;
Caption = 'Customer Tax ID';
}
Register your prefix/suffix with Microsoft before development. [src7]"runtime": "15.0" means the extension only works on BC27+. Fix: Set runtime to the lowest version supporting the AL features you need. [src6]Always write upgrade codeunits for schema changes. [src1]/companies({companyId})/. Fix: Query /api/v2.0/companies first to get the company ID. [src2]Keep subscribers lightweight. Queue heavy work to Job Queue entries. [src4, src8]Use named procedures with [ServiceEnabled] attribute. [src2]# Check available API endpoints (list companies)
curl -s "https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/v2.0/companies" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
# Test authentication (should return 200)
curl -s -o /dev/null -w "%{http_code}" \
"https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/v2.0/companies" \
-H "Authorization: Bearer $TOKEN"
# Check if custom API page is registered
curl -s "https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/mycompany/integration/v2.0/companies({company_id})/integrationData?\$top=1" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
# Verify extension is installed (Automation API)
curl -s "https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/microsoft/automation/v2.0/companies({company_id})/extensions?\$filter=displayName eq 'My Custom Integration'" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
# Check OData metadata for custom endpoint
curl -s "https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/{environment}/api/mycompany/integration/v2.0/\$metadata" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/xml"
| BC Version | Release | AL Runtime | Status | Key Changes | Migration Notes |
|---|---|---|---|---|---|
| BC28 | 2026 Wave 1 | 16.x | Preview | NuGet symbol downloads without environment | Test against preview before release [src6] |
| BC27 | 2025 Wave 2 | 15.x | Current | New default runtime; EntityCaption/EntitySetCaption required on API pages | Update app.json runtime; add new required properties |
| BC26 | 2025 Wave 1 | 14.x | Supported | VS Code actions from Web Client; improved symbols | Minimum recommended for new development |
| BC25 | 2024 Wave 2 | 13.x | Supported | Enhanced isolated events; HttpClient improvements | Still widely deployed |
| BC24 | 2024 Wave 1 | 12.x | Supported | Interface improvements; partial records | Consider upgrading for performance |
| BC21 | 2022 Wave 2 | 10.x | EOL | Enum extensibility matured; API v2.0 standard | Upgrade required — out of support |
Microsoft follows a "Modern Lifecycle" policy: each release wave is supported until the next two waves are GA (~12 months). API v1.0 is deprecated in favor of v2.0. C/AL is fully deprecated — AL extensions are the only supported path. Microsoft provides at least one release wave notice before removing deprecated features. [src1, src6]
| Use When | Don't Use When | Use Instead |
|---|---|---|
| You need custom business logic in standard BC processes | You only need to read/write standard entities | Standard API v2.0 (no extension needed) |
| You need to expose custom tables or extended fields as API endpoints | You need simple automations without code | Power Automate with BC connector |
| You want to publish a reusable app to AppSource | You need to modify base application behavior directly | Not possible in SaaS — redesign using events |
| Event-driven hooks into posting, validation, or approvals | Bulk data migration of millions of records | Configuration Packages / RapidStart Services |
| Call external APIs from within BC | Real-time BI dashboards from BC data | OData published pages + Power BI |