How to let other systems read and write your Business Central data safely: when to use the standard v2.0 APIs, how to build an API page and an API query, every property that matters, authentication, real requests, paging and versioning.
This is part 9 of AL development from scratch. So far the Pallet Manager app has a table, pages, a table extension on Item, codeunits, a report, a query and an XMLport. Everything we have built is for people using Business Central in the browser. This part is about the other kind of user: another system. A warehouse system that needs to know our pallet types, a Power Automate flow, a web portal, a data warehouse.
In Business Central online, the way other systems talk to your data is through APIs: REST endpoints that return and accept JSON, built on OData version 4. You build them in AL as API pages (read and write) and API queries (read-only, joined data). By the end of this part, Pallet Manager will have an API that a warehouse system can call to list, create, change and delete pallet types.
Before you build anything, check whether Microsoft has already built it. Business Central ships a large set of standard APIs under version v2.0: customers, vendors, items, contacts, sales orders and invoices, purchase orders and invoices, journals, general ledger entries, dimensions, employees, attachments, and many more. They are documented, supported, kept stable across releases, and they already handle the awkward parts (document lines, posting, number series, validation order).
The standard API base URL is:
https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environmentName}/api/v2.0/companies({companyId})/items
If what you need is "read customers" or "create a sales order", use the standard API. Build your own only when:
Our pallet types are our own table, so a custom API it is. And because the item's default pallet type lives in a table extension on Item, the standard items API won't show it, which is a good reason for an API query later on.
An API page is a page object with PageType = API. It has a source table, a layout with fields, and triggers, just like the list and card pages from part 5. The differences are:
SystemId, the GUID every table has, rather than on your primary key (see system fields).Before API pages, the usual approach was to publish an ordinary list or card page as an OData web service from the Web Services page. That still works, but it ties your integration to a page that users see and that someone may change, and the URLs carry the page name. For anything new, use API pages.
These are the properties you set on an API page. Most are required; the rest you should set deliberately.
| Property | What it does | Example / default |
|---|---|---|
| PageType | Makes the page an API page. | PageType = API; |
| APIPublisher | The publisher segment of the URL. Use something that identifies you, lowercase, no spaces. | APIPublisher = 'bbx'; |
| APIGroup | Groups related APIs under one segment of the URL, such as one per app or business area. | APIGroup = 'pallets'; |
| APIVersion | The version segment. Can hold more than one version if the page serves several unchanged. | APIVersion = 'v1.0'; |
| EntityName | The singular name of one record, used in metadata. | EntityName = 'palletType'; |
| EntitySetName | The plural name used in the URL for the collection. | EntitySetName = 'palletTypes'; |
| EntityCaption | A readable singular name, used by tools such as Power Automate and Power BI when they list your entity. | EntityCaption = 'Pallet Type'; |
| EntitySetCaption | A readable plural name for the same tools. | EntitySetCaption = 'Pallet Types'; |
| SourceTable | The table the API reads and writes. | SourceTable = "BBX Pallet Type"; |
| ODataKeyFields | Which field identifies a record in the URL. Use SystemId so the key never changes, even if someone renames the code. | ODataKeyFields = SystemId; |
| DelayedInsert | Waits until all the fields in the request have been set before inserting the record. API pages need it, otherwise the record is inserted after the first field and the rest become modifications. | DelayedInsert = true; |
| Extensible | Whether other extensions can add fields with a page extension. Set it to false: your API's shape should be decided by you, in your versioned page. | Extensible = false; (default true) |
| InsertAllowed / ModifyAllowed / DeleteAllowed | Turn off POST, PATCH or DELETE for the whole entity. A read-only API sets all three to false. | DeleteAllowed = false; (defaults true) |
| Editable | Set to false to make the whole page read-only. | Editable = false; |
| ChangeTrackingAllowed | Allows change tracking on the entity, which webhook notifications rely on. | ChangeTrackingAllowed = true; |
| DataAccessIntent | Set to ReadOnly on a read-only API so that requests can be served from a read-only copy of the database, which takes load off the main one. | DataAccessIntent = ReadOnly; |
| SourceTableTemporary | Makes the source table temporary, for APIs that build their records in code rather than reading them straight from a table. | SourceTableTemporary = true; |
Together, publisher, group and version decide the URL: /api/bbx/pallets/v1.0/companies({companyId})/palletTypes. Publisher, group and version together must be unique to you, which is why the publisher should not be something generic like api or custom.
On an ordinary page, a field's control name hardly matters. On an API page, the control name is the JSON property name. The rules:
maxWeightKg, unitCost, lastModifiedDateTime.maxWeightKg. The compiler warns you if you break this.id, the change timestamp lastModifiedDateTime, a description description or displayName. People integrating with you will expect the same.lengthMm is better than length, because the person reading the JSON can't see your table.Here is the complete page. Add it to the project as src/api/PalletTypeAPI.Page.al. Like every complete listing in this series, it starts with our namespace, BusinessBoxed.PalletManager. Namespaces are optional: leave the line out and the page goes into the global namespace and works exactly as before. We show them because they are the modern standard Microsoft uses in its own code, and the choice is yours (see part 1 for the full explanation). The namespace is an AL code organisation feature; the API's URL comes from the API properties below, not from the namespace.
namespace BusinessBoxed.PalletManager;
page 50103 "BBX Pallet Type API"
{
PageType = API;
APIPublisher = 'bbx';
APIGroup = 'pallets';
APIVersion = 'v1.0';
EntityName = 'palletType';
EntitySetName = 'palletTypes';
EntityCaption = 'Pallet Type';
EntitySetCaption = 'Pallet Types';
SourceTable = "BBX Pallet Type";
ODataKeyFields = SystemId;
DelayedInsert = true;
Extensible = false;
ChangeTrackingAllowed = true;
layout
{
area(Content)
{
repeater(Records)
{
field(id; Rec.SystemId)
{
Caption = 'Id', Locked = true;
Editable = false;
}
field(code; Rec.Code)
{
Caption = 'Code', Locked = true;
}
field(description; Rec.Description)
{
Caption = 'Description', Locked = true;
}
field(material; Rec.Material)
{
Caption = 'Material', Locked = true;
}
field(lengthMm; Rec."Length (mm)")
{
Caption = 'Length (mm)', Locked = true;
}
field(widthMm; Rec."Width (mm)")
{
Caption = 'Width (mm)', Locked = true;
}
field(maxWeightKg; Rec."Max. Weight (kg)")
{
Caption = 'Max. Weight (kg)', Locked = true;
}
field(unitCost; Rec."Unit Cost")
{
Caption = 'Unit Cost', Locked = true;
}
field(blocked; Rec.Blocked)
{
Caption = 'Blocked', Locked = true;
}
field(lastModifiedDateTime; Rec.SystemModifiedAt)
{
Caption = 'Last Modified Date Time', Locked = true;
Editable = false;
}
}
}
}
}
Some points about that code:
area(Content) with one repeater. The same page serves the collection (a GET of all records) and a single record (a GET by id).Locked = true is right here, when everywhere else in the app we translate."Wood" or "Plastic", and accepts the same names on the way in. That is one more reason, from part 4, to give enum values sensible names and never rename them once they are in use.Writing through an API page runs the same table logic as typing on the card page. When a caller PATCHes maxWeightKg, the field's OnValidate trigger from part 3 runs, and if it calls Error, the caller gets an HTTP 400 response with your error text in the body. That is exactly what you want: a rule enforced in one place, whether the change comes from a person or a system.
The order matters, though. Fields are validated in the order they appear on the API page, not the order they appear in the JSON. If one field's validation depends on another (a unit cost that checks the material, say), put the field it depends on higher up the page.
You can use page triggers on an API page in the same way as on any page. A common one is OnInsertRecord, to fill something in before the record is inserted:
trigger OnInsertRecord(BelowxRec: Boolean): Boolean
begin
if Rec.Material = Rec.Material::" " then
Rec.Material := Rec.Material::Wood;
end;
Keep this kind of logic small. Anything that matters to the business belongs in the table or a codeunit, so it applies however the record is created.
Sometimes a caller needs to do something that isn't just setting fields: post an invoice, release an order, block a pallet type and do some tidying up at the same time. For that, an API page can expose a bound action: a procedure the caller invokes with a POST against one record.
[ServiceEnabled]
[Scope('Cloud')]
procedure Block(var ActionContext: WebServiceActionContext)
var
PalletMgt: Codeunit "BBX Pallet Mgt.";
begin
PalletMgt.BlockPalletType(Rec);
ActionContext.SetObjectType(ObjectType::Page);
ActionContext.SetObjectId(Page::"BBX Pallet Type API");
ActionContext.AddEntityKey(Rec.FieldNo(SystemId), Rec.SystemId);
ActionContext.SetResultCode(WebServiceActionResultCode::Updated);
end;
The [ServiceEnabled] attribute exposes the procedure, [Scope('Cloud')] makes it callable in Business Central online, and the WebServiceActionContext tells the caller which record was affected and how. The caller invokes it like this:
POST https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environmentName}/api/bbx/pallets/v1.0/companies({companyId})/palletTypes({id})/Microsoft.NAV.block
The action appears in the API's metadata document (add $metadata to the end of the API's base URL), which is the place to check its exact name. The standard APIs follow the same pattern; posting a sales invoice is salesInvoices({id})/Microsoft.NAV.post.
The BlockPalletType procedure lives in our management codeunit, not on the page, so the card page's Block action and the API share one piece of logic. Notice that the API calls BlockPalletType, not the ConfirmAndBlockPalletType version from part 7: an API call has nobody to answer a Confirm or read a Message, so anything an API runs must not ask the user anything.
Documents need lines: an order with its order lines. An API page can include another API page as a part, which becomes a navigation property. The caller can then go from a parent record to its children, or fetch both in one request with $expand. If Pallet Manager later gained an API page listing the items that use each pallet type, the part on our page would look like this:
part(items; "BBX Pallet Item API")
{
Caption = 'Items', Locked = true;
EntityName = 'palletItem';
EntitySetName = 'items';
SubPageLink = "BBX Default Pallet Type" = field(Code);
}
The part goes inside the repeater, after the fields. The subpage is itself an API page with the same publisher, group and version. We don't need one for this app, but this is how the standard sales order API exposes its lines.
Once the app is published, the API is live. The URL for a custom API is built like this:
https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environmentName}/api/{publisher}/{group}/{version}/companies({companyId})/{entitySetName}
So for our pallet types in a sandbox called Sandbox:
https://api.businesscentral.dynamics.com/v2.0/{tenantId}/Sandbox/api/bbx/pallets/v1.0/companies({companyId})/palletTypes
.../api/bbx/pallets/v1.0/companies returns every company with its id and name.Data in Business Central belongs to a company, so almost every API call goes through companies({companyId}). Integrations commonly look up the company id once, by name, and store it.
A person signs in with their own account. A system (a warehouse app running unattended at 2am) needs its own identity, and in Business Central online that means a Microsoft Entra app registration using the OAuth 2.0 client credentials flow. Basic authentication with web service access keys is no longer available in Business Central online. The steps:
API.ReadWrite.All. Then grant admin consent for the tenant.The token request:
POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id={clientId}
&client_secret={clientSecret}
&scope=https://api.businesscentral.dynamics.com/.default
The response contains an access_token, valid for about an hour, which goes in the Authorization header of every API call: Authorization: Bearer {accessToken}.
The Business Central side is what controls access. The app registration only proves who the caller is; the permission sets on the Microsoft Entra Applications card decide what it can do. Give an integration exactly what it needs and nothing more, and give each integration its own app registration, so you can see who did what in the change log and switch one off without affecting the others.
These are raw HTTP requests, so you can see exactly what goes over the wire. Tools like Postman, the REST Client extension in VS Code, Power Automate's HTTP action and every programming language do the same thing. For readability, {base} stands for https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environmentName}/api/bbx/pallets/v1.0/companies({companyId}).
GET {base}/palletTypes
Authorization: Bearer {accessToken}
Accept: application/json
The response:
{
"@odata.context": "...",
"value": [
{
"@odata.etag": "W/\"JzE5OzEyMzQ1Njc4OTAxMjM0NTY3ODkxOzAwOyc=\"",
"id": "{palletTypeId}",
"code": "UK-STD",
"description": "UK standard pallet",
"material": "Wood",
"lengthMm": 1200,
"widthMm": 1000,
"maxWeightKg": 1000,
"unitCost": 12.5,
"blocked": false,
"lastModifiedDateTime": "2026-11-06T09:15:22.483Z"
}
]
}
Every record comes with an @odata.etag, a version stamp for that record. Keep it; you need it to change the record.
GET {base}/palletTypes({palletTypeId})
The id goes in brackets, without quotes, because it is a GUID.
POST {base}/palletTypes
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"code": "EURO",
"description": "Euro pallet",
"material": "Wood",
"lengthMm": 1200,
"widthMm": 800,
"maxWeightKg": 1500,
"unitCost": 11.75
}
The response is 201 Created with the full new record, including the id the platform assigned. Fields you leave out get their default values, just as if a user had created the record on the card.
PATCH {base}/palletTypes({palletTypeId})
Authorization: Bearer {accessToken}
Content-Type: application/json
If-Match: W/"JzE5OzEyMzQ1Njc4OTAxMjM0NTY3ODkxOzAwOyc="
{
"unitCost": 13.25
}
PATCH sends only the fields you are changing. The If-Match header carries the etag from when you read the record. If anyone has changed the record since, Business Central refuses with 412 Precondition Failed rather than letting you overwrite their change without knowing. This is optimistic concurrency, and it is how the API stops two systems silently overwriting each other. You can send If-Match: * to skip the check, but do that only when you genuinely don't care what you overwrite.
Three things worth knowing about PATCH in Business Central:
If-Match: *; that just hides the conflict.Content-Type: application/octet-stream.DELETE {base}/palletTypes({palletTypeId})
Authorization: Bearer {accessToken}
If-Match: W/"JzE5OzEyMzQ1Njc4OTAxMjM0NTY3ODkxOzAwOyc="
A successful delete returns 204 No Content. The table's OnDelete trigger runs, so if part 3's rule stops you deleting a pallet type that items still use, the caller gets that error instead.
Because the APIs are OData, callers can shape the response with query options instead of downloading everything and sorting it out themselves. The common ones:
| Option | What it does | Example |
|---|---|---|
| $filter | Returns only the records that match. | ?$filter=blocked eq false and material eq 'Wood' |
| $select | Returns only the fields you name. Smaller responses, and it can be faster. | ?$select=id,code,description |
| $orderby | Sorts the results. | ?$orderby=code asc |
| $top | Returns at most this many records. | ?$top=50 |
| $skip | Skips this many records first. | ?$skip=50 |
| $expand | Includes child entities (parts) in the same response. | salesOrders?$expand=salesOrderLines |
| $count | Adds the total number of matching records to the response. | ?$count=true |
One filter every integration should know is the one for "what has changed since I last looked":
GET {base}/palletTypes?$filter=lastModifiedDateTime gt 2026-11-05T00:00:00Z
That is why we exposed SystemModifiedAt. A nightly sync that asks only for records changed since the last run is far kinder to everyone than one that downloads the whole table every time.
Comparison operators in filters are eq, ne, gt, ge, lt and le, combined with and and or. Text values go in single quotes; numbers, booleans and dates don't.
Ask for a large table and you won't necessarily get it all in one response. Business Central returns a page of records, plus an @odata.nextLink property at the end of the response containing the URL for the next page:
{
"value": [ ... ],
"@odata.nextLink": "https://api.businesscentral.dynamics.com/v2.0/.../palletTypes?aid=FIN&$skiptoken=..."
}
The rule for the caller is simple: keep requesting the nextLink until a response arrives without one. An integration that ignores nextLink works perfectly in testing with fifty records, then quietly drops data in production with fifty thousand.
An API page is based on one table. Often a caller wants data from several tables joined together: for example, every item with its default pallet type's description and dimensions. You could make the caller fetch items and pallet types separately and join them, but it is much better to give them an API query.
A query object with QueryType = API is exposed in exactly the same way as an API page, under the same URL pattern, with the same publisher, group and version properties. It is always read-only. We met query objects in part 8; an API query is the same object with different properties and camelCase column names. Because it reads Microsoft's Item table, the listing has a using Microsoft.Inventory.Item; line under the namespace, so the compiler knows where to find Item.
namespace BusinessBoxed.PalletManager;
using Microsoft.Inventory.Item;
query 50101 "BBX Item Pallets API"
{
QueryType = API;
APIPublisher = 'bbx';
APIGroup = 'pallets';
APIVersion = 'v1.0';
EntityName = 'itemPallet';
EntitySetName = 'itemPallets';
EntityCaption = 'Item Pallet';
EntitySetCaption = 'Item Pallets';
DataAccessIntent = ReadOnly;
elements
{
dataitem(Item; Item)
{
column(itemNo; "No.") { }
column(itemDescription; Description) { }
column(unitsPerPallet; "BBX Units per Pallet") { }
dataitem(PalletType; "BBX Pallet Type")
{
DataItemLink = Code = Item."BBX Default Pallet Type";
SqlJoinType = InnerJoin;
column(palletTypeCode; Code) { }
column(palletDescription; Description) { }
column(lengthMm; "Length (mm)") { }
column(widthMm; "Width (mm)") { }
column(maxWeightKg; "Max. Weight (kg)") { }
}
}
}
}
This one uses ID 50101, so we need a second query ID from our range alongside the "BBX Items by Pallet Type" query from part 8. It is called like any other entity set:
GET {base}/itemPallets?$filter=palletTypeCode eq 'UK-STD'
The join happens in SQL, in one round trip, and the result is flat, which is exactly what Power BI, a data warehouse or a spreadsheet wants. With DataAccessIntent = ReadOnly, it can also be served from the read-only copy of the database, so heavy reporting doesn't slow down the people entering orders.
Use an API query when the caller only reads, needs data from more than one table, or needs totals (queries can group and sum, as in part 8). Use an API page when the caller needs to write, or needs actions.
Once an API is in use, somebody's integration depends on its exact shape. Rename a field, change its type, or remove it, and their integration breaks, often silently and usually at night. The rule is simple: a published API version never changes in a way that breaks a caller.
When you need a breaking change, create a new page with APIVersion = 'v2.0' (a new object, with its own ID) and leave the v1.0 page alone. Tell the people using v1.0, give them time to move, and only then mark the old page obsolete and eventually remove it. Microsoft did exactly this with the standard APIs: beta and v1.0 sat alongside v2.0 for years.
The APIVersion property can list several versions (APIVersion = 'v1.0', 'v2.0';) when one page serves both unchanged, which saves duplicating pages that haven't changed when you bump the version of the whole group.
This ties in with the wider point of writing extensions that survive release waves: your API is a contract, and contracts are kept.
Polling ("has anything changed?" every five minutes) works, but it is wasteful. Business Central also supports webhooks: a caller subscribes to an entity, and Business Central sends a notification to the caller's URL when records are created, changed or deleted. The caller then fetches what changed. Subscriptions are made through the subscriptions endpoint of the standard API, and expire after a few days unless renewed, so a webhook-based integration must renew them. That is why we set ChangeTrackingAllowed = true on our page. Webhooks deserve a post of their own; integrating with Business Central APIs done properly covers how to choose between polling, webhooks and business events, and business events and Power Automate covers the event-driven option.
.../api/bbx/pallets/v1.0/$metadata) and check every field name and type is what you intended. This is the contract the caller will see..http file in the repository, so the next developer can run them too.Sometimes Business Central is the caller: your code needs to update a record in another system's API. AL's HttpClient has Get, Post, Put and Delete methods, but no Patch, so you build a request message and set the method yourself. Here it is as a complete codeunit, telling a (fictional) warehouse system that a pallet type has been blocked:
namespace BusinessBoxed.PalletManager;
codeunit 50106 "BBX Warehouse Sync"
{
Access = Internal;
var
SendFailedErr: Label 'Could not reach the warehouse system. Check the connection and try again.';
CallFailedErr: Label 'The warehouse system rejected the update (status %1).', Comment = '%1 = HTTP status code';
procedure SendBlocked(PalletType: Record "BBX Pallet Type"; WarehouseUrl: Text)
var
Client: HttpClient;
Request: HttpRequestMessage;
Response: HttpResponseMessage;
Content: HttpContent;
ContentHeaders: HttpHeaders;
Body: JsonObject;
BodyText: Text;
begin
Body.Add('blocked', PalletType.Blocked);
Body.WriteTo(BodyText);
Content.WriteFrom(BodyText);
Content.GetHeaders(ContentHeaders);
ContentHeaders.Remove('Content-Type');
ContentHeaders.Add('Content-Type', 'application/json');
Request.Method := 'PATCH';
Request.SetRequestUri(WarehouseUrl + '/palletTypes/' + PalletType.Code);
Request.Content := Content;
if not Client.Send(Request, Response) then
Error(SendFailedErr);
if not Response.IsSuccessStatusCode() then
Error(CallFailedErr, Response.HttpStatusCode());
end;
}
The pattern is the same for any verb: build the content, set its Content-Type header on the content (not on the request), set Request.Method, and send. If the other system uses etags like Business Central does, add the If-Match header through Request.GetHeaders(). For timeouts, storing secrets such as API keys safely, and handling errors properly, see calling external APIs safely with HttpClient. In a real app the URL would live in a setup table rather than being passed around as text.
ODataKeyFields = SystemId, so renaming a code doesn't break every integration that stored it.If-Match: * everywhere means two systems can overwrite each other's changes without anyone knowing.Pallet Manager now has a versioned, secure API: palletTypes for reading and writing pallet types, with a block action, and itemPallets for a joined, read-only view that reporting tools will love. Any system that can make an HTTP request can now use pallet types, with every rule from our table enforced.
There is one loose end: we keep saying "give it the right permission sets", and we haven't built them yet. That is where the series ends, with everything you need to ship the app properly.
Previous: part 8, reports, report extensions, queries and XMLports. Next: part 10, permissions, install and upgrade codeunits, tests and shipping. Back to the series contents.
Want help with your first extension? Book a 30-minute call.