---
# === IDENTITY ===
id: business/erp-integration/business-central-al-extensions/2026
canonical_question: "How do Business Central AL Extensions work as a customization and integration API?"
aliases:
  - "Business Central AL language extension development and event architecture"
  - "How to build custom APIs in Dynamics 365 Business Central using AL extensions"
  - "Business Central extension model — events, subscribers, versioning, and AppSource publishing"
  - "AL extension API pages, rate limits, and OData endpoints in Business Central"
entity_type: erp_integration
domain: business > erp-integration > business-central-al-extensions
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === SYSTEM PROFILE ===
systems:
  - name: "Microsoft Dynamics 365 Business Central"
    vendor: "Microsoft"
    version: "2025 Release Wave 2 (BC27) / AL Runtime 15.x"
    edition: "Essentials, Premium"
    deployment: "cloud"
    api_surface: "AL Extensions (OData v4 / REST API v2.0)"

# === VERIFICATION ===
last_verified: 2026-03-03
confidence: 0.87
version: 1.0
first_published: 2026-03-03

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: volatile
  last_breaking_change: "BC27 (2025 Wave 2) — default runtime version changed; new API page properties required (EntityCaption, EntitySetCaption)"
  next_review: 2026-08-30
  change_sensitivity: high

# === CONSTRAINTS ===
constraints:
  - "API rate limit: 600 API requests/min in Production, 300/min in Sandbox (combined OData + SOAP) — returns HTTP 429 when exceeded"
  - "Request timeout: 10-minute maximum execution time per request — returns HTTP 504 on timeout"
  - "Cannot extend existing standard API pages — must create new API pages instead; only table extensions can add fields to standard entities"
  - "AL extensions cannot modify base application code — only extend via events, table extensions, page extensions, and new objects"
  - "AppSource apps must pass automated validation including prefix/suffix requirements, DataClassification on all Normal fields, and mandatory test codeunits"
  - "Per-tenant extensions are limited to 75 published extensions per environment in SaaS"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs standard Business Central REST API v2.0 endpoints without custom AL development"
    use_instead: "business/erp-integration/business-central-api-v2/2026"
  - condition: "User needs a different ERP system's extension model"
    use_instead: "business/erp-integration/{system}-extensions/2026"
  - condition: "User needs Business Central on-premises C/AL development (legacy)"
    use_instead: "N/A — C/AL is deprecated; migrate to AL extensions"

# === 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, CDC, platform events)"
      - "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-al-extensions/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-03-03)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: "business/erp-integration/business-central-api-v2/2026"
      label: "Business Central standard REST API v2.0 endpoints and capabilities"
  solves:
    - id: "business/erp-integration/dynamics365-salesforce-integration/2026"
      label: "Dynamics 365 to Salesforce integration playbook"
  alternative_to:
    - id: "business/erp-integration/dynamics365-power-automate/2026"
      label: "Power Automate connectors for Business Central (low-code alternative)"
  often_confused_with:
    - id: "business/erp-integration/business-central-api-v2/2026"
      label: "Standard API v2.0 pages (pre-built by Microsoft, not custom AL)"

# === SOURCES ===
sources:
  - id: src1
    title: "Developing extensions in AL — Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-dev-overview
    type: official_docs
    published: 2025-10-01
    reliability: authoritative
  - id: src2
    title: "Developing a Custom API — Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-develop-custom-api
    type: official_docs
    published: 2025-10-01
    reliability: authoritative
  - id: src3
    title: "Current 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-current-limits
    type: official_docs
    published: 2025-10-01
    reliability: authoritative
  - id: src4
    title: "Events in Microsoft Dynamics 365 Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-events-in-al
    type: official_docs
    published: 2025-10-01
    reliability: authoritative
  - id: src5
    title: "Using Service to Service Authentication — Business Central"
    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-10-01
    reliability: authoritative
  - id: src6
    title: "Choose Runtime Version in AL — Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-choosing-runtime
    type: official_docs
    published: 2025-10-01
    reliability: authoritative
  - id: src7
    title: "Rules and Guidelines for AL Code — Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/compliance/apptest-overview
    type: official_docs
    published: 2025-10-01
    reliability: authoritative
  - id: src8
    title: "AL Guidelines — Business Central Design Patterns"
    author: AL Guidelines Community
    url: https://alguidelines.dev/
    type: community_resource
    published: 2025-06-01
    reliability: moderate_high
---

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

## TL;DR

