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:
- Open Azure Portal (portal.azure.com) and go to Azure Active Directory > App registrations.
- Click New Registration. Enter a name such as ‘BCO API Client’ and leave the redirect URI blank for Client Credentials flow.
- After registration, go to Certificates & Secrets > New Client Secret. Copy the secret value immediately — it is only shown once.
- Go to API Permissions > Add Permission > Dynamics 365 Business Central > Application Permissions. Add Financials.ReadWrite.All.
- Click Grant Admin Consent for your tenant.
- Copy the Application (client) ID and Directory (tenant) ID from the Overview page.
- 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:
- Press F5 in VS Code to publish the extension to your Business Central sandbox environment.
- In Business Central, search for ‘Extension Management’ and confirm your extension is installed and Active.
- Open Postman, get a fresh bearer token (Step 6), and test the GET /customers endpoint.
- Verify the totalValueOnStock field appears in the Item API response with the correct calculated value.
- Test a POST request to create a Sales Order, then immediately GET it with ?$expand=salesOrderLines.
- Try a PATCH request to update the externalDocNo field — confirm the If-Match header is required.
- 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/