BizCentralOrbit

How to Build Custom API Pages in Microsoft Dynamics 365 Business Central


Microsoft Dynamics 365 Business Central is not just an ERP — it is a platform. And one of the most powerful platform capabilities it offers is the ability to expose your own data as REST API endpoints using Custom API Pages. In this blog, we are going to show you exactly how to build four production-ready Custom API Pages in AL: a Customer API, a Sales Order API with nested Sales Lines, a Sales Lines API, and an Item Inventory API with a computed virtual field.

Custom API Pages follow the OData v4 standard, support OAuth 2.0 authentication, and work natively with Postman, Power BI, Power Automate, Azure Logic Apps, and any third-party system. Once your Custom API Pages are published, any authorized application can read and write BC data without needing a BC licence — making this one of the most valuable integration patterns in the Business Central ecosystem.

In this tutorial, we will build a complete AL extension with four API pages, set up Azure App Registration for OAuth 2.0 authentication, get a bearer token in Postman, test all endpoints live, and explore OData query options like $filter, $select, $expand, and $orderby.

Prerequisites

Before starting, make sure you have the following ready:

  • Visual Studio Code with the AL Language extension installed
  • Microsoft Dynamics 365 Business Central (online sandbox or on-premises)
  • Postman — for testing API endpoints
  • An Azure subscription — for registering an OAuth 2.0 app
  • Basic knowledge of AL Programming Language
  • A Business Central admin account with permission to create app registrations

What Are Custom API Pages in Business Central?

Custom API Pages are AL pages with PageType = API. Unlike standard BC pages, they are not displayed in the Business Central user interface. Instead, they are accessible through REST API endpoints at a standard URL pattern:

GET https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{env}/api
        /{publisher}/{group}/{version}/companies({companyId})/{entitySetName}

Each API page has four key properties that define its URL: APIPublisher, APIGroup, APIVersion, and EntitySetName. The page is linked to a source table through SourceTable, and each field in the page becomes a JSON property in the API response.

By default, the BC OData key is SystemId — a stable GUID that never changes, even if a document number is modified. This is always the right choice for ODataKeyFields.

Step 1: Create the Extension Manifest (app.json)

Start by creating a new AL project in VS Code. The app.json file is the manifest of your extension. Set your publisher name, a unique ID range, and the correct runtime version. For BC 24/25/26 environments, use runtime 12.0 or higher.

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "name": "BCO API Extension",
  "publisher": "BizCentralOrbit",
  "version": "1.0.0.0",
  "idRanges": [{ "from": 50100, "to": 50199 }],
  "platform": "22.0.0.0",
  "application": "22.0.0.0",
  "runtime": "12.0"
}


💡 Note: Use a unique ID range that does not conflict with existing extensions in your environment. The range 50100–50199 is commonly used for custom development objects.

Step 2: Build a Read-Only Customer API (Page 50100)

Our first API page exposes the Customer table (Table 18) as a read-only REST endpoint. We set InsertAllowed, ModifyAllowed, and DeleteAllowed all to false, and Editable = false, because this is a reporting/integration endpoint — not a write endpoint.

The API will be accessible at the URL pattern:

GET /api/bizcentralorbit/sales/v1.0/companies({id})/customers
page 50100 "BCO Customer API"
{
    PageType      = API;
    APIPublisher  = 'bizcentralorbit';
    APIGroup      = 'sales';
    APIVersion    = 'v1.0';
    EntityName    = 'customer';
    EntitySetName = 'customers';
    SourceTable   = Customer;
    ODataKeyFields = SystemId;
    Editable       = false;
    InsertAllowed  = false;
    ModifyAllowed  = false;
    DeleteAllowed  = false;
    layout
    {
        area(Content)
        {
            repeater(CustomerData)
            {
                field(id;            Rec.SystemId)               { }
                field(number;        Rec."No.")                  { }
                field(displayName;   Rec.Name)                   { }
                field(email;         Rec."E-Mail")               { }
                field(phoneNumber;   Rec."Phone No.")            { }
                field(creditLimit;   Rec."Credit Limit (LCY)")   { }
                field(balance;       Rec."Balance (LCY)")        { }
                field(address;       Rec.Address)                { }
                field(city;          Rec.City)                   { }
                field(country;       Rec."Country/Region Code")  { }
                field(blocked;       Rec.Blocked)                { }
                field(salespersonCode; Rec."Salesperson Code")   { }
                field(paymentTermsCode; Rec."Payment Terms Code") { }
                field(currencyCode;  Rec."Currency Code")        { }
            }
        }
    }
}
💡 Note: The field names in quotes (like id, number, displayName) become the JSON property names in the API response. Choose them carefully — they are the public API contract for any consuming application.

