Email this post as a PDFNewDevelopment · 6 November 2026 · 19 min read

AL development from scratch, part 9: API pages and API queries

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.

Contents
  1. First: check the standard APIs
  2. API pages compared with ordinary pages
  3. The API page properties
  4. Naming the fields
  5. The Pallet Type API page
  6. Adding an action to the API
  7. Parent and child entities
  8. Where the API lives: the URL
  9. Signing in: authentication for a system
  10. Calling the API: the five requests
  11. Asking for less: filters and query options
  12. API queries: read-only, joined data
  13. Versioning: never break v1.0
  14. Webhooks: being told instead of asking
  15. Testing your API before anyone else does
  16. The other direction: sending a PATCH from AL
  17. Common mistakes
  18. What we have built

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.

First: check the standard APIs

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:

  • the data is your own (like our pallet types), so no standard API exists;
  • you need fields the standard API does not expose, such as fields from your table extensions;
  • you need a shape the standard API does not give you, such as a flat, joined, read-only view for reporting.

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.

API pages compared with ordinary pages

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:

  • It never appears in the browser. Nobody opens it; it exists only as an endpoint.
  • Its field names become JSON property names, so they follow API naming rules, not user-interface rules.
  • It carries publisher, group and version properties, which become part of the URL.
  • It is keyed on SystemId, the GUID every table has, rather than on your primary key (see system fields).
  • Captions, tooltips, ApplicationArea and promoted actions mean nothing to it.

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.

The API page properties

These are the properties you set on an API page. Most are required; the rest you should set deliberately.

PropertyWhat it doesExample / default
PageTypeMakes the page an API page.PageType = API;
APIPublisherThe publisher segment of the URL. Use something that identifies you, lowercase, no spaces.APIPublisher = 'bbx';
APIGroupGroups related APIs under one segment of the URL, such as one per app or business area.APIGroup = 'pallets';
APIVersionThe version segment. Can hold more than one version if the page serves several unchanged.APIVersion = 'v1.0';
EntityNameThe singular name of one record, used in metadata.EntityName = 'palletType';
EntitySetNameThe plural name used in the URL for the collection.EntitySetName = 'palletTypes';
EntityCaptionA readable singular name, used by tools such as Power Automate and Power BI when they list your entity.EntityCaption = 'Pallet Type';
EntitySetCaptionA readable plural name for the same tools.EntitySetCaption = 'Pallet Types';
SourceTableThe table the API reads and writes.SourceTable = "BBX Pallet Type";
ODataKeyFieldsWhich field identifies a record in the URL. Use SystemId so the key never changes, even if someone renames the code.ODataKeyFields = SystemId;
DelayedInsertWaits 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;
ExtensibleWhether 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 / DeleteAllowedTurn off POST, PATCH or DELETE for the whole entity. A read-only API sets all three to false.DeleteAllowed = false; (defaults true)
EditableSet to false to make the whole page read-only.Editable = false;
ChangeTrackingAllowedAllows change tracking on the entity, which webhook notifications rely on.ChangeTrackingAllowed = true;
DataAccessIntentSet 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;
SourceTableTemporaryMakes 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.

Naming the fields

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:

  • camelCase. Start with a lowercase letter, capitalise each following word: maxWeightKg, unitCost, lastModifiedDateTime.
  • Letters and digits only. No spaces, full stops, brackets or underscores. "Max. Weight (kg)" becomes maxWeightKg. The compiler warns you if you break this.
  • Follow Microsoft's names where they exist. Every standard API calls the key id, the change timestamp lastModifiedDateTime, a description description or displayName. People integrating with you will expect the same.
  • Include units where a number needs them. lengthMm is better than length, because the person reading the JSON can't see your table.

The Pallet Type API page

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:

  • The layout is a single repeater. API pages always use area(Content) with one repeater. The same page serves the collection (a GET of all records) and a single record (a GET by id).
  • Captions are locked. API captions are technical names, not text a user reads in the client, so they are not translated. That is why Locked = true is right here, when everywhere else in the app we translate.
  • id and lastModifiedDateTime are read-only. The platform sets SystemId and SystemModifiedAt; nobody should be able to write them.
  • The enum comes out by name. The material field returns the enum value's name, such as "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.
  • No FlowField. I have left out "No. of Items". You can expose FlowFields on an API page, and the platform calculates them, but each one costs a calculation per record on every GET. Expose them only when a caller actually needs them.

Validation still happens

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.

Doing something when a record is created

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.

Adding an action to the API

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.

Parent and child entities

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.

Where the API lives: the URL

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
  • {tenantId} is the Microsoft Entra tenant ID (a GUID) or the tenant's primary domain name.
  • {environmentName} is the environment's name as it appears in the admin centre, such as Production or Sandbox.
  • {companyId} is the company's id, a GUID. Get it by calling the companies endpoint of your API: .../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.

Signing in: authentication for a system

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:

  1. Register the app in Microsoft Entra ID. In the Azure portal, open Microsoft Entra ID, then App registrations, then New registration. Give it a name that says what it is, such as "Warehouse system - Business Central API".
  2. Add API permissions. Add a permission for Dynamics 365 Business Central, choose Application permissions, and select API.ReadWrite.All. Then grant admin consent for the tenant.
  3. Create a client secret (or, better, upload a certificate). Note the secret's expiry date in somebody's diary; an expired secret is one of the most common reasons an integration stops working overnight.
  4. Register the app in Business Central. In Business Central, search for the Microsoft Entra Applications page and create a new card. Enter the Client ID from the app registration, give it a description, set the State to Enabled, and in the user permission sets section assign only the permission sets the integration needs. For our warehouse system, that is "BBX Pallet Edit" from part 10, plus whatever it needs from the standard app. Then use the Grant Consent action on the card.
  5. Get a token and call the API. The integration asks Microsoft Entra ID for a token, then sends it with every request.

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.

Calling the API: the five requests

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}).

Read all pallet types (GET)

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.

Read one pallet type

GET {base}/palletTypes({palletTypeId})

The id goes in brackets, without quotes, because it is a GUID.

Create a pallet type (POST)

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.

Change a pallet type (PATCH)

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:

  • The If-Match header is required. A PATCH without one is rejected, so a caller has to read the record (or use the etag from its last response) before it can change it.
  • On a 412, read again and retry. GET the record, check whether your change still makes sense against what someone else did, and apply it again with the new etag. Don't "fix" 412s by switching to If-Match: *; that just hides the conflict.
  • Use PATCH, not PUT, to update records. PATCH changes only the fields you send, and every field you send is validated as if a user typed it, in the order you send them. If one field's validation depends on another, send them in that order. PATCH is also how files are uploaded to content properties, such as an item's picture, with Content-Type: application/octet-stream.

Delete a pallet type (DELETE)

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.

Asking for less: filters and query options

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:

OptionWhat it doesExample
$filterReturns only the records that match.?$filter=blocked eq false and material eq 'Wood'
$selectReturns only the fields you name. Smaller responses, and it can be faster.?$select=id,code,description
$orderbySorts the results.?$orderby=code asc
$topReturns at most this many records.?$top=50
$skipSkips this many records first.?$skip=50
$expandIncludes child entities (parts) in the same response.salesOrders?$expand=salesOrderLines
$countAdds 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.

Paging

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.

API queries: read-only, joined data

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.

Versioning: never break v1.0

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.

  • Safe in the same version: adding a new field, adding a new action, fixing a bug in validation, improving performance.
  • Not safe: renaming a field, removing a field, changing a field's type or meaning, changing the key, making an optional field mandatory.

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.

Webhooks: being told instead of asking

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.

Testing your API before anyone else does

  1. Publish to a sandbox and get a token for an app registration that has only the permission sets you plan to give the real integration. Testing as yourself, with SUPER, proves nothing about permissions.
  2. GET the metadata (.../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.
  3. Try all five requests, including a PATCH with a stale etag (you should get 412) and a POST that breaks a validation rule (you should get 400 with a readable message).
  4. Load enough data to force paging and check that following nextLink returns everything exactly once.
  5. Write the requests down in a .http file in the repository, so the next developer can run them too.

The other direction: sending a PATCH from AL

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.

Common mistakes

  • Building a custom API for data the standard v2.0 APIs already serve. You then own its maintenance for ever.
  • Forgetting DelayedInsert. Records get inserted half-filled, and validation that depends on several fields fails in confusing ways.
  • Using the primary key as the OData key. Use ODataKeyFields = SystemId, so renaming a code doesn't break every integration that stored it.
  • Field names with spaces or capitals at the start. Follow camelCase and Microsoft's naming from the start; renaming later is a breaking change.
  • Putting business rules in the API page. Put them in the table or a codeunit, so the card page and the API behave the same way.
  • Giving the integration SUPER, or a person's account. Use its own app registration with narrow permission sets.
  • Ignoring etags. Sending If-Match: * everywhere means two systems can overwrite each other's changes without anyone knowing.
  • Ignoring @odata.nextLink. The integration works in test and loses records in production.
  • Changing a published version. Add v2.0; don't edit v1.0.
  • Exposing every FlowField "just in case". Each one is a calculation per record per call.
  • Letting the client secret expire unnoticed. Diarise it, or use a certificate with a longer life and a renewal plan.

What we have built

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.

Email this post as a PDF
A branded PDF of this post, sent to your inbox to keep, print or share. Copy in colleagues if you like.
We only use these addresses to send this PDF. Nobody is added to a mailing list. Mike at Business Boxed receives a copy. See our privacy notice.
Email this post
Talk to us
Want this done in your Business Central?
Book a 30 minute callGet the monthly newsletter

Related posts