- **Bottom line**: AL extensions are the only supported customization model for Business Central SaaS. They let you add custom tables, pages, codeunits, reports, and API pages without modifying the base application. Custom API pages expose your data as OData v4 / REST endpoints that external systems can consume.
- **Key limit**: 600 API requests/min in Production, 300/min in Sandbox (combined OData + SOAP). Individual requests time out at 10 minutes (HTTP 504). [src3]
- **Watch out for**: You cannot extend existing standard API pages — you must create entirely new API page objects. Also, the event subscriber model means you must understand the publisher-subscriber pattern to hook into standard business logic without breaking upgrades. [src1, src2]
- **Best for**: Building custom integrations, adding business logic to standard processes via events, exposing custom data through REST/OData endpoints, and publishing reusable apps to AppSource. [src1]
- **Authentication**: Microsoft Entra ID (Azure AD) OAuth 2.0 for SaaS — use Client Credentials flow for service-to-service (S2S) integrations; Authorization Code flow for user-context apps. [src5]

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

| 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 URL** | [AL Developer Documentation](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-dev-overview) |
| **Status** | GA |

## 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 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 (customers, items, sales orders) | Configurable via $top (default 100, max varies) | 600/min Production, 300/min Sandbox | Yes | Partial (paged) |
| Custom API Pages (AL) | OData v4 / REST | Custom entities and business logic exposed as endpoints | Same as standard API | Shared with standard API | Yes | Partial (paged) |
| SOAP Web Services | SOAP/XML | Legacy integrations, published pages/codeunits | N/A | Shared (combined limit) | Yes | No |
| OData v4 (Published Pages) | OData v4 | Ad-hoc queries on published page/query objects | 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 integration | N/A | Connector-specific | Yes | N/A |

[src1, src2, src3]

## Rate Limits & Quotas

### Per-Request Limits

| 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 [src3] |
| Max simultaneous OData/SOAP requests | Enforced per environment | All connections | HTTP 429 returned when exceeded [src3] |
| 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] |

[src3]

### Rolling / Daily Limits

| 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 against this |
| Background sessions (Job Queue) | Based on environment resources | Concurrent | Shared with scheduled tasks and automation |

[src3]

### AL Extension Operational Limits

| 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 (not ToBeClassified for AppSource) [src7] |
| Max event subscribers per extension | No hard limit | Performance impact scales with subscriber count; avoid heavy logic in OnInsert/OnModify triggers |
| Extension install/upgrade timeout | 30 minutes | Schema sync + upgrade codeunit execution; large data migrations can hit this |
| Compile time for large extensions | Environment-dependent | Cloud compilation limits apply; very large extensions may timeout during publish |

[src1, src7]

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

| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 Client Credentials (S2S) | Server-to-server integrations, no user context | Configurable (default ~1h) | New token per request cycle | Register app in Entra ID + configure in BC Microsoft Entra Applications page [src5] |
| OAuth 2.0 Authorization Code | User-context apps, delegated permissions | Access: ~1h, Refresh: sliding window | Yes | Requires redirect URI; user authenticates via Entra ID login [src5] |
| Basic Auth (Web Service Access Key) | Legacy — being deprecated | Session-based | No | Not recommended; being phased out in favor of OAuth [src5] |

[src5]

### Authentication Gotchas

- **S2S apps cannot use SUPER permission set**: When configuring a Microsoft Entra application in Business Central for S2S authentication, you cannot assign the SUPER permission set. Create a custom permission set with exactly the permissions the integration needs. [src5]
- **Client Credentials flow requires explicit BC configuration**: Registering the app in Entra ID is not enough. You must also open the "Microsoft Entra Applications" page in Business Central and add the app's Client ID, then assign permission sets. Without this step, API calls return 401. [src5]
- **Token audience must match**: The OAuth token must be requested with the audience/resource set to `https://api.businesscentral.dynamics.com` (SaaS) or the on-premises server URL. A mismatched audience causes authentication failures that are hard to debug. [src5]
- **Multi-environment setups**: Each Business Central environment (production, sandbox) requires separate Entra app configuration. Tokens obtained for one environment do not work on another. [src5]

## Constraints