Step 3: Build a Sales Order API with Nested Lines (Pages 50101 & 50102)

The Sales Order API is a writable endpoint — it supports POST (create) and PATCH (update) in addition to GET. This makes it the most complex of our four pages. We also embed the Sales Lines as a nested sub-page using a part declaration, so consuming applications can retrieve an order with all its lines in a single API call.

Key Design Decisions

  • DeleteAllowed = false — Sales Orders should never be deleted via API. Use the Release/Cancel workflow instead.
  • DelayedInsert = true — This is mandatory on any API page where InsertAllowed = true (AL compiler rule AL0505).
  • ODataKeyFields = SystemId — Never use document number as the key. SystemId is a stable GUID.
  • OnInsertRecord trigger — We must explicitly set Document Type = Order when creating via POST, because BC does not assume this automatically for API pages.

Sales Order API — Page 50101

page 50101 "BCO Sales Order API"
{
    PageType      = API;
    APIPublisher  = 'bizcentralorbit';
    APIGroup      = 'sales';
    APIVersion    = 'v1.0';
    EntityName    = 'salesOrder';
    EntitySetName = 'salesOrders';
    SourceTable   = "Sales Header";
    ODataKeyFields = SystemId;
    DelayedInsert  = true;
    InsertAllowed  = true;
    ModifyAllowed  = true;
    DeleteAllowed  = false;
    layout
    {
        area(Content)
        {
            repeater(SalesOrderData)
            {
                // Identity
                field(id;           Rec.SystemId)                { }
                field(number;       Rec."No.")                   { }
                // Dates
                field(orderDate;    Rec."Order Date")            { }
                field(dueDate;      Rec."Due Date")              { }
                field(documentDate; Rec."Document Date")         { }
                // Customer
                field(customerNumber; Rec."Sell-to Customer No.") { }
                field(customerName;   Rec."Sell-to Customer Name") { }
                // Amounts
                field(amount;       Rec.Amount)                  { }
                field(amountInclVAT; Rec."Amount Including VAT") { }
                // Status & References
                field(status;       Rec.Status)                  { }
                field(externalDocNo; Rec."External Document No.") { }
                field(salespersonCode; Rec."Salesperson Code")   { }
                field(currencyCode; Rec."Currency Code")         { }
                // Ship-to
                field(shipToName;    Rec."Ship-to Name")         { }
                field(shipToAddress; Rec."Ship-to Address")      { }
                field(shipToCity;    Rec."Ship-to City")         { }
                // Nested Lines
                part(salesOrderLines; "BCO Sales Lines API")
                {
                    Caption       = 'Lines';
                    EntityName    = 'salesOrderLine';
                    EntitySetName = 'salesOrderLines';
                    SubPageLink   = "Document Type" = field("Document Type"),
                                    "Document No."  = field("No.");
                }
            }
        }
    }
    trigger OnInsertRecord(BelowxRec: Boolean): Boolean
    begin
        // POST: BC does not set Document Type automatically for API pages
        Rec."Document Type" := Rec."Document Type"::Order;
        Rec."Order Date"    := Today();
        Rec."Document Date" := Today();
        if Rec."Sell-to Customer No." = '' then
            Error('customerNumber is required in the POST body.');
        exit(true);
    end;
    trigger OnModifyRecord(): Boolean
    begin
        // PATCH: Block modification of Released orders
        if Rec.Status = Rec.Status::Released then
            Error('Cannot modify a Released Sales Order via API.');
        exit(true);
    end;
}

💡 Note: The part declaration with SubPageLink creates the parent-child JOIN between Sales Header and Sales Lines. Access nested data with: GET …/salesOrders?$expand=salesOrderLines

Sales Lines API — Page 50102

The Sales Lines API is a sub-page linked to Sales Orders. It is also writable and supports all CRUD operations. A critical rule: the Sales Line table (Table 37) has no Status field — Status belongs to the Sales Header. Any status validation in OnModifyRecord must read from the parent Sales Header.

