Guidelines for Partners
What is an API page
An API page is a special type of page in Business Central that has no visual interface. You cannot open it from a menu, and it has no buttons. Instead, it is a data endpoint — it speaks OData v4 (a standard REST protocol) and lets other programs read your Business Central data over HTTP
Think of it like this:
- Normal page: a window inside BC that a user opens and interacts with.
- API page: a door at the back of BC that other applications knock on to receive data as JSON.
API pages are perfect when you want Power Automate, Power BI, an external website, or a mobile app to read data from Business Central without a user logging in.
Common use cases:
- Power BI — connect directly to BC data without manual exports
- Power Automate / Logic Apps — trigger BC actions from other systems
- Excel — live data refresh from BC tables
- Mobile apps or web apps — read/write BC data via REST calls
- Integration with third-party ERP, CRM, or accounting software
- Automated scripts and batch jobs running outside BC
The Skeleton of an API page
page 50000 "My First API Page" { PageType = API; APIPublisher = 'mycompany'; APIGroup = 'inventory'; APIVersion = 'v1.0'; EntityName = 'product'; EntitySetName = 'products'; Caption = 'My Product API'; SourceTable = Item; Editable = false; InsertAllowed = false; ModifyAllowed = false; DeleteAllowed = false; DelayedInsert = true; ODataKeyFields = SystemId; layout { area(Content) { repeater(Lines) { field(id; Rec.SystemId) { Caption = 'Id'; ApplicationArea = All; } field(no; Rec."No.") { Caption = 'No.'; ApplicationArea = All; } field(description; Rec.Description) { Caption = 'Description'; ApplicationArea = All; } } } } }
Business Central API Properties Explained:
- PageType = API This single line makes the page headless. Without it you get a normal list page. With it, the page disappears from all BC menus and becomes reachable only via HTTP.
- APIPublisher, APIGroup, APIVersion — your URL address.
- APIPublisher: your company name, lowercase, no spaces. Example: contoso
- APIGroup: the functional area. Example: sales, inventory, finance. All lowercase.
- APIVersion: start with v1.0. Bump to v2.0 only when you make breaking changes.
These three properties form the URL path of your endpoint:
https://your-bc-server/api/
{APIPublisher}/{APIGroup}/{APIVersion}/{EntitySetName}
Example with the values above:
https://your-bc-server/api/mycompany/inventory/v1.0/products
Never use the same APIPublisher + APIGroup + APIVersion for two different API pages — they would conflict.
- EntitySetName: plural name (collection of items), used in the URL, case‑sensitive. Example: products
- EntityName: singular name (one item), used in metadata, not in the URL.
Example: product
- SourceTable: The Business Central table your API reads from. You can use any standard BC tables (Item, Customer, Vendor…) or your own custom tables.
- Editable / InsertAllowed / ModifyAllowed / DeleteAllowed For a safe read-only API set all four to false. Callers can only GET data — they cannot create, change, or delete records. If you do want to allow writes, set the relevant property to true and BC will accept POST, PATCH, and DELETE requests.
- DelayedInsert is used for editable API pages. It waits until all fields are filled, then creates the record.
Set DelayedInsert = true; to insert the record only after all data is provided.
Not needed if Editable = false.
- ODataKeyFields: Tells OData which field(s) uniquely identify one record, used when a caller requests a single entry:
GET …/products(‘Item0001’)
Usually use SystemID (it never changes). The field must exist on the API page.
- Defining Fields — What Data to Expose: The repeater inside the layout section controls which columns appear in the JSON response. Only fields listed here are visible to API callers.
repeater (Lines) { field (id; Rec.SystemId) { Caption = 'Id'; ApplicationArea = All; } }
Adding Business Logic — The ECB Example
Sometimes you do not just want to read data — you want the API to also run code when it is called. Our ECB example does exactly that: every time the API receives a request it automatically fetches fresh exchange rates from the internet and saves them first.
Making an HTTP Request (HttpClient)
Business Central has a built-in HttpClient object. Here is how to call an external URL:
procedure FetchRates(var TempRate: Record "Currency Exchange Rate" temporary) var Client: HttpClient; // makes the HTTP call Response: HttpResponseMessage; // holds status code + response body ResponseText: Text; // the raw XML/JSON text begin // Step 1: send the GET request if not Client.Get(ECBApiUrlLbl, Response) then Error(FetchFailedErr, 'Connection error'); // network problem // Step 2: check the server returned success (HTTP 200 OK) if not Response.IsSuccessStatusCode() then Error(FetchFailedErr, Format(Response.HttpStatusCode())); // Step 3: read the response body as a text string if not Response.Content.ReadAs(ResponseText) then Error(ResponseReadErr); // Step 4: parse the text and fill our temporary buffer record ParseRatesToBuffer(ResponseText, TempRate); end; Always check both Client.Get() and IsSuccessStatusCode(). A false from Get() means the network failed. A non-2xx status means the server responded but with an error.
Parsing XML
The ECB API returns XML. BC has built-in XML types — no libraries needed. The pattern: read the text into an XmlDocument, then loop over all elements looking for the attributes you need:
local procedure ParseRatesToBuffer(ResponseText: Text; var TempRate: Record "Currency Exchange Rate" temporary) var XmlDoc: XmlDocument; RootElement: XmlElement; NodeList: XmlNodeList; Node: XmlNode; Element: XmlElement; CurrencyAttr: XmlAttribute; // will hold currency="USD" RateAttr: XmlAttribute; // will hold rate="1.0765" ExchangeRate: Decimal; begin // Parse the raw text into an XmlDocument object if not XmlDocument.ReadFrom(ResponseText, XmlDoc) then Error(XmlParseErr); // '//*' is XPath for "select every element in the document" XmlDoc.GetRoot(RootElement); RootElement.SelectNodes('//*', NodeList); // Loop every element — we only care about ones that look like: // <Cube currency="USD" rate="1.0765"/> foreach Node in NodeList do begin Element := Node.AsXmlElement(); if Element.Attributes().Get('currency', CurrencyAttr) then if Element.Attributes().Get('rate', RateAttr) then begin // Convert the text "1.0765" to a Decimal — format 9 = invariant (dot separator) Evaluate(ExchangeRate, RateAttr.Value(), 9); // Store in the temporary buffer TempRate.Init(); TempRate."Currency Code" := CopyStr(CurrencyAttr.Value(), 1, 10); TempRate."Exchange Rate Amount" := ExchangeRate; TempRate.Insert(false); end; end; end;
Evaluate (myDecimal, text, 9) the 9 forces dot-decimal parsing regardless of regional settings. Always use this when parsing numbers from external APIs
The Complete ECB API Page
Below is the full working page that combines all the pieces above. You can copy, compile, and deploy this directly:
page 50001 "ECB Curr. Exch. Rate API" { PageType = API; APIPublisher = 'defaultpublisher'; APIGroup = 'currencyExchange'; APIVersion = 'v1.0'; EntityName = 'currencyExchangeRate'; EntitySetName = 'currencyExchangeRates'; Caption = 'ECB Currency Exchange Rate API'; SourceTable = "Currency Exchange Rate"; Editable = false; InsertAllowed = false; ModifyAllowed = false; DeleteAllowed = false; ODataKeyFields = "Currency Code", "Starting Date"; layout { area(Content) { repeater(Lines) { field(currencyCode; Rec."Currency Code") { ApplicationArea=All; Caption='Currency Code'; ToolTip='ISO 4217 code, e.g. USD.'; } field(startingDate; Rec."Starting Date") { ApplicationArea=All; Caption='Starting Date'; ToolTip='Date this rate is valid from.'; } field(exchangeRateAmount; Rec."Exchange Rate Amount") { ApplicationArea=All; Caption='Exchange Rate Amount'; ToolTip='Units of this currency equal to 1 EUR.'; } field(relationalExchRateAmount; Rec."Relational Exch. Rate Amount") { ApplicationArea=All; Caption='Relational Exch. Rate Amount'; ToolTip='Base currency amount (always 1).'; } } } } trigger OnOpenPage() begin UpdateExchangeRates(); end; var ECBApiUrlLbl: Label 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml', Locked=true; ApplySuccessMsg: Label 'Updated %1 currency exchange rates.'; FetchFailedErr: Label 'Could not retrieve ECB rates. Status: %1'; ResponseReadErr: Label 'Could not read the ECB response.'; XmlParseErr: Label 'Could not parse the ECB XML.'; procedure UpdateExchangeRates() var TempRate: Record "Currency Exchange Rate" temporary; begin FetchRates(TempRate); ApplyRates(TempRate); end; procedure FetchRates(var TempRate: Record "Currency Exchange Rate" temporary) var Client: HttpClient; Response: HttpResponseMessage; ResponseText: Text; begin if not Client.Get(ECBApiUrlLbl, Response) then Error(FetchFailedErr, 'Connection error'); if not Response.IsSuccessStatusCode() then Error(FetchFailedErr, Format(Response.HttpStatusCode())); if not Response.Content.ReadAs(ResponseText) then Error(ResponseReadErr); ParseRatesToBuffer(ResponseText, TempRate); end; procedure ApplyRates(var TempRate: Record "Currency Exchange Rate" temporary) var LiveRate: Record "Currency Exchange Rate"; AppliedCount: Integer; begin if not TempRate.FindSet() then exit; repeat LiveRate.SetLoadFields("Currency Code","Starting Date","Exchange Rate Amount","Relational Exch. Rate Amount"); LiveRate.SetRange("Currency Code", TempRate."Currency Code"); LiveRate.SetRange("Starting Date", TempRate."Starting Date"); if LiveRate.FindFirst() then begin LiveRate."Exchange Rate Amount" := TempRate."Exchange Rate Amount"; LiveRate.Modify(false); end else begin LiveRate.Init(); LiveRate."Currency Code" := TempRate."Currency Code"; LiveRate."Starting Date" := TempRate."Starting Date"; LiveRate."Exchange Rate Amount" := TempRate."Exchange Rate Amount"; LiveRate."Relational Exch. Rate Amount" := 1; LiveRate.Insert(false); end; AppliedCount += 1; until TempRate.Next() = 0; Message(ApplySuccessMsg, AppliedCount); end; local procedure ParseRatesToBuffer(ResponseText: Text; var TempRate: Record "Currency Exchange Rate" temporary) var XmlDoc: XmlDocument; RootElement: XmlElement; NodeList: XmlNodeList; Node: XmlNode; Element: XmlElement; CurrencyAttr: XmlAttribute; RateAttr: XmlAttribute; TimeAttr: XmlAttribute; RateDate: Date; ExchangeRate: Decimal; begin if not XmlDocument.ReadFrom(ResponseText, XmlDoc) then Error(XmlParseErr); XmlDoc.GetRoot(RootElement); RootElement.SelectNodes('//*', NodeList); foreach Node in NodeList do begin // find the date first Element := Node.AsXmlElement(); if Element.Attributes().Get('time', TimeAttr) then begin Evaluate(RateDate, TimeAttr.Value(), 9); break; end; end; if RateDate = 0D then Error(XmlParseErr); TempRate.Reset(); TempRate.DeleteAll(); foreach Node in NodeList do begin // then find every rate Element := Node.AsXmlElement(); if Element.Attributes().Get('currency', CurrencyAttr) then if Element.Attributes().Get('rate', RateAttr) then begin Evaluate(ExchangeRate, RateAttr.Value(), 9); TempRate.Init(); TempRate."Currency Code" := CopyStr(CurrencyAttr.Value(), 1, 10); TempRate."Starting Date" := RateDate; TempRate."Exchange Rate Amount" := ExchangeRate; TempRate."Relational Exch. Rate Amount" := 1; TempRate.Insert(false); end; end; end; }
After publishing the extension, any external tool can call your API via HTTP.
Get All Records
GET https://{tenant}.api.businesscentral.dynamics.com/v2.0/{tenant}/api/defaultpublisher/currencyExchange/v1.0/ currencyExchangeRates
Response (JSON):
"value": [ { "currencyCode": "USD", "startingDate": "2026-05-25", "exchangeRateAmount": 1.0765, "relationalExchRateAmount": 1 }, { "currencyCode": "GBP", "startingDate": "2026-05-25", "exchangeRateAmount": 0.8573, "relationalExchRateAmount": 1 } ]
Filter with OData $filter — No AL Code Needed
GET .../currencyExchangeRates?$filter=startingDate ge 2026-01-01 GET .../currencyExchangeRates?$filter=currencyCode eq 'USD'
OData $filter, $top, $skip, and $orderby work automatically on every field in your repeater. You do not need to write any filter code in AL
Common Mistakes to Avoid:
- Duplicate APIPublisher + APIGroup + APIVersion: each combination must be unique across all API pages in your app.
- Spaces in EntityName / EntitySetName: use camelCase only. ‘salesOrder’ is correct; ‘Sales Order’ will fail.
- Hardcoded strings in Error/Message: always use Label variables. AppSource validation will reject hardcoded strings.
- Forgetting ODataKeyFields: without this, callers cannot request a single record.
- No error handling on HttpClient.Get(): networks fail — always check the return value and handle errors gracefully.
API page vs List page — comparison
| API Page (PageType = API) | List Page (PageType = List) | |
| Who uses it | External systems, scripts, apps | BC users in the web client |
| Access method | HTTP GET/POST/PATCH/DELETE | Browser — BC web client |
| Output format | JSON (OData v4) | HTML table in BC |
| Has visual UI | No | Yes |
| Can have action buttons | No — actions are never rendered | Yes — visible in ribbon |
| Port | 7047 (OData port) | 8080 (Web Client port) |
| URL pattern | /api/publisher/group/version/entity | /BC/WebClient/?page=XXXXX |
Why Use a Business Central API
API pages in Business Central let you share your data with other systems without showing anything on the screen. There’s no UI, no buttons — just a clean endpoint that other tools can call to get data as JSON.
When you create a page with PageType = API, you basically open a door for apps like Power BI, Power Automate, Excel, or external systems to access your data directly.
But API pages are not only for reading data. They can also run logic and automate tasks. For example, you can fetch data from another system, process it, and store it in Business Central — all through one API call.
In simple terms, an API page is not just a way to expose data — it’s a way to connect Business Central with the rest of the world and make it work smarter.