Imagine you order food at a crowded restaurant. You have two options. The first is to walk up to the kitchen every two minutes to ask whether your dish is ready, which annoys the cook and wastes your time. The second is for them to hand you one of those buzzing pagers: you sit down, wait calmly, and the gadget alerts you the moment there is news.
In the world of integrations, exactly the same thing happens. The first option is polling, that is, querying the Business Central API every X minutes to see whether something changed. The second is webhooks: it is BC that takes the initiative and notifies you when a record is created, modified, or deleted. This post explains, conceptually and with an example you can build yourself, how Business Central webhooks work on top of API Pages.
Companion repository: all the code from this article, the AL extension and the receiving Azure Function with its tests, lives at github.com/ivanrlg/webhooks-business-central. Clone it and follow along with the post.
TL;DR
- A webhook is not a different technology from the API: it is the same HTTP call with the initiative reversed. The real debate is push versus pull, and it is settled with two questions: how often the data changes and how stale you can afford to be.
- A webhook in BC is nothing more than a subscription to changes of an entity exposed as an API Page. When a record of that entity changes, BC makes an HTTP call to the URL you registered.
- You register it with
POST .../api/{publisher}/{group}/{version}/subscriptionsfor custom APIs (for Microsoft’s standard APIs it is usually.../api/v2.0/subscriptions). Before activating the subscription, BC validates your endpoint with a handshake: it sends you avalidationTokenand you must echo it back with a200 OK, ideally in under 5 seconds. - The notification does not carry the data that changed, only a pointer (the change type and the resource URL). For
createdandupdatedyou make aGETback to read the real data; fordeleted, the pointer simply identifies which record disappeared. - You can trigger a notification on demand from a button (by modifying the subscribed record) and, above all, cut the flow off immediately by cancelling the subscription with
DELETE. There is no native “pause”. - Subscriptions expire after 3 days and are renewed with
PATCH, which asks for the handshake again. If your endpoint fails, BC retries for 36 hours, but only on a408,429, or a5xx. On any other code, it deletes the subscription. - A webhook is not guaranteed delivery. The robust pattern is hybrid: the webhook propagates instantly and a lazy backup pull reconciles whatever was lost.
- As of 2026 release wave 1 (version 28), the v1.0 API is removed, so always use v2.0 and OAuth. And when you need precise control over which event fires and what payload travels, evaluate External Business Events (currently in preview) as an alternative.
Webhook or API? The question is wrong
A very common confusion is treating the webhook and the API call as two competing, distinct technologies. Mechanically they are the same thing: an HTTP request with a JSON inside. Look at the flow we will see in the diagram below: registering the subscription is an API call from you to BC, the notification is an API call from BC to your function, and reading the data is another API call from you in return. Everything is API. What changes is the direction of the initiative: who calls, when, and who pays the cost of asking.
That is why the real debate is not webhook versus API, but push versus pull. With pull, you ask: either right when you need the data (a direct call), or every so often (polling). With push, the source notifies you when something happens. And choosing between the two comes down to two variables: how often the data changes and how stale you can afford to be.
The webhook clearly wins in one very specific quadrant: the data changes rarely, but when it does you need to know right away. In that quadrant, pull is the worst of both worlds. If you ask often, you spend 99 percent of your calls hearing “still the same”. If you ask sparingly, you find out late.
Let’s see it with our own IoT scenario. Suppose the Azure Function must discard readings from suspended devices (under maintenance, or pulled from monitoring). A device’s state changes rarely, but when someone suspends it, continuing to process its readings is a mistake from the very first second. The pull path would be to query BC for the device’s state on every reading that comes in: it works, but it adds one call per reading, pays its latency on the hot path, and, with enough sensors reporting, ends up hitting the API’s request limits. The push path is to keep a local cache in the function with each device’s state, and a second webhook subscription on the devices entity that invalidates that cache as soon as a state changes. The hot path pays no extra call, and the change notice arrives on its own.
| Mechanism | Nature | Shines when | Fails when |
|---|---|---|---|
| Webhook | Reactive push | The data changes rarely but you must know right away; you want to decouple propagation from the read path | You treat it as guaranteed delivery with no safety net |
| Direct call on every operation | Synchronous pull | You need fresh data exactly at the moment of acting | You repeat it on every interaction against a source with request limits |
| Periodic polling | Deferred pull | Low volume and latency does not matter; operational simplicity | You ask frequently about something that almost never changes |
| Webhook plus backup pull | Hybrid | Almost always when correctness matters | Almost never; it is the sensible default |
Keep the last row in mind, because we will come back to it: a webhook is not guaranteed delivery, and that is why it should rarely work alone. But first, let’s build one end to end.
First things first: why an API Page and not a regular page?
In Business Central you can expose data to the outside in two ways, and the difference is precisely the one that enables webhooks.
A Card or List page can be published as an OData web service. It works, but it is designed for the user interface: it drags along presentation metadata, is heavier, and was not designed for machine-to-machine conversations. An API page (with PageType = API) is exactly the opposite: it returns clean JSON, is versioned through APIPublisher, APIGroup, and APIVersion, and is the only one BC’s webhook engine knows how to watch natively.
| Aspect | Regular page (Card/List) as OData | API Page (PageType = API) |
|---|---|---|
| Purpose | User interface, people first | Integration, machines first |
| Output format | OData with UI metadata | Clean, versioned JSON |
| Versioning | Not native | publisher/group/version in the URL |
| Supports webhooks | Not reliably | Yes, natively |
| Key for webhooks | Often problematic | ODataKeyFields = SystemId |
The practical takeaway is simple: if you want webhooks, expose the entity as an API Page with a simple key based on SystemId. We will see later why this last part is not optional.
The full lifecycle of a webhook in BC
Before writing a single line, it helps to have the full mental map. The following sequence diagram sums up the whole lifecycle in three phases: registration with its handshake, notification delivery, and the immediate cutoff (what I call the kill switch further down).
sequenceDiagram
participant S as Sensor (IoT)
participant BC as Business Central
participant JQ as Job Queue
participant AF as Azure Function
rect rgb(235,245,255)
Note over S,AF: Phase 1 - Subscription registration (handshake)
S->>BC: POST /subscriptions (notificationUrl, resource, clientState)
BC->>AF: GET ?validationToken=xxx
Note right of AF: Respond in under 5 s
AF-->>BC: 200 OK + validationToken (plain text)
BC-->>S: 201 Created (subscription active)
end
rect rgb(240,255,240)
Note over S,AF: Phase 2 - Notification delivery
S->>BC: INSERT a reading into the table
Note left of BC: Batches changes and waits ~30 s
BC->>JQ: Enqueues the send
JQ->>AF: POST notification (pointer only: changeType + URL)
AF-->>BC: 200 OK (validates clientState)
AF->>BC: GET the record (OAuth token)
BC-->>AF: 200 OK + full payload (deviceId, temperature)
end
rect rgb(255,238,238)
Note over S,AF: Phase 3 - Stop sending immediately (kill switch)
Note over BC: The user clicks "Stop sending" on a BC page
BC->>BC: DELETE /subscriptions(id) with If-Match
Note over BC,AF: Subscription deleted: BC stops generating new notifications
endNote: if your WordPress theme does not render Mermaid natively, paste this block into mermaid.live and export a PNG or SVG to insert it as an image.
The phase 1 handshake (in blue) is the heart of everything and the source of 90 percent of the headaches. BC does not trust a URL just because you hand it over: it tests it live. It calls you with a validationToken parameter in the query string and demands that you return it verbatim, in plain text, with a 200 OK, and fast. Microsoft’s documentation does not set a number, but the community’s practical reference, popularized by Kauffmann, is to respond in under five seconds, and the timeout error is very real. If your function cold-starts, if there is a slow proxy in the middle, or if you are slow to respond, validation expires and the subscription never even gets created. A detail many people overlook: this same handshake repeats on every renewal, so your receiver must always check, first thing, whether the request carries a validationToken.
Phase 2 (in green) is the day to day: a change in the table ends up turned into a POST toward your receiver. And phase 3 (in red), optional but key in many real scenarios, is the immediate cutoff of the subscription. We develop all three below.
Hands on: an IoT example you actually can try
Let’s build a realistic case that joins Business Central with IoT. The idea: a device (an ESP8266, for example) records temperature readings in BC through an API Page. Every time a new reading comes in, BC fires a webhook toward an Azure Function, which processes the data (it could raise an alert, write to another system, whatever you want).
Prerequisite. This example needs a Business Central to publish it on: a SaaS sandbox works, or the Docker with App Registration we set up in the previous post (a containerized OnPrem BC, with end-to-end Entra ID OAuth). If you followed that one, you already have the container, the S2S token, and the registered app; further down, in “Run it on your own Docker”, you will see the only two settings that make this same code point at your container instead of SaaS.
Step 1. The source table in AL
We start with a simple table that will store the readings.
table 50100 "Sensor Reading"
{
Caption = 'Sensor Reading';
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer)
{
Caption = 'Entry No.';
AutoIncrement = true;
}
field(2; "Device Id"; Code[20])
{
Caption = 'Device Id';
}
field(3; "Temperature"; Decimal)
{
Caption = 'Temperature';
DecimalPlaces = 1 : 2;
}
field(4; "Reading DateTime"; DateTime)
{
Caption = 'Reading Date Time';
}
field(5; "Processed"; Boolean)
{
Caption = 'Processed';
}
}
keys
{
key(PK; "Entry No.")
{
Clustered = true;
}
}
}
Step 2. The API Page that enables the webhook
Here is the key piece. Notice the four header properties (APIPublisher, APIGroup, APIVersion, EntityName/EntitySetName) and, above all, ODataKeyFields = SystemId.
page 50100 "Sensor Reading API"
{
PageType = API;
Caption = 'Sensor Reading API';
APIPublisher = 'ivansingleton';
APIGroup = 'iot';
APIVersion = 'v2.0';
EntityName = 'sensorReading';
EntitySetName = 'sensorReadings';
SourceTable = "Sensor Reading";
DelayedInsert = true;
ODataKeyFields = SystemId;
layout
{
area(Content)
{
repeater(General)
{
field(id; Rec.SystemId)
{
Caption = 'Id';
Editable = false;
}
field(deviceId; Rec."Device Id") { Caption = 'Device Id'; }
field(temperature; Rec.Temperature) { Caption = 'Temperature'; }
field(readingDateTime; Rec."Reading DateTime") { Caption = 'Reading Date Time'; }
field(processed; Rec.Processed) { Caption = 'Processed'; }
field(lastModifiedDateTime; Rec.SystemModifiedAt)
{
Caption = 'Last Modified Date Time';
Editable = false;
}
}
}
}
}
Why ODataKeyFields = SystemId is mandatory here, and not a style whim: BC’s webhook engine needs a simple, stable key to build the pointer to the record that changed. If you use a composite key, or none, the subscription to that entity simply will not work. This is one of those silent traps that cost you a whole afternoon. There are several scenarios where the webhook will not fire either: if the source table is temporary (SourceTableTemporary = true), if the API is declared as a query instead of a page, or if the source table is a system table. Keep it in mind when designing.
Step 3. Create the subscription
With the extension published, you can now register the webhook. An important nuance for custom APIs: the subscriptions endpoint lives on the same path as your API. For Microsoft’s standard APIs it would be api/v2.0/subscriptions, but for our custom API it is api/ivansingleton/iot/v2.0/subscriptions.
POST https://api.businesscentral.dynamics.com/v2.0/<tenantId>/<environment>/api/ivansingleton/iot/v2.0/subscriptions
Authorization: Bearer <oauth-token>
Content-Type: application/json
{
"notificationUrl": "https://my-receiver.azurewebsites.net/api/bc-webhook",
"resource": "api/ivansingleton/iot/v2.0/companies(<companyId>)/sensorReadings",
"clientState": "a-long-random-shared-secret"
}
Three fields deserve comment. The notificationUrl is your public receiver. The resource is the path relative to the entity set within your own API (you get the companyId from a prior GET to api/v2.0/companies). And the clientState is optional but highly recommended: it is a shared secret that BC will send back on every notification, and it lets you verify that the call really comes from your subscription and not from a third party who discovered the URL.
The instant you send this POST, BC calls your notificationUrl with the validationToken. If your receiver responds correctly, you get a 201 Created with the subscription data, including its subscriptionId and its expirationDateTime. Save that subscriptionId, because you will need it to renew and to cancel.
📷 Suggested image: the repo’s REST flow (
requests/subscriptions.http) run with the VS Code REST Client, showing the201 Createdof the registration with thesubscriptionIdin the response. It is the cleanest way to capture the registration without depending on the UI.
Run it on your own Docker (the one from the previous post)
The URLs above are the SaaS ones (https://api.businesscentral.dynamics.com/v2.0/<tenant>/<environment>/...). If you are going to use the container from the previous post, two things change and nothing else, because the companion repo left them configurable on the Sensor Webhook Setup page:
- API Base URL → your container’s root, for example
https://<container>:7048/BC. The rest of the path (/api/ivansingleton/iot/v2.0/...) is identical; on OnPrem there is no<tenant>/<environment>in the path. - OAuth Scope → the App ID URI of your app registration, for example
api://<tenant>.onmicrosoft.com/bcaad/.default. On a free tenant the SaaS resource is disabled and requesting a token against it returnsAADSTS500014; that is why we use the App ID URI as the resource, which is exactly the one the container accepts viaValidAudiences.
The authority (https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token) and the client-credentials flow are the same ones you already validated in the previous post: the token that works to read companies works just as well to create the subscription. Here is the POST against the container:
POST https://<container>:7048/BC/api/ivansingleton/iot/v2.0/subscriptions
Authorization: Bearer <oauth-token>
Content-Type: application/json
{
"notificationUrl": "https://your-https-receiver/api/bc-webhook",
"resource": "api/ivansingleton/iot/v2.0/companies(<companyId>)/sensorReadings",
"clientState": "a-long-random-shared-secret"
}
And a trap that bites right here: the notificationUrl must be HTTPS. Even locally, BC rejects the registration with 400 (Value specified in the notificationUrl must be a HTTPS service.) if you pass it an http://. For the handshake you need a receiver reachable over HTTPS from the container (the already-deployed Azure Function, or a tunnel such as dev tunnel/ngrok). The rest of the flow, token, reading sensorReadings, registering, listing, and deleting the subscription, behaves the same against the container as against SaaS.
📷 Suggested image: the repo’s Sensor Webhook Setup card in the web client, filled in with API Base URL, OAuth Scope, Tenant Id, Company Id, Client Id, Notification Url, and Client State (with the secret masked as Configured). It is the example’s “control panel”.
Checklist of real traps when running it on the container
I tested the example end to end against the container from the previous post (registration, delivery, GET-back, and kill switch) and, on top of the SaaS items, five traps came up that you do not see in the cloud. I leave them here in the order they usually appear:
- Anti-SSRF: the AL button cannot call its own container. When an AL action (
Create subscription,Renew,Cancel) makes thePOST/DELETEback tohttps://<container>:7048, the ALHttpClientblocks it: BC ships an anti-SSRF guard that rejects targets on a non-routable IP, and inside the lab<container>resolves to a private Docker IP (e.g.172.28.200.160). In the event viewer you will seeSSRF violation detected: non routable network address, theHttpClientgetsStatus code = 0, and AL throws “The subscription request could not be sent.” Lab fix: allowlist the container’s range,Set-BcContainerServerConfiguration -containerName <c> -keyName NavHttpClientAntiSSRFAllowedAddresses -keyValue '["172.16.0.0/12"]', (or setNavHttpClientAntiSSRFEnabledtofalse), which restarts the NST. It does not happen on SaaS nor on OnPrem with a routable address; nor does the REST path from an external client suffer it, because there it is not BC calling itself. (And if even with the allowlist in place it still returnsStatus code = 0, jump to the Extra below: the service tier may not have reloaded the key, and restarting it fixes it.)
📷 Suggested image: the
SSRF violation detected: non routable network addressevent in the container’s Event Viewer (before), and theNavHttpClientAntiSSRFAllowedAddresseskey already set inCustomSettings.config(after).
Before — the event in the container’s event viewer:
After — the allowlist already in CustomSettings.config:
-
Allow HttpClient Requests. For AL’s outbound calls (the buttons) to go out, enable Allow HttpClient Requests on the extension in Extension Management. Otherwise the request is not even attempted.
-
Trust the self-signed certificate. The container publishes HTTPS with a self-signed cert (
CN=<container>). The ALHttpClientvalidates the cert against the machine’s trust store and you cannot bypass that validation from AL; add that cert to the Trusted Root inside the container or the call fails on the TLS handshake. -
Task Scheduler enabled. On OnPrem, delivery goes out through the Job Queue / Task Scheduler. If the service tier has
EnableTaskScheduler=false, the subscription is created but delivers nothing. Enable it (Set-BcContainerServerConfiguration ... -keyName EnableTaskScheduler -keyValue true, restart the NST). Hint: if tasks do not start after enabling it, a service tier restart unsticks them. -
Permission set for the S2S app. The GET-back reads
sensorReadingswith the registered app’s token. A standard set like D365 BUS FULL ACCESS does not cover your custom objects, so theGETfails with “…permissions prevented the action (TableData 50100 Sensor Reading Read)”. The repo includespermissionset 50100 "Sensor Webhook"(RIMD on the tables,Xon pages and codeunit); assign it to the S2S app, and remove any temporarySUPER, to land on least privilege.
📷 Suggested image: the Sensor Readings page with the action ribbon (Create subscription / Renew / Mark as reviewed / Stop sending notifications) and the message “Subscription created. Id: …” after clicking Create subscription; and, optionally, the Setup card already showing Subscription Id and Expiration DateTime populated.
Extra: what came up the second time, debugging it live from VS Code
The five traps above came up the first time, running the example. When I went back to the lab to debug it from VS Code, publish with F5 and set breakpoints in the AL code to see the flow, a few more showed up. I leave them because they are exactly the ones that stop you when you want to understand why something will not work, and because one of them (c) is a twist on the anti-SSRF trap that took me a while to catch.
a) The development endpoint asks for NavUserPassword, not your AAD user. Even though the container’s web client signs in through Entra ID, the development endpoint (:7049/<instance>/dev) authenticates with NavUserPassword: the VS Code prompt takes the container’s local admin (admin + its password), not your AAD user. And if, after entering the credentials correctly, VS Code keeps repeating Unauthorized and “The credential cache has been cleaned” in the AL output, the extension is reusing a cached token in a bad state: run AL: Clear credentials cache in the command palette and publish again.
b) To publish+debug with F5, the app cannot be installed as Global. If you uploaded it with BcContainerHelper (Publish-BcContainerApp), it lands as a Global app; VS Code’s development publish is per-tenant, and BC refuses to have both at once: 422 Unprocessable Entity — "already deployed as a global application". Unpublish the Global one first (UnPublish-BcContainerApp -containerName <c> -name "<app>" -unInstall); since -unInstall by default keeps the data, when you reinstall the per-tenant one with F5 your setup comes back (including the secret in Isolated Storage, which you will see again as Configured). From there, F5 publishes, syncs, installs, and leaves the debugger attached: the breakpoints in Sensor Webhook Mgt. now hit.
c) Anti-SSRF, the twist: the allowlist was in place, but the service tier was not applying it. With the allowlist ["172.16.0.0/12"] already in CustomSettings.config (trap 1), the registration still failed with Status code = 0. The clue was in the container’s event viewer: event 701 SSRF violation detected: non routable network address, and its blocked IpAddress was the container’s own private IPv4 (172.28.200.160), which does fall inside 172.16.0.0/12. In other words: the rule was in the config, but the running service tier was not applying it. What unblocked it was restarting the service tier so it reloaded the configuration:
Restart-BcContainer -containerName <container>
After the restart, the anti-SSRF respects the allowlist and the POST goes through. Lesson: if the registration fails again with SSRF violation even with the key in the config (typical after an external restart of the container or the host), restart the NST and re-validate. Set-BcContainerServerConfiguration restarts the NST for you when you change the key; here the nuance was that the key was already there and the live process had not reloaded it. (And there is no shortcut pointing the API Base URL at the IP directly: the cert is CN=<container>, so the URL has to use the name or TLS validation fails.)
d) Function cold start → 400 on validation. The first registration against an Azure Function on a Consumption plan that had been asleep for a while returns 400 — "Service specified in the notificationUrl has not responded in time to the validation request.": the validation handshake (the window is a few seconds) gets eaten by the Function’s cold start. Warm it up first with a GET https://<your-function>/api/bc-webhook?validationToken=ping (it answers ping) and retry the registration right away, with the Function already awake.
e) 400 — "A subscription already exist…" is not an error. If you click Create subscription again with a subscription already alive, BC rejects the duplicate for the same notificationUrl + resource pair. It is duplicate protection, not a failure: use Renew to extend it or Cancel for the kill switch.
📷 Suggested image: the Function’s Live Logs showing
GET-back OK. changeType=updated deviceId=ESP-01 temperature=…andExecuted 'BcWebhook' (Succeeded, Duration=…ms), next to the Invocations tab with Success/Error count. It is the end-to-end proof: your edit in BC traveled to the Function and the GET-back read the real data you just changed.
Step 4. The receiver: an Azure Function that understands the handshake
This is the side people get wrong the most. The golden rule: always check first whether a validationToken arrives and return it before doing anything else. Here is an Azure Function using the .NET isolated worker model. Notice that the trigger accepts GET and POST: the documentation does not pin down the handshake verb, so covering both saves you scares.
public class BcWebhook
{
private readonly ILogger<BcWebhook> _logger;
private readonly INotificationProcessor _processor;
// Shared secret configured in app settings (local.settings.json / Function App configuration).
private readonly string _expectedClientState;
public BcWebhook(ILogger<BcWebhook> logger, INotificationProcessor processor)
{
_logger = logger;
_processor = processor;
_expectedClientState = Environment.GetEnvironmentVariable("ExpectedClientState") ?? string.Empty;
}
[Function("BcWebhook")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "bc-webhook")]
HttpRequestData req)
{
// 1. Handshake: if BC sends a validationToken, echo it back as plain text within 5 seconds.
var query = HttpUtility.ParseQueryString(req.Url.Query);
var validationToken = query["validationToken"];
if (!string.IsNullOrEmpty(validationToken))
{
_logger.LogInformation("Handshake received. Echoing validationToken back.");
var handshake = req.CreateResponse(HttpStatusCode.OK);
handshake.Headers.Add("Content-Type", "text/plain");
await handshake.WriteStringAsync(validationToken);
return handshake;
}
// 2. Real notification: read and deserialize the body defensively.
try
{
var body = await new StreamReader(req.Body).ReadToEndAsync();
var envelope = JsonSerializer.Deserialize<NotificationEnvelope>(body);
// 3. Validate the shared secret before trusting anything.
foreach (var notification in envelope?.Value ?? new())
{
if (notification.ClientState != _expectedClientState)
{
_logger.LogWarning("Notification ignored: clientState did not match.");
continue; // ignore anything that does not carry our secret
}
// 4. The payload is only a pointer. Hand it off (enqueue) and return fast,
// then fetch the actual record from notification.Resource using OAuth.
await _processor.ProcessAsync(notification);
}
}
catch (JsonException ex)
{
// A malformed body must NOT become a 4xx/5xx: that would make BC retry or drop the subscription.
_logger.LogError(ex, "Could not parse the notification body; returning 200 to avoid retries/drops.");
}
// 5. Always answer 200 quickly so BC does not retry or drop the subscription.
return req.CreateResponse(HttpStatusCode.OK);
}
}
// The seam that makes the receiver testable: a fake implementation lets a unit test
// assert that a valid notification is processed and a bad clientState is ignored.
public interface INotificationProcessor
{
Task ProcessAsync(Notification notification);
}
public class NotificationEnvelope
{
[JsonPropertyName("value")]
public List<Notification> Value { get; set; } = new();
}
public class Notification
{
[JsonPropertyName("subscriptionId")]
public string SubscriptionId { get; set; } = string.Empty;
[JsonPropertyName("clientState")]
public string ClientState { get; set; } = string.Empty;
[JsonPropertyName("resource")]
public string Resource { get; set; } = string.Empty;
[JsonPropertyName("changeType")]
public string ChangeType { get; set; } = string.Empty; // created | updated | deleted | collection
[JsonPropertyName("lastModifiedDateTime")]
public DateTimeOffset LastModifiedDateTime { get; set; }
}
The reasoning behind the design, step by step. The handshake goes first because BC demands it before activating the subscription and repeats it on every renewal. The clientState validation goes before processing because the endpoint is anonymous and anyone who discovers the URL could hit it. The real work is enqueued instead of done inline because you have a short response window and because the notification is only a pointer: the real data you will fetch afterward with an authenticated GET. And it answers 200 fast because anything else makes BC retry or, worse, delete the subscription. One last design decision: the work is not run inside Run, but delegated to an injected INotificationProcessor. That seam is exactly what lets you cover the receiver with unit tests; the example repo includes xUnit tests that verify the handshake, the clientState filtering, and that the endpoint always answers 200, even on a malformed body (hence the try/catch around the parsing).
And here is what that handshake looks like live: with the Function already deployed on Azure, at the instant of registration its Live Logs record Handshake received. Echoing validationToken back. and answer 200 in milliseconds.
If your audience is more functional than developer-oriented, this same receiver can be replaced with a Power Automate flow or a Logic App, which solve the handshake for you. That said, with one caveat we will see in the traps.
Step 5. What a real notification looks like
When a new reading comes in, BC does not send you the temperature. It sends you this:
{
"value": [
{
"subscriptionId": "3f2a1b6c-9d4e-4f8a-b1c2-7e5d6a8f0b21",
"clientState": "a-long-random-shared-secret",
"expirationDateTime": "2026-06-09T10:15:00Z",
"resource": "api/ivansingleton/iot/v2.0/companies(<companyId>)/sensorReadings(<systemId>)",
"changeType": "created",
"lastModifiedDateTime": "2026-06-06T10:15:00Z"
}
]
}
📷 Suggested image: in Azure, App Insights → the
requeststable (or Live Metrics) showing thePOSTtobc-webhookwithresultCode 200— the notification actually arriving from BC to the cloud.
The resource is the URL of the specific record. Your next step is to make a GET to that address with your OAuth token to obtain the deviceId, the temperature, and the rest. Notice too that the payload comes inside a value array: a single HTTP call can batch several changes, so always iterate.
Step 6. The GET-back: reading the real data
With that pointer in hand, the receiver makes the GET back. In the repo that work lives in the real implementation of INotificationProcessor, the one Run injects and that _processor.ProcessAsync calls: it acquires an OAuth client-credentials token and reads the record that changed. For deleted there is nothing to read, so the GET is skipped.
public class BcApiNotificationProcessor : INotificationProcessor
{
private readonly HttpClient _http;
private readonly BcApiOptions _options; // TenantId, ClientId, ClientSecret, Scope, ApiBaseUrl
private readonly ILogger<BcApiNotificationProcessor> _logger;
public BcApiNotificationProcessor(HttpClient http, BcApiOptions options,
ILogger<BcApiNotificationProcessor> logger)
{
_http = http; _options = options; _logger = logger;
}
public async Task ProcessAsync(Notification notification)
{
// "deleted" only points at a record that no longer exists: nothing to GET back.
if (string.Equals(notification.ChangeType, "deleted", StringComparison.OrdinalIgnoreCase))
return;
var token = await AcquireTokenAsync();
if (string.IsNullOrEmpty(token)) return;
// notification.Resource is relative to the service root: prepend the API base URL.
var url = $"{_options.ApiBaseUrl.TrimEnd('/')}/{notification.Resource.TrimStart('/')}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await _http.SendAsync(request);
if (!response.IsSuccessStatusCode) return; // tolerate, do not throw
var body = await response.Content.ReadAsStringAsync();
var root = JsonDocument.Parse(body).RootElement;
var deviceId = root.TryGetProperty("deviceId", out var d) ? d.GetString() : "(n/a)";
var temperature = root.TryGetProperty("temperature", out var t) ? t.ToString() : "(n/a)";
_logger.LogInformation("GET-back OK. deviceId={DeviceId} temperature={Temperature}",
deviceId, temperature);
}
private async Task<string?> AcquireTokenAsync()
{
var tokenUrl = $"https://login.microsoftonline.com/{_options.TenantId}/oauth2/v2.0/token";
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = _options.ClientId,
["client_secret"] = _options.ClientSecret,
["scope"] = _options.Scope,
});
using var response = await _http.PostAsync(tokenUrl, content);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
return JsonDocument.Parse(json).RootElement
.TryGetProperty("access_token", out var at) ? at.GetString() : null;
}
}
This implementation is registered in Program.cs as the default INotificationProcessor when credentials are configured; otherwise a log-only one remains as a fallback. That way Run does not change and the injectable seam lets you test the GET-back with a fake HttpClient (token requested once, GET to the right URL with Bearer, deleted with no GET).
A nuance that bites in the lab: the token is for the same Business Central that notified you, with your API’s OAuth scope, and reading custom objects (like
sensorReadings) requires the S2S app to have permissions on them. You will see it in the permission set trap below.
📷 Suggested image: in Azure, App Insights →
traces(or the Function’s Log stream) showing the lineGET-back OK. deviceId=ESP-01 temperature=22.7— the visual proof that the cloud read the real data from BC.
Beyond the INSERT: trigger and stop from a button in BC
So far, the webhook fires on its own when a record changes. But there are two very useful things you can control from a button on a BC page: triggering the notification on demand and, above all, cutting the flow off at once when you no longer want it.
A button to trigger the notification (indirectly)
A button does not “launch” the webhook as if it were a loose event, because the classic webhook reacts to data changes, not to clicks. But a button can indeed cause the change that triggers it. If the action modifies a record of the subscribed table, BC detects the modification and sends an updated notification, exactly like with an insert.
action(MarkReviewed)
{
Caption = 'Mark as reviewed';
ApplicationArea = All;
Image = Approve;
trigger OnAction()
begin
Rec.Validate(Processed, true);
Rec.Modify(true); // this change fires an "updated" webhook notification
end;
}
Keep in mind that it is still subject to the usual rules: the roughly 30-second batching and the Job Queue. It is an on-demand trigger, but not an instant one.
📷 Suggested image: clicking Mark as reviewed on a reading in Sensor Readings and, ~30 s later, the
updatednotification showing up in App Insights with its GET-back — the same Phase 2 cycle, but triggered by hand from a button.
A button to stop sending immediately (the kill switch)
Here is the interesting part, and the one that most resembles a real case. Imagine a sensor goes into maintenance, or you cancel a device’s monitoring plan. You do not want BC to keep notifying readings to your function, and you cannot afford to wait for the next 30-second cycle nor for the subscription to expire in 3 days. You need a switch that cuts the flow off instantly.
It is the complement of the cache scenario in the push versus pull section: there a webhook propagated the suspension of a specific device so the receiver would stop accepting its readings; here, when the task is to stop the whole channel, the cut is made on the subscription itself.
The conceptual key: the “subscription” is exactly what you need to cancel. Stopping the webhook means deleting the subscription with a DELETE on the subscriptions endpoint. Once deleted, BC stops generating new notifications for that subscription, no matter how many readings keep coming in. That said, design the receiver to tolerate a notification already enqueued or in transit in the Job Queue: the cut applies to new ones, not necessarily to those already on their way.
To give the user that button inside BC, the action calls the subscriptions endpoint itself with HttpClient. You need two things saved from when you created the subscription: the subscriptionId the POST returned (save it in a setup table) and an OAuth token to authenticate the call.
action(StopNotifications)
{
Caption = 'Stop sending notifications';
ApplicationArea = All;
Image = Cancel;
trigger OnAction()
var
WebhookMgt: Codeunit "Sensor Webhook Mgt.";
begin
if not Confirm('Stop the webhook subscription now? Notifications will stop immediately.') then
exit;
WebhookMgt.CancelSubscription();
Message('Subscription cancelled. Business Central will no longer notify the receiver.');
end;
}
codeunit 50111 "Sensor Webhook Mgt."
{
procedure CancelSubscription()
var
Setup: Record "Sensor Webhook Setup";
Client: HttpClient;
Headers: HttpHeaders;
Response: HttpResponseMessage;
Url: Text;
begin
Setup.Get();
Url := StrSubstNo('%1/api/ivansingleton/iot/v2.0/subscriptions(''%2'')', Setup.GetApiBaseUrl(), Setup."Subscription Id");
Headers := Client.DefaultRequestHeaders;
Headers.Add('Authorization', SecretStrSubstNo('Bearer %1', GetAccessToken()));
Headers.Add('If-Match', '*'); // '*' matches any version, so we skip reading the etag first
if not Client.Delete(Url, Response) then
Error('The cancel request could not be sent.');
if not Response.IsSuccessStatusCode then
Error('Could not cancel the subscription. Status: %1', Response.HttpStatusCode);
end;
local procedure GetAccessToken(): SecretText
begin
// Acquire an OAuth client-credentials token (e.g., with the OAuth2 system codeunit),
// cache it, and reuse it until it expires. In BC 2026 wave 1 the token is a SecretText.
end;
}
Several important notes. First, the subscriptionId must go in single quotes in the URL (hence the ''%2'' in the StrSubstNo, which in AL produces the literal quotes); it is an explicit requirement of the documentation. Second, the If-Match is not optional: just like renewal with PATCH, cancellation with DELETE demands the header, and using '*' saves you from reading the @odata.etag first. Third, if you do the DELETE from AL with HttpClient, remember to enable Allow HttpClient Requests on the extension, or the outbound call will not go out. Fourth, in BC 2026 wave 1 the OAuth token travels as a SecretText and, in a target = Cloud extension, you cannot extract its plain text (Unwrap is restricted to on-premises); that is why the header is built with SecretStrSubstNo('Bearer %1', GetAccessToken()), which keeps the secret masked end to end. And finally, this same CancelSubscription can be invoked from wherever you want: a button, a scheduled routine in the Job Queue, or as a reaction to an external signal you receive; the button is just the visible face and the cutoff logic is reusable. As an advanced alternative, the subscription lives in a BC system table, so in theory you could delete the record directly from AL without the HTTP call, but touching system tables is fragile and not recommended, so the supported path is the DELETE via API.
📷 Suggested image: the Stop sending notifications confirmation dialog and the message “Subscription cancelled…”; to close the loop, insert a new reading afterward and show that no notification appears in App Insights — the kill switch working.
Cancel, let expire, or pause: which is which
It is worth not confusing three different behaviors:
- Cancel (
DELETE): immediate cutoff. It is your kill switch. To resume, you have to create the subscription again, with its new handshake. - Let it expire: passive and slow, up to 3 days. No good when you need to stop now.
- Pause: BC has no native pause. If you want to stop and resume without recreating the subscription, keep it alive but ignore notifications in the receiver (for example, by checking a flag), or control the flow at the data source.
Common traps (the section I wish I had read earlier)
| Symptom | Cause | Fix |
|---|---|---|
| “Service has not responded in time to the validation request” | Your receiver took more than 5 seconds to return the validationToken (cold start, slow proxy). |
Answer the handshake first, before any logic. Consider keeping the function warm or using a plan that avoids cold start. |
| The subscription is created but no notification arrives in sandbox | Notifications are sent via Job Queue, and a delegated admin cannot start job queues. | Trigger the change with a normally-licensed user, not a delegated admin account. |
| The webhook was working and suddenly stops arriving | The subscription expired after 3 days and nobody renewed it. | Schedule a renewal with PATCH before the 3 days are up. |
Renewing (PATCH) or cancelling (DELETE) the subscription fails |
The If-Match header is missing. |
Send If-Match with the subscription’s @odata.etag, or use If-Match: * to accept any version. |
| A bulk process does not trigger my Power Automate flow | If many records change in a short window, BC sends a single collection-type notification, which the Power Automate connector does not process. |
For high volumes, use your own receiver (Azure Function) and decouple with a queue like Service Bus. |
| BC stopped retrying and deleted the subscription | Your endpoint responded with a code other than 408, 429, or 5xx. |
Make sure to return 200 on the happy path; any other code outside those three stops the retries and deletes the subscription. |
| The receiver processes the same change twice | Delivery does not guarantee uniqueness: retries and batching can duplicate notifications. | Make the handler idempotent: deduplicate by resource and lastModifiedDateTime before applying the change. |
| Notifications arrive in a different order than the changes | Delivery order is not guaranteed. | Do not assume sequence: compare lastModifiedDateTime (or your own version field) and discard anything older than what is already applied. |
| The AL button fails with “could not be sent” when running it on a local container | BC’s anti-SSRF guard blocks AL’s HttpClient toward non-routable IPs, and the container resolves to a private Docker IP. |
Allowlist the range with NavHttpClientAntiSSRFAllowedAddresses (or disable NavHttpClientAntiSSRFEnabled). Lab only; detail in Run it on your own Docker. |
Two more concepts worth internalizing. First, BC batches changes: by default it waits about 30 seconds after the first change before notifying, so webhooks are not strictly real-time to the millisecond. Second, on-premises you have to enable OData, API, and Job Queue on the service tier the users use, because the notification fires in the session of the user making the change.
The safety net: webhook plus backup pull
All the traps above point to an uncomfortable truth: in BC a subscription can die silently. It expires after 3 days if nobody renews it, and BC deletes it without warning if your endpoint responds with the wrong code. If the webhook is your only mechanism, “an event was lost” turns into “I never find out”.
The answer is not to distrust the webhook, but to not let it work alone. The pattern that almost always wins is hybrid: the webhook propagates instantly and a lazy pull reconciles every so often. In our example, a scheduled process would suffice (hourly, nightly, whatever your case tolerates) that makes a GET to sensorReadings filtering with $filter=lastModifiedDateTime gt <last sync> and processes whatever the webhook skipped. That same process can take the chance to check with a GET to subscriptions that the subscription is still alive, and recreate it if it disappeared. With that safety net, the worst case goes from “I never find out” to “I find out within X at the latest”, and you choose X.
2026 and beyond: where to look
Two warnings so this knowledge is not born stale. As of Business Central 2026 release wave 1 (version 28), the v1.0 API is removed, so everything above must be built on v2.0, and basic authentication is already deprecated in favor of OAuth with client credentials.
And a design reflection. Classic webhooks watch any change to an entity’s record, without you deciding which specific change matters nor what information travels. When you need exactly that (fire on a specific business event and send the payload you want), an alternative to evaluate is External Business Events, declared in AL with the [ExternalBusinessEvent] attribute. It is worth reviewing their status before betting on them: today Microsoft still documents them as preview and very tied to Dataverse and Power Automate, although they can also notify external systems. They also have an interesting nuance versus the classic webhook: the notification is only sent when the transaction commits, so if the process rolls back, the event does not fire. They do not replace webhooks in every case, but they are the right tool when you want control and selectivity instead of watching the CRUD of a whole table.
Conclusion
Business Central webhooks are not magic: they are a subscription to an API Page, a five-second handshake, a pointer that reaches you when something changes, and a GET back to read the data. On top of that base you can trigger notifications on demand from a button and, when needed, cut the flow off by cancelling the subscription with a DELETE. The conceptual part fits on a napkin; the real difficulty is in the operational details, the handshake on time, the clientState, the renewal with etag, the Job Queue, and in having exposed the entity as an API Page with SystemId as the key.
And if you keep just one idea, let it be the two-question rule: how often does the data change? and how urgently do I need to find out? If it changes rarely and the urgency is high, webhook with a safety net. If you need it fresh exactly at the moment of acting, a direct call. If there is no rush and the volume is low, lazy polling. The two classic mistakes are the extremes: aggressively polling something that almost never changes, and trusting a webhook as if it were guaranteed delivery.
The IoT example from this post lives in a companion repository with all the code: the AL extension (the table, the API Page, and the buttons for registering, renewing, and the immediate cutoff with the subscription DELETE) and the receiving Azure Function with its tests. You can find it at github.com/ivanrlg/webhooks-business-central — clone it and build it yourself.
The Business Central community has always grown by sharing what it learns. I hope this one saves you the afternoon it cost many of us to understand it.
Resources and references
- Microsoft Learn, Working with webhooks (v2.0): https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-subscriptions
- Microsoft Learn, API page type: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-api-pagetype
- Microsoft Learn, Developing a custom API: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-develop-custom-api
- Arend-Jan Kauffmann, How to test Business Central webhooks: https://www.kauffmann.nl/
- Stefano Demiliani, Webhooks with Dynamics 365 Business Central: https://demiliani.com/2019/12/10/webhooks-with-dynamics-365-business-central/
- Fredborg, Lessons Learned: Working with Webhooks in Business Central: https://fredborg.org/?p=914
- Microsoft Learn, Delete subscriptions (quotes in the id and If-Match): https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/api/dynamics_subscriptions_delete
- Microsoft Learn, Business events on Business Central (preview): https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/business-events-overview
- Companion repository for this post (AL extension + Azur