page 50102 "BCO Sales Lines API"
{
    PageType      = API;
    APIPublisher  = 'bizcentralorbit';
    APIGroup      = 'sales';
    APIVersion    = 'v1.0';
    EntityName    = 'salesLine';
    EntitySetName = 'salesLines';
    SourceTable   = "Sales Line";
    ODataKeyFields = SystemId;
    DelayedInsert  = true;
    InsertAllowed  = true;
    ModifyAllowed  = true;
    DeleteAllowed  = true;
    layout
    {
        area(Content)
        {
            repeater(SalesLineData)
            {
                field(id;             Rec.SystemId)            { }
                field(documentType;   Rec."Document Type")     { }
                field(documentNo;     Rec."Document No.")      { }
                field(lineNo;         Rec."Line No.")          { }
                field(type;           Rec.Type)                { }
                field(itemNo;         Rec."No.")               { }
                field(description;    Rec.Description)         { }
                field(quantity;       Rec.Quantity)            { }
                field(unitPrice;      Rec."Unit Price")        { }
                field(lineAmount;     Rec."Line Amount")       { }
                field(lineDiscount;   Rec."Line Discount %")   { }
                field(unitOfMeasure;  Rec."Unit of Measure Code") { }
                field(locationCode;   Rec."Location Code")     { }
            }
        }
    }
    trigger OnInsertRecord(BelowxRec: Boolean): Boolean
    begin
        Rec."Document Type" := Rec."Document Type"::Order;
        if Rec."No." = '' then
            Error('itemNo is required when inserting a Sales Line.');
        if Rec.Quantity <= 0 then
            Error('quantity must be greater than zero.');
        exit(true);
    end;
    trigger OnModifyRecord(): Boolean
    var
        SalesHeader: Record "Sales Header";
    begin
        // Sales Line has no Status field — read from parent Sales Header
        if SalesHeader.Get(Rec."Document Type", Rec."Document No.") then
            if SalesHeader.Status = SalesHeader.Status::"Pending Approval" then
                Error('Cannot modify a line on an order that is Pending Approval.');
        exit(true);
    end;
}

💡 Note: Rec.Status does not exist on Sales Line (Table 37). Always use SalesHeader.Get() and check SalesHeader.Status when you need parent order status inside a Sales Line trigger.


Step 4: Build an Item Inventory API with a Computed Field (Page 50103)

The Item Inventory API demonstrates one of the most important patterns in Custom API Pages: how to expose a computed (virtual) field — a field that does not exist in the source table but is calculated on the fly for each record returned by the API.

In this case, we compute TotalValueOnStock = Inventory × Unit Cost. This value is not stored anywhere in BC — it is calculated fresh for every API response.

The Critical Rule: No Triggers Inside Field Declarations

In earlier AL versions, some developers tried to put trigger code inside field declarations using OnBeforePassField. This trigger does NOT exist in AL for API pages and will cause compilation error AL0162. The correct pattern is:

  • Declare the virtual field as a plain empty field: field(totalValueOnStock; TotalValue) { }
  • Declare a page-level var TotalValue: Decimal;
  • Calculate TotalValue inside the page-level trigger OnAfterGetRecord() — this fires once per record
  • Use OnOpenPage() with SetAutoCalcFields() to auto-calculate FlowFields for all records
