Business Central AL Extensions: Customization, Event Architecture, and Custom API Development

Type: ERP Integration System: Microsoft Dynamics 365 Business Central (BC27 / AL Runtime 15.x) Confidence: 0.87 Sources: 8 Verified: 2026-03-03 Freshness: 2026-03-03

TL;DR

System Profile

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]

PropertyValue
VendorMicrosoft
SystemDynamics 365 Business Central (BC27 — 2025 Release Wave 2)
API SurfaceAL Extensions (OData v4 / Custom API Pages / Standard API v2.0)
Current Runtime15.x (BC27)
Editions CoveredEssentials, Premium (Premium adds Manufacturing, Service Management)
DeploymentCloud (SaaS) — On-premises also supports AL extensions
API DocsAL Developer Documentation
StatusGA

API Surfaces & Capabilities

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 SurfaceProtocolBest ForMax Records/RequestRate LimitReal-time?Bulk?
Standard API v2.0OData v4 / RESTPre-built CRUD on standard entitiesConfigurable via $top (default 100)600/min Prod, 300/min SandboxYesPartial (paged)
Custom API Pages (AL)OData v4 / RESTCustom entities and extended fieldsSame as standard APIShared with standard APIYesPartial (paged)
SOAP Web ServicesSOAP/XMLLegacy integrationsN/AShared (combined limit)YesNo
OData v4 (Published Pages)OData v4Ad-hoc queries on published pages/queriesConfigurableSharedYesRead-oriented
Webhooks (Subscriptions)HTTPS callbacksReal-time change notificationsN/AMax 3 subscriptions per entity per userYesN/A
Power Platform ConnectorsREST via connectorPower Automate / Power AppsN/AConnector-specificYesN/A

Rate Limits & Quotas

Per-Request Limits

Limit TypeValueApplies ToNotes
Max execution time per request10 minutesAll API callsReturns HTTP 504 Gateway Timeout if exceeded [src3]
Max records per OData response pageDefault 100, configurable via $topAPI v2.0 + Custom API pagesUse @odata.nextLink for pagination
Max simultaneous OData/SOAP requestsEnforced per environmentAll connectionsHTTP 429 returned when exceeded
Max request body size~20 MBAPI write operationsVaries by entity; large payloads may need chunking
Max page fields in API pageRecommended <100 fieldsCustom API pagesPerformance degrades with very wide API pages [src2]

Rolling / Daily Limits

Limit TypeValueWindowEdition Differences
API requests (Production)600Per minuteShared across OData + SOAP; same for Essentials and Premium [src3]
API requests (Sandbox)300Per minuteLower limit to promote testing best practices [src3]
Webhook subscriptions3 per entity per userPersistentPer-entity limit; does not pool across entities
Published extensions per environment75Per environmentPer-tenant extensions only; AppSource apps do not count
Background sessions (Job Queue)Based on environment resourcesConcurrentShared with scheduled tasks and automation

AL Extension Operational Limits

Limit TypeValueNotes
Max AL objects per extensionNo hard limitRecommended: keep extensions focused; split large solutions into multiple apps
Max table fields per table extensionNo hard limitEach field must have DataClassification property set [src7]
Max event subscribers per extensionNo hard limitPerformance scales with subscriber count; keep subscribers lightweight
Extension install/upgrade timeout30 minutesSchema sync + upgrade codeunit execution
Compile time for large extensionsEnvironment-dependentCloud compilation limits apply

Authentication

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]

FlowUse WhenToken LifetimeRefresh?Notes
OAuth 2.0 Client Credentials (S2S)Server-to-server, no user contextConfigurable (default ~1h)New token per request cycleRegister in Entra ID + configure in BC [src5]
OAuth 2.0 Authorization CodeUser-context apps, delegated permissionsAccess: ~1h, Refresh: slidingYesRequires redirect URI; user authenticates via Entra ID
Basic Auth (Web Service Access Key)Legacy — being deprecatedSession-basedNoNot recommended; phased out in favor of OAuth

Authentication Gotchas

Constraints

Integration Pattern Decision Tree

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

Quick Reference

OperationMethodEndpoint PatternPayloadNotes
Query custom APIGET/api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}N/AOData v4 query params: $filter, $top, $skip, $select, $expand [src2]
Get single recordGET/api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}({recordId})N/ArecordId is SystemId (GUID)
Create recordPOST/api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}JSONStandard JSON — no value wrapper needed
Update recordPATCH/api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}({recordId})JSONRequires If-Match header with ETag
Delete recordDELETE/api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}({recordId})N/ARequires If-Match header
Query standard API v2.0GET/api/v2.0/companies({id})/{entity}N/APre-built standard entities
Subscribe to webhooksPOST/api/v2.0/subscriptionsJSONnotificationUrl, resource, clientState
Publish extensionN/AVS Code: Ctrl+F5.app packageRequires sandbox or development environment
Install extension via APIPOST/api/microsoft/automation/v2.0/companies({id})/extensionsJSONAutomation API for CI/CD