- **Extensions cannot modify base code** — all customization must be additive: new objects, object extensions, or event subscribers. You cannot delete fields, remove validation logic, or change base table structures. [src1]
- **Cannot extend standard API pages** — Microsoft explicitly blocks extending API page objects. To add custom fields to a standard entity's API response, create a new custom API page that sources from the base table plus your table extension. [src2]
- **Breaking schema changes require upgrade codeunits** — once an extension is published and synchronized, all subsequent versions must have backward-compatible schemas. Removing fields, changing field types, or renaming tables requires writing upgrade codeunit logic. [src1, src7]
- **AppSource mandatory requirements** — extensions published to AppSource must pass automated validation: reserved prefix/suffix on all objects, DataClassification on every Normal field (not ToBeClassified), mandatory test codeunits with adequate coverage, and alignment with the latest BC release cadence. [src7]
- **Per-tenant extension limit** — maximum 75 per-tenant extensions per environment in SaaS. AppSource-published apps are separate and do not count against this limit. [src1]
- **Event subscriber execution order is not guaranteed** — when multiple extensions subscribe to the same event, the execution order is undefined. Do not rely on one subscriber running before another. [src4]

## 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? --> Consider 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 --> Implement idempotency (check before insert) + error logging table
    |-- Best-effort --> Try/catch with Notification or Error logging
```

## Quick Reference

| 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) [src2] |
| Create record | POST | `/api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}` | JSON | Standard JSON — no value wrapper needed (unlike Acumatica) [src2] |
| Update record | PATCH | `/api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}({recordId})` | JSON | Requires If-Match header with ETag for optimistic concurrency [src2] |
| Delete record | DELETE | `/api/{publisher}/{group}/v2.0/companies({id})/{entitySetName}({recordId})` | N/A | Requires If-Match header [src2] |
| Query standard API v2.0 | GET | `/api/v2.0/companies({id})/{entity}` | N/A | Microsoft's pre-built standard entities [src3] |
| Subscribe to webhooks | POST | `/api/v2.0/subscriptions` | JSON | notificationUrl, resource, clientState [src1] |
| Publish extension | N/A | VS Code: Ctrl+F5 (publish without debug) | .app package | Requires development environment or sandbox [src1] |
| Install extension via API | POST | `/api/microsoft/automation/v2.0/companies({id})/extensions` | JSON | Automation API for CI/CD pipelines [src1] |

[src1, src2, src3]

## 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 the `launch.json` to target your Business Central sandbox and `app.json` with correct runtime version, dependencies, and publisher info. [src1]

```json
// 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, downloading the base app symbols.

### 2. Create a custom table and table extension

Define a custom table for your integration data, or extend an existing table with new fields using a table extension. [src1]