page 50103 "BCO Item Inventory API"
{
    PageType      = API;
    APIPublisher  = 'bizcentralorbit';
    APIGroup      = 'inventory';
    APIVersion    = 'v1.0';
    EntityName    = 'item';
    EntitySetName = 'items';
    SourceTable   = Item;
    ODataKeyFields = SystemId;
    Editable       = false;
    InsertAllowed  = false;
    ModifyAllowed  = false;
    DeleteAllowed  = false;
    layout
    {
        area(Content)
        {
            repeater(ItemData)
            {
                field(id;              Rec.SystemId)             { }
                field(number;          Rec."No.")                { }
                field(description;     Rec.Description)          { }
                field(inventory;       Rec.Inventory)            { }
                field(unitCost;        Rec."Unit Cost")          { }
                field(unitPrice;       Rec."Unit Price")         { }
                field(itemCategoryCode; Rec."Item Category Code") { }
                field(baseUnitOfMeasure; Rec."Base Unit of Measure") { }
                field(qtyOnSalesOrder; Rec."Qty. on Sales Order") { }
                field(qtyOnPurchOrder; Rec."Qty. on Purch. Order") { }
                field(blocked;         Rec.Blocked)              { }
                // Virtual / computed field — NO trigger inside { }
                field(totalValueOnStock; TotalValue)             { }
            }
        }
    }
    // Page-level trigger — fires once per record ──────────────────────
    trigger OnAfterGetRecord()
    begin
        TotalValue := Rec.Inventory * Rec."Unit Cost";
    end;
    // Auto-calculate all FlowFields before iteration starts ────────────
    trigger OnOpenPage()
    begin
        Rec.SetAutoCalcFields(Inventory, "Qty. on Sales Order", "Qty. on Purch. Order");
    end;
    var
        TotalValue: Decimal;
}

💡 Note: Inventory is a FlowField in the Item table — it requires SetAutoCalcFields() to be calculated automatically for every record during iteration. Without it, Inventory will always return 0 in API responses.

Step 5: Register an Azure App for OAuth 2.0 Authentication

Business Central API endpoints require OAuth 2.0 authentication. You cannot call them with a username and password — you must obtain a bearer token from Azure Active Directory using an App Registration. Follow these steps:

  1. Open Azure Portal (portal.azure.com) and go to Azure Active Directory > App registrations.
  2. Click New Registration. Enter a name such as ‘BCO API Client’ and leave the redirect URI blank for Client Credentials flow.
  3. After registration, go to Certificates & Secrets > New Client Secret. Copy the secret value immediately — it is only shown once.
  4. Go to API Permissions > Add Permission > Dynamics 365 Business Central > Application Permissions. Add Financials.ReadWrite.All.
  5. Click Grant Admin Consent for your tenant.
  6. Copy the Application (client) ID and Directory (tenant) ID from the Overview page.
  7. In Business Central: search for ‘Azure Active Directory Applications’ and register your App ID with the correct permission set (e.g. SUPER or a custom permission set).

💡 Note: The Client Credentials flow (app-to-app) is the recommended pattern for server-to-server integrations. No user interaction or browser redirect is required.

Step 6: Get a Bearer Token in Postman

Once your Azure App Registration is configured, use Postman to get an OAuth 2.0 access token. Send a POST request to the Microsoft identity platform token endpoint:

POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Body (x-www-form-urlencoded):
  grant_type    = client_credentials
  client_id     = {your Application ID}
  client_secret = {your Client Secret}
  scope         = https://api.businesscentral.dynamics.com/.default

The response is a JSON object. Copy the value of the access_token field — this is your bearer token. It is valid for approximately 1 hour.

💡 Note: In Postman: set the method to POST (not GET), set the Body type to x-www-form-urlencoded, and enter each field as a key-value pair. Do NOT put the fields in the URL — they must be in the request body.

Step 7: Test All Four API Endpoints in Postman

With your bearer token, you can now test all four Custom API endpoints. Set the Authorization header to Bearer {your_token} on every request.

Get All Customers

GET https://api.businesscentral.dynamics.com/v2.0/{tenantId}/Sandbox/api
    /bizcentralorbit/sales/v1.0/companies({companyId})/customers
Headers:
  Authorization: Bearer {access_token}

Get All Sales Orders

GET .../bizcentralorbit/sales/v1.0/companies({companyId})/salesOrders

Get All Items with Inventory

GET .../bizcentralorbit/inventory/v1.0/companies({companyId})/items

Create a New Sales Order (POST)

POST .../bizcentralorbit/sales/v1.0/companies({companyId})/salesOrders
Headers:
  Authorization: Bearer {access_token}
  Content-Type: application/json
Body:
{
  "customerNumber": "C00010"
}

Update a Sales Order (PATCH)

To update a record, you must first GET it to obtain its SystemId and the @odata.etag value. Then send:

PATCH .../salesOrders({systemId})
Headers:
  Authorization: Bearer {access_token}
  Content-Type: application/json
  If-Match: {etag value — without backslashes}
Body:
{
  "externalDocNo": "PO-2026-001"
}