Step-by-Step Integration Guide

1. Set up AL development environment

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.

2. Create a custom table and table extension

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

3. Create a custom API page

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.

4. Implement event subscribers

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.

5. Register S2S authentication

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.

6. Implement error handling and retry logic

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.

Code Examples

AL: Custom API page with bound action

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;
}

PowerShell: Authenticate and query custom API

# 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

cURL: Quick API connectivity test

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

Data Mapping

Field Mapping Reference

AL Field TypeAPI JSON TypeOData TypeTransformGotcha
Code[N]stringEdm.StringDirect; trimmed to N charsCase-sensitive matching in filters
Text[N]stringEdm.StringDirectMax N characters; longer values cause validation error
DecimalnumberEdm.DecimalDirectPrecision depends on DecimalPlaces property in AL
IntegernumberEdm.Int32DirectOverflow at 2,147,483,647
BooleanbooleanEdm.BooleanDirecttrue/false, not 0/1
DateTimestring (ISO 8601)Edm.DateTimeOffsetUTC with offsetBC stores in UTC; returned with timezone offset
DatestringEdm.DateYYYY-MM-DDNo time component
EnumstringEdm.StringDisplay name (not ordinal)API uses caption value, not integer ordinal [src2]
GUID (SystemId)stringEdm.GuidLowercase with hyphensAuto-generated; used as primary key via ODataKeyFields
Option (legacy)stringEdm.StringCaption valueDeprecated in favor of Enum

Data Type Gotchas

Error Handling & Failure Points

Common Error Codes

CodeMeaningCauseResolution
400Bad RequestRequired field missing, wrong data typeParse error.message for field-level details [src3]
401UnauthorizedExpired token, wrong audience, or app not configured in BCRe-authenticate; verify Microsoft Entra Applications page [src5]
403ForbiddenS2S app lacks required permission setsAdd appropriate permission sets in BC [src5]
404Not FoundWrong API publisher/group/version or record missingVerify endpoint URL; check extension is published [src2]
409ConflictConcurrent modificationRe-fetch record, get new ETag, retry
412Precondition FailedETag mismatchFetch current ETag with GET, then retry PATCH/DELETE [src2]
429Too Many RequestsExceeded 600/min (Prod) or 300/min (Sandbox)Exponential backoff; check Retry-After header [src3]
504Gateway TimeoutRequest exceeds 10-minute limitOptimize query; reduce $expand depth; split operations [src3]

Failure Points in Production

Anti-Patterns

Wrong: Directly modifying base application tables

// 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]) { }
    }
}

Correct: Use table extensions to add fields

// 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';
        }
    }
}

Wrong: Trying to extend a standard API page

// BAD — API pages cannot be extended; causes compilation error
pageextension 50100 "Customer API Ext" extends "Customer API"
{
    // NOT ALLOWED by design
}

Correct: Create a new custom API page for extended data

// 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") { }
            }
        }
    }
}

Wrong: Using ToBeClassified DataClassification

// BAD — blocks AppSource validation and is a compliance risk
field(50100; "Customer Tax ID"; Text[20])
{
    DataClassification = ToBeClassified;
}

Correct: Always set proper DataClassification

// GOOD — explicit classification for GDPR/compliance
field(50100; "Customer Tax ID"; Text[20])
{
    DataClassification = CustomerContent;
    Caption = 'Customer Tax ID';
}

Common Pitfalls

Diagnostic Commands

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

Version History & Compatibility

BC VersionReleaseAL RuntimeStatusKey ChangesMigration Notes
BC282026 Wave 116.xPreviewNuGet symbol downloads without environmentTest against preview before release [src6]
BC272025 Wave 215.xCurrentNew default runtime; EntityCaption/EntitySetCaption required on API pagesUpdate app.json runtime; add new required properties
BC262025 Wave 114.xSupportedVS Code actions from Web Client; improved symbolsMinimum recommended for new development
BC252024 Wave 213.xSupportedEnhanced isolated events; HttpClient improvementsStill widely deployed
BC242024 Wave 112.xSupportedInterface improvements; partial recordsConsider upgrading for performance
BC212022 Wave 210.xEOLEnum extensibility matured; API v2.0 standardUpgrade required — out of support

Deprecation Policy

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]

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
You need custom business logic in standard BC processesYou only need to read/write standard entitiesStandard API v2.0 (no extension needed)
You need to expose custom tables or extended fields as API endpointsYou need simple automations without codePower Automate with BC connector
You want to publish a reusable app to AppSourceYou need to modify base application behavior directlyNot possible in SaaS — redesign using events
Event-driven hooks into posting, validation, or approvalsBulk data migration of millions of recordsConfiguration Packages / RapidStart Services
Call external APIs from within BCReal-time BI dashboards from BC dataOData published pages + Power BI

Important Caveats

Related Units