```al
// 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;
            Caption = 'External ID';
        }
        field(3; "Description"; Text[250])
        {
            DataClassification = CustomerContent;
            Caption = 'Description';
        }
        field(4; "Amount"; Decimal)
        {
            DataClassification = CustomerContent;
            Caption = 'Amount';
        }
        field(5; "Status"; Enum "Integration Status")
        {
            DataClassification = CustomerContent;
            Caption = 'Status';
        }
    }

    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: APIPublisher, APIGroup, APIVersion, EntityName, EntitySetName. [src2]

```al
// Custom API page — exposes "My Integration Data" as REST endpoint
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)
                {
                    Caption = 'Id';
                    Editable = false;
                }
                field(entryNo; Rec."Entry No.")
                {
                    Caption = 'Entry No.';
                    Editable = false;
                }
                field(externalId; Rec."External ID")
                {
                    Caption = 'External ID';
                }
                field(description; Rec.Description)
                {
                    Caption = 'Description';
                }
                field(amount; Rec.Amount)
                {
                    Caption = 'Amount';
                }
                field(status; Rec.Status)
                {
                    Caption = 'Status';
                }
            }
        }
    }
}
```

**Verify**: After publishing, call `GET /api/mycompany/integration/v2.0/companies({companyId})/integrationData` and confirm the endpoint returns data. [src2]

### 4. Implement event subscribers

Subscribe to Business Events or Integration Events to hook into standard processes. Use the `[EventSubscriber]` attribute to declare subscriber methods. [src4]

```al
codeunit 50100 "My Event Subscribers"
{
    // Subscribe to the OnAfterPostSalesDocument event
    [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
        // Log the posted document for external system sync
        IntegrationData.Init();
        IntegrationData."External ID" := SalesInvHdrNo;
        IntegrationData.Description := 'Posted Sales Invoice';
        IntegrationData.Amount := SalesHeader."Amount Including VAT";
        IntegrationData.Status := IntegrationData.Status::Pending;
        IntegrationData.Insert(true);
    end;
}
```

**Verify**: Post a sales invoice in BC, then query the custom API endpoint to confirm the integration data record was created.

### 5. Register S2S authentication for external consumption

Register a Microsoft Entra ID application, configure it in Business Central, and obtain OAuth tokens for server-to-server API access. [src5]

```bash
# Input:  Entra ID tenant ID, client ID, client secret
# Output: OAuth 2.0 access token for Business Central API

# Step 1: 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"

# Response:
# {
#   "access_token": "eyJ...",
#   "token_type": "Bearer",
#   "expires_in": 3599
# }

# Step 2: 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 integration data records with HTTP 200.

### 6. Implement error handling and retry logic

Handle HTTP 429 (rate limit) and 504 (timeout) responses with exponential backoff. Parse Business Central error responses for actionable details. [src3]

```python
# Input:  Business Central API URL, access token
# Output: API response or structured error

import requests
import time

def bc_api_request(method, url, token, data=None, max_retries=5):
    """Call Business Central API with retry logic for rate limits."""
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "Accept": "application/json"
    }
    if method in ("PATCH", "DELETE"):
        # Optimistic concurrency — fetch ETag first
        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))
            print(f"Rate limited. Waiting {retry_after}s (attempt {attempt + 1})")
            time.sleep(retry_after)
            continue

        if response.status_code == 504:
            wait = min(2 ** attempt * 5, 120)
            print(f"Gateway timeout. Waiting {wait}s (attempt {attempt + 1})")
            time.sleep(wait)
            continue

        if response.status_code == 401:
            raise Exception("Authentication failed — check Entra ID config and token audience")

        # Parse BC error response
        try:
            error = response.json().get("error", {})
            msg = 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 Business Central error message.

## Code Examples

### AL: Custom API page with bound action

```al
// Input:  External system calls POST to trigger custom business logic
// Output: Action processes the record and returns updated status

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

```powershell
# Input:  Entra ID credentials, BC environment details
# Output: Integration data records as PowerShell objects

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

# Get OAuth token
$tokenParams = @{
    ClientId     = $clientId
    TenantId     = $tenantId
    ClientSecret = $clientSecret
    Scopes       = "https://api.businesscentral.dynamics.com/.default"
}
$token = Get-MsalToken @tokenParams

# Query custom API
$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

```bash
# Input:  Entra ID tenant, client credentials, BC environment
# Output: List of companies or custom API response

# 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 via custom API
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 Type | API JSON Type | OData Type | Transform | Gotcha |
|---|---|---|---|---|
| Code[N] | string | Edm.String | Direct; trimmed to N chars | Leading/trailing spaces are trimmed; case-sensitive matching in filters |
| Text[N] | string | Edm.String | Direct | Max N characters; longer values cause validation error (not silent truncation) |
| Decimal | number | Edm.Decimal | Direct | Precision depends on DecimalPlaces property in AL; defaults to 2 |
| 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 [src1] |
| Date | string | Edm.Date | YYYY-MM-DD format | No time component; time zone depends on BC setup |
| Enum | string | Edm.String | Display name (not ordinal) | API uses the caption value, not the internal integer ordinal [src2] |
| GUID (SystemId) | string | Edm.Guid | Lowercase with hyphens | Auto-generated; used as primary key in API pages via ODataKeyFields [src2] |
| Option (legacy) | string | Edm.String | Caption value | Deprecated in favor of Enum; still supported for backward compatibility |

### Data Type Gotchas

- **Enum values in API are caption strings, not ordinals**: When filtering or setting enum fields via the API, use the English caption value (e.g., `"Open"`, not `0`). If the environment has custom translations, the API still uses the base English caption. [src2]
- **DateTime timezone handling**: Business Central stores all DateTime values in UTC. The API returns them with the UTC offset. Do not assume local time; always parse the offset or convert to UTC on your integration side. [src1]
- **ETag required for PATCH and DELETE**: Business Central uses optimistic concurrency. Every PATCH or DELETE request must include an `If-Match` header with the record's current ETag value. Omitting it returns HTTP 412 Precondition Failed. The ETag changes with every modification. [src2]
- **SystemId vs. "No." fields**: API pages should use SystemId (GUID) as the key via ODataKeyFields. Do not use the traditional "No." field as the OData key — it causes issues with records that have compound keys. [src2]

## Error Handling & Failure Points

### Common Error Codes

| Code | Meaning | Cause | Resolution |
|---|---|---|---|
| 400 Bad Request | Validation error | Required field missing, wrong data type, or business logic validation failure | Parse error.message for field-level details; check AL validation triggers [src3] |
| 401 Unauthorized | Authentication failure | Expired token, wrong audience, or Entra app not configured in BC | Re-authenticate; verify Microsoft Entra Applications page in BC [src5] |
| 403 Forbidden | Insufficient permissions | S2S app lacks required permission sets | Add appropriate permission sets in BC Microsoft Entra Applications page [src5] |
| 404 Not Found | Endpoint or record missing | Wrong API publisher/group/version, company ID, or record does not exist | Verify endpoint URL components; check if extension is published and API page is available [src2] |
| 409 Conflict | Concurrent modification | Another user/process modified the record since you fetched it | Re-fetch the record, get new ETag, retry the operation [src2] |
| 412 Precondition Failed | ETag mismatch | If-Match header does not match current record version | Fetch current ETag with GET, then retry PATCH/DELETE [src2] |
| 429 Too Many Requests | Rate limit exceeded | More than 600/min (Production) or 300/min (Sandbox) | Implement exponential backoff; check Retry-After header [src3] |
| 504 Gateway Timeout | Request too slow | Operation exceeds 10-minute execution limit | Optimize query; reduce $expand depth; split into smaller operations [src3] |

[src3, src5]

### Failure Points in Production

- **Extension upgrade breaks event subscribers**: When the base application is updated (e.g., from BC26 to BC27), event signatures may change, causing subscriber compilation failures. Fix: `Pin your app.json runtime version and application dependency to a tested version. Test against the preview environment before each BC release wave.` [src1, src6]
- **Custom API page returns 404 after deployment**: The extension is published but the API page is not visible. This happens when the API page is not properly registered or the APIVersion/APIPublisher/APIGroup combination conflicts with another extension. Fix: `Verify the extension is installed (not just published) on the target environment. Check for APIPublisher/APIGroup naming conflicts. Restart the service tier if on-premises.` [src2]
- **Rate limit exhaustion in batch integrations**: Bulk data sync operations that send individual API calls for each record quickly hit the 600/min limit when processing thousands of records. Fix: `Implement client-side batching with rate limiting (throttle to ~500/min with backoff). Use $batch OData requests where supported. Consider AL background sessions (Job Queue) for server-side processing.` [src3]
- **ETag-related update failures in concurrent environments**: Multiple integration processes or users updating the same records cause ETag mismatches (HTTP 409/412), leading to silent data loss if not handled. Fix: `Always implement read-modify-write pattern: GET the record, extract ETag, then PATCH with If-Match. On 409/412, re-read and retry with merge logic.` [src2]
- **Event subscriber infinite loops**: A subscriber that modifies a record can trigger the same event again, causing stack overflow. Fix: `Use a static boolean flag or SessionInformation.IsRerunOriginalRequest to detect re-entry. Design subscribers to be idempotent.` [src4]

## Anti-Patterns

### Wrong: Directly modifying base application tables

```al
// BAD — this is impossible in AL extensions and causes compilation errors
// Attempting to modify the base Customer table directly
table 18 Customer
{
    // This will NOT compile — you cannot redefine base objects
    fields
    {
        field(50100; "My Custom Field"; Text[100]) { }
    }
}
```

### Correct: Use table extensions to add fields

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

```al
// BAD — API pages cannot be extended; this causes a compilation error
pageextension 50100 "Customer API Ext" extends "Customer API"
{
    // NOT ALLOWED — API page extensions are blocked by design
}
```

### Correct: Create a new custom API page for extended data

```al
// 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 in production extensions

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

### Correct: Always set proper DataClassification

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

## Common Pitfalls

- **Not reserving a prefix/suffix for AppSource**: Every object name and field name in an AppSource extension must use a reserved prefix or suffix. Failing to do so is the #1 cause of AppSource validation failures. Fix: `Register your prefix/suffix with Microsoft before development. Apply it consistently to ALL objects, fields, actions, and variables.` [src7]
- **Hardcoding runtime version too high**: Setting `"runtime": "15.0"` in app.json means the extension only works on BC27+. Customers on BC26 cannot install it. Fix: `Set runtime to the lowest version that supports the AL features you need. Test against multiple BC versions.` [src6]
- **Ignoring upgrade codeunits**: Removing or renaming a table field in a new extension version without an upgrade codeunit causes data loss during upgrade. Fix: `Always write upgrade codeunits (OnUpgradePerDatabase, OnUpgradePerCompany) to migrate data before schema changes.` [src1]
- **Not handling the "Companies" wrapper in API URLs**: Every Business Central API call must include the company ID in the URL path: `/companies({companyId})/`. Omitting it returns 404 or data from the wrong company. Fix: `Always query /api/v2.0/companies first to get the company ID, then use it in all subsequent API calls.` [src2]
- **Subscribing to too many events with heavy logic**: Each event subscriber adds overhead to the base operation. If 10 extensions each subscribe to OnAfterPostSalesDocument and each does external HTTP calls, posting a sales invoice becomes extremely slow. Fix: `Keep event subscribers lightweight. Queue heavy processing to Job Queue entries or background sessions.` [src4, src8]
- **Using OnRun trigger in codeunits for API logic**: The OnRun trigger is intended for manual execution and does not work well with API page bound actions. Fix: `Use named procedures with the [ServiceEnabled] attribute for API-callable business logic.` [src2]

## Diagnostic Commands

```bash
# Check available API endpoints (list all published API pages)
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 companies list)
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"
# Expected: 200

# 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"
# Expected: 200 with JSON response (even if empty array)

# Verify extension is installed (via 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 Version | Release | AL Runtime | Status | Key Changes | Migration Notes |
|---|---|---|---|---|---|
| BC28 | 2026 Wave 1 | 16.x | Preview | NuGet symbol downloads without environment; further runtime enhancements | Test against preview before release [src6] |
| BC27 | 2025 Wave 2 | 15.x | Current | New default runtime version; enhanced API page properties (EntityCaption, EntitySetCaption required) | Update app.json runtime; add new required properties to API pages [src6] |
| BC26 | 2025 Wave 1 | 14.x | Supported | VS Code actions from Web Client; improved symbol downloading | Minimum recommended version for new development [src6] |
| BC25 | 2024 Wave 2 | 13.x | Supported | Enhanced isolated events; new HttpClient improvements | Still widely deployed |
| BC24 | 2024 Wave 1 | 12.x | Supported | Interface improvements; partial records enhancements | Consider upgrading for performance improvements |
| BC21 | 2022 Wave 2 | 10.x | EOL | Enum extensibility matured; API v2.0 became standard | Upgrade required — out of support |

[src1, src6]

### Deprecation Policy

Microsoft follows a "Modern Lifecycle" policy for Business Central: each release wave is supported until the next two release waves are generally available (approximately 12 months of support per wave). API v1.0 endpoints are deprecated in favor of API v2.0. The legacy C/AL development model is fully deprecated — AL extensions are the only supported customization path for SaaS. Microsoft provides at least one release wave notice before removing deprecated AL features. [src1, src6]

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| You need to add custom business logic to standard BC processes | You only need to read/write standard entities without customization | 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 |
| You need event-driven hooks into posting, validation, or approval workflows | You need bulk data migration of millions of records | Configuration Packages / RapidStart Services |
| You need to call external APIs from within BC | You need real-time BI dashboards from BC data | OData published pages + Power BI |

## Important Caveats

- AL extension development requires a Business Central developer license or a production/sandbox environment with development permissions. The VS Code AL Language extension is free, but publishing to an environment requires appropriate licensing. [src1]
- Sandbox environments have lower API rate limits (300/min vs 600/min) and may not reflect production performance. Always validate integration throughput against a production-representative environment. [src3]
- The extension model is additive-only — you cannot remove functionality from the base application. If a customer needs to remove standard fields or behavior, the only option is to hide UI elements and use event subscribers to bypass logic. [src1]
- AppSource certification has strict requirements that go beyond code quality: telemetry, privacy policy, support availability, and alignment with BC release cadence. Plan for 4-8 weeks of validation cycle time. [src7]
- Microsoft releases two major BC updates per year (Wave 1 in April, Wave 2 in October). Each wave may introduce new AL features, deprecate old ones, and change event signatures. Budget for testing and updating extensions with each wave. [src1, src6]

## Related Units

- [Business Central standard REST API v2.0 endpoints](/business/erp-integration/business-central-api-v2/2026)
- [Power Automate connectors for Business Central](/business/erp-integration/dynamics365-power-automate/2026)
- [Dynamics 365 to Salesforce integration playbook](/business/erp-integration/dynamics365-salesforce-integration/2026)