💡 Note: The If-Match header must contain the exact etag value from the GET response — but remove any escaped backslashes. If the etag in the JSON appears as W/”abc123″, use W/”abc123″ in the header (no backslashes).

Step 8: Use OData Query Options ($filter, $expand, $select, $orderby)

One of the biggest advantages of Custom API Pages over custom web services is full OData v4 query support. Here are the most useful query options:

$expand — Get Order with Lines

GET .../salesOrders?$expand=salesOrderLines
// Or for a specific order:
GET .../salesOrders({systemId})?$expand=salesOrderLines

$filter — Filter Records Server-Side

// Get orders for a specific customer
GET .../salesOrders?$filter=customerNumber eq 'C00010'
// Get items with inventory above 100
GET .../items?$filter=inventory gt 100
// Get released orders only
GET .../salesOrders?$filter=status eq 'Released'

$select — Return Only Specific Fields

GET .../customers?$select=number,displayName,email,balance

$orderby — Sort Results

// Sort items by inventory descending
GET .../items?$orderby=inventory desc
// Sort orders by order date descending
GET .../salesOrders?$orderby=orderDate desc

$top and $skip — Pagination

// Get first 20 records
GET .../customers?$top=20
// Get records 21-40 (page 2)
GET .../customers?$top=20&$skip=20

💡 Note: Combining OData options: GET …/salesOrders?$filter=customerNumber eq ‘C00010’&$expand=salesOrderLines&$orderby=orderDate desc — this is the power of OData v4 over traditional web services.

Step 9: Deploy and Test in Business Central

Once all AL files are ready, follow these steps to publish and test the extension:

  1. Press F5 in VS Code to publish the extension to your Business Central sandbox environment.
  2. In Business Central, search for ‘Extension Management’ and confirm your extension is installed and Active.
  3. Open Postman, get a fresh bearer token (Step 6), and test the GET /customers endpoint.
  4. Verify the totalValueOnStock field appears in the Item API response with the correct calculated value.
  5. Test a POST request to create a Sales Order, then immediately GET it with ?$expand=salesOrderLines.
  6. Try a PATCH request to update the externalDocNo field — confirm the If-Match header is required.
  7. Test $filter and $orderby to confirm OData query options work end-to-end.

💡 Note: If you receive ‘HTTP 401 Unauthorized’, your bearer token has expired. Request a new token from the Azure token endpoint and update the Authorization header in Postman.

Conclusion

In this blog, we successfully built four production-ready Custom API Pages in Microsoft Dynamics 365 Business Central using AL programming. We covered:

  • A read-only Customer API with full OData v4 support
  • A writable Sales Order API with embedded nested Sales Lines using part and SubPageLink
  • A Sales Lines API with proper status validation by reading from the parent Sales Header
  • An Item Inventory API with a computed virtual field using the correct page-level OnAfterGetRecord trigger pattern
  • OAuth 2.0 authentication using Azure App Registration and Client Credentials flow
  • Full OData query options: $filter, $expand, $select, $orderby, $top, $skip

Custom API Pages are the recommended integration pattern for Business Central because they support OData v4, require no SOAP configuration, work natively with Power BI and Power Automate, and are fully supported in both SaaS and on-premises environments. Once published, your custom endpoints are available to any authorized application — making BC a true open platform.

The complete AL source code used in this blog is available in the video description on our YouTube channel. Subscribe and turn on notifications so you do not miss future tutorials.

To get more such useful information, please follow our LinkedIn page and you can also subscribe our you tube page.

YouTube Link: https://www.youtube.com/@bizcentralorbit

LinkedIn Link: https://www.linkedin.com/company/bizcentralorbit/posts/?feedView=all

If you want to book a 1-to-1 live session with any of our expert consultants then click the link: https://bizcentralorbit.com/#One-to

If you enjoyed this blog, you may also like: How to Call Claude AI API from Microsoft Dynamics 365 Business Central

If you want a Tutorial videos of “Add custom Cue Group on Roll Center Page by AL Language” then click the link: https://www.youtube.com/watch?v=pfa7VobkurQ

Raise a support ticket instantly by clicking the link: https://bizcentralorbit.com/contact-us/

 

Leave a Comment

Your email address will not be published. Required fields are marked *

0
    0
    Your Cart
    Your cart is empty
    Scroll to Top