Bramvia — Business Central Experts

How to build a custom API in Business Central with AL: page, auth, filters, versioning and the mistakes that break integrations

The standard APIs cover the common entities. For your custom fields, your tables and your logic you need an API page in AL. Full working code, Entra app registration for service-to-service auth, $filter/$select/$expand, ETags on updates, versioning — and the five mistakes that break integrations at the first update.

Short answer: when the standard Business Central API doesn't expose what your integration needs — a custom field, a custom table, a calculated value, your own validation — you publish an API page in AL. It's a page with PageType = API, an APIPublisher, APIGroup and APIVersion, and a repeater of fields. Business Central turns it into a versioned REST/OData endpoint under /api/{publisher}/{group}/{version}/, authenticated with Microsoft Entra, with $filter, $select, $expand, ETags and batch for free. Below: the full code, the auth setup, the URL you actually call, and the five mistakes that make integrations break at the next update.

1. The page

Minimal, working, and versioned from day one:

page 50100 "BRV Customer API"
{
    PageType = API;
    APIPublisher = 'bramvia';
    APIGroup = 'sales';
    APIVersion = 'v1.0';
    EntityName = 'customer';
    EntitySetName = 'customers';
    SourceTable = Customer;
    DelayedInsert = true;
    ODataKeyFields = SystemId;
    Extensible = false;

    layout
    {
        area(Content)
        {
            repeater(Group)
            {
                field(id; Rec.SystemId) { Caption = 'Id'; Editable = false; }
                field(number; Rec."No.") { Caption = 'Number'; }
                field(displayName; Rec.Name) { Caption = 'Display Name'; }
                field(email; Rec."E-Mail") { Caption = 'Email'; }
                field(creditLimit; Rec."Credit Limit (LCY)") { Caption = 'Credit Limit'; }
                field(customerGroup; Rec."BRV Customer Group") { Caption = 'Customer Group'; } // your custom field
                field(lastModified; Rec.SystemModifiedAt) { Caption = 'Last Modified'; Editable = false; }
            }
        }
    }
}

Four properties matter more than the rest:

2. The URL you call

https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/bramvia/sales/v1.0/companies({companyId})/customers

Example calls:

GET  .../customers?$filter=customerGroup eq 'WHOLESALE'&$select=id,number,displayName
GET  .../customers?$filter=lastModified gt 2026-09-01T00:00:00Z         ← incremental sync
GET  .../customers?$top=100&$skip=200                                    ← paging
GET  .../customers({id})?$expand=salesOrders                             ← only if you define the subpage
POST .../customers                   body: { "displayName": "Acme", "customerGroup": "WHOLESALE" }
PATCH .../customers({id})            header: If-Match: {etag}   body: { "creditLimit": 50000 }
DELETE .../customers({id})           header: If-Match: {etag}

The lastModified filter is the single most useful line here. It turns "sync everything every five minutes" into "give me what changed since my last run" — which is the difference between an integration that scales and one that gets throttled.

3. Authentication: service-to-service, no user in the middle

Integrations should not run as a person. They run as an application:

  1. Entra ID → App registrations → New registration. Single tenant. Note the Application (client) ID. Create a client secret and store it in your integration's secret store, not in a config file.
  2. API permissions → Add → Dynamics 365 Business Central → Application permissions → API.ReadWrite.All (or API.Read.All for read-only). Grant admin consent.
  3. In Business Central → search "Microsoft Entra Applications" → New. Paste the client ID, set State = Enabled, and assign permission sets: D365 BUS FULL ACCESS is lazy; give it the permission sets for the tables your API touches plus D365 READ — least privilege applies here too.
  4. Token: POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token with client_credentials, scope=https://api.businesscentral.dynamics.com/.default. Use the bearer token on every call.

If step 3 is skipped you get 401 with a valid token, which is the error people spend an afternoon on.

4. Updates: use the ETag or you will overwrite someone

Every GET returns an @odata.etag. Send it back as If-Match on PATCH and DELETE. If the record changed in between, you get 412 Precondition Failed instead of silently overwriting a change a user made ten seconds earlier. Integrations that omit If-Match are the source of "the system changed my data" tickets.

5. Sub-entities and $expand

Lines belong under their header. Define a second API page for the lines with SourceTable = "Sales Line" and link it as a part:

part(salesOrderLines; "BRV Sales Order Line API")
{
    EntityName = 'salesOrderLine';
    EntitySetName = 'salesOrderLines';
    SubPageLink = "Document No." = field("No.");
}

Then GET .../salesOrders({id})?$expand=salesOrderLines returns header and lines in one call, and a POST with nested lines creates both. Without this, consumers make N+1 calls and you get throttled.

6. Business logic: events, not page code

Validation belongs on the table or in a codeunit, triggered by events — not in the page triggers. An API page is one consumer among several (the UI, other APIs, batch jobs). Put the rule where every path hits it:

[EventSubscriber(ObjectType::Table, Database::Customer, 'OnBeforeInsertEvent', '', false, false)]
local procedure ValidateCustomerGroup(var Rec: Record Customer)
begin
    if Rec."BRV Customer Group" = '' then
        Error('Customer group is mandatory.');
end;

The API then returns a clean 400 with your message, and the UI enforces the same rule.

7. Versioning: the contract you sign with every consumer

APIVersion = 'v1.0' is not decoration. When you need a breaking change — rename a field, change a type, remove one — you publish v2.0 as a new page and keep v1.0 running until every consumer has moved. Adding a field is not breaking; removing or renaming is. Treat it like a public API, because it is one.

The five mistakes that break integrations

Mistake What happens Do instead
Keying on "No." Renumbering or a merge breaks every consumer ODataKeyFields = SystemId
Pulling full entities every run Throttling (429), slow syncs, complaints $filter on lastModified, $select only what you need
Logic in page triggers The rule works via API but not via UI, or vice versa Table events or a codeunit both paths call
No If-Match on PATCH Silent overwrites of concurrent user edits Send the ETag; handle 412
Editing v1.0 in place Every consumer breaks on your deploy New version page; deprecate the old one on a schedule

And one that isn't code: no direct SQL, ever. If a NAV-era integration reads tables, it does not migrate — it gets rebuilt on exactly this pattern. Why, and what else changed.

Testing without breaking production

FAQ

Standard API or custom page? Standard first, always. Write a custom page only for what the standard doesn't expose. Most integrations end up with the standard API for masters and one or two custom pages for the specific bits.

Can I expose a custom table? Yes — same pattern, SourceTable pointing at your table. Give it a SystemId key and you're done.

How do I handle 20,000 records? $filter on lastModified, $top with $skip or @odata.nextLink, and a query-based page. Never $expand on a list of thousands.

What about webhooks? Subscribe to your API entity via the standard subscriptions endpoint and you get notified on changes instead of polling. Same page, no extra code.

Is this what your MCP server uses? Ours reads public content, but the pattern for an MCP over a company's own Business Central is exactly this: API pages plus permissions, exposed as tools. How that works.

Need an integration built on this pattern, or a NAV-era SQL integration rebuilt for the cloud? Free assessment, no commitment — first reply within one working day.


Bramvia · bramvia.net