Azure Functions and Business Central: syncing a scheduler both ways
By Fred Morin ·
Business version (in French): Deux systèmes qui doivent dire la même chose : synchroniser Business Central avec Azure Functions
In the previous post I described a drag-and-drop resource scheduler that ran inside a Business Central page. This one covers the plumbing behind it: how a move made in the scheduler reached Business Central, and how the two stayed in sync, using Azure Functions.
The data: bookings tied to sales order lines
In Business Central, each booking was linked to a sales order line with a resource on it. That's a natural fit: the work being scheduled is what ends up on the invoice, so the booking and the billable line stay connected.
Two kinds of data carried the schedule:
- a schedule table in the extension, holding each booking's start and end time;
- a few custom fields added to the Sales Line through a table extension, for the scheduling values that belonged with the order line itself.
When a user dragged a booking to a new time, what had to change in Business Central was the start and end time in that schedule table.
Why Azure Functions in the middle
The scheduler (a Blazor app) could in principle call Business Central directly. A layer of Azure Functions in between is a good place for everything that talks to Business Central: authentication, retries, logging, and the translation between what the scheduler knows and what the Business Central API expects. It also sat alongside the rest of the Azure side of the system, which handled the sync with the Xamarin field app the vets used on the road.
Scheduler → Functions → Business Central
When a booking moved, the Blazor app called an HTTP-triggered Function, which called the extension's custom API to update the start and end time. A simplified version, not the original code:
public record BookingMove(Guid ScheduleEntryId, DateTime Start, DateTime End);
public class MoveBooking(BusinessCentralClient bc)
{
[Function("MoveBooking")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "bookings/move")] HttpRequestData req)
{
var move = await req.ReadFromJsonAsync<BookingMove>();
if (move is null) return req.CreateResponse(HttpStatusCode.BadRequest);
await bc.UpdateScheduleAsync(move);
return req.CreateResponse(HttpStatusCode.NoContent);
}
}
And the call into Business Central, a PATCH on one record of the custom API page:
public async Task UpdateScheduleAsync(BookingMove move)
{
using var request = new HttpRequestMessage(HttpMethod.Patch, $"{_apiBase}/scheduleEntries({move.ScheduleEntryId})")
{
Content = JsonContent.Create(new { startDateTime = move.Start, endDateTime = move.End })
};
// Business Central requires If-Match on updates. "*" means "whatever
// version is there"; see the note on concurrency below.
request.Headers.IfMatch.Add(EntityTagHeaderValue.Any);
using var response = await _http.SendAsync(request);
response.EnsureSuccessStatusCode();
}
Business Central → Functions
Traffic went both ways. AL code in Business Central called the Functions too, over plain HTTP. In AL that's the built-in HttpClient, with the Function key sent in the x-functions-key header. For example:
procedure NotifyScheduler(ScheduleEntry: Record "Schedule Entry")
var
Setup: Record "Scheduler Setup";
Client: HttpClient;
Content: HttpContent;
Headers: HttpHeaders;
Response: HttpResponseMessage;
Body: JsonObject;
BodyText: Text;
FunctionKey: Text;
begin
if not Setup.Get() or not Setup.Enabled then
exit;
// Secrets go in IsolatedStorage, not in a table field users can read.
if not IsolatedStorage.Get('SchedulerFunctionKey', DataScope::Module, FunctionKey) then
Error('The scheduler key is not set up.');
Body.Add('scheduleEntryId', Format(ScheduleEntry.SystemId, 0, 4).ToLower());
Body.Add('start', ScheduleEntry."Start DateTime");
Body.Add('end', ScheduleEntry."End DateTime");
Body.WriteTo(BodyText);
Content.WriteFrom(BodyText);
Content.GetHeaders(Headers);
Headers.Remove('Content-Type');
Headers.Add('Content-Type', 'application/json');
Client.DefaultRequestHeaders.Add('x-functions-key', FunctionKey);
if not Client.Post(Setup."Functions URL" + '/api/bookings/changed', Content, Response) then
Error('The scheduler could not be reached.');
if not Response.IsSuccessStatusCode then
Error('The scheduler returned %1.', Response.HttpStatusCode);
end;
Timers for everything that isn't urgent
Not everything needs to happen the instant a user clicks. Alongside the HTTP triggers, timer-triggered Functions ran on a schedule to keep the two sides aligned: the kind of background sync where a few minutes of delay doesn't matter.
[Function("SyncSchedule")]
public async Task Run([TimerTrigger("0 */5 * * * *")] TimerInfo timer)
{
// Every five minutes: pull what changed on each side and reconcile.
await _sync.RunAsync();
}
The split is a useful rule of thumb: HTTP when a person is waiting on the result, timers when nobody is.
How it performed
It was pretty fast. A drag and drop in the scheduler made the round trip through the Function to Business Central's API without users waiting on it.
Worth knowing if you build this
General advice, not a list of problems I ran into:
- Concurrency.
If-Match: *overwrites whatever is there. If two people can edit the same booking, send the record's@odata.etaginstead, and handle the412Business Central returns when someone got there first. - Authentication to Business Central. For Business Central online, use a Microsoft Entra app registration with service-to-service access, and keep its secret in Azure Key Vault or the Function's app settings, never in code.
- Custom fields and released orders. Many standard Sales Line fields refuse changes once an order is released, because their validation calls
TestStatusOpen. Your own fields don't do that unless you add it: decide on purpose whether a booking may move on a released order. - Idempotency. Timers and retries mean the same change can arrive twice. Make every update safe to apply again.
- Logging. When a sync goes wrong, the Functions are the one place that saw both sides. Log enough there to reconstruct what happened.
If you have two systems that should agree and don't, let's talk.