Business Central webhooks let an external service receive a notification when a record such as a customer, item or sales invoice is created, updated or deleted, instead of polling the API. You create a subscription with a POST to /api/v2.0/subscriptions naming a notificationUrl and a resource; Business Central then calls that URL with a validationToken query parameter, and your endpoint must return the token in the body with 200 OK before the subscription is registered. Subscriptions expire after three days, so renew each one with a PATCH, which repeats the same handshake.
Who this is for and what you will have at the end
This guide is for developers who integrate Business Central online with CRM, e-commerce, data platforms or event buses and want near-real-time change data without hammering the API. Polling with delta links isn't an option any more: Microsoft removed delta link support in version 24 and names webhooks as the preferred approach to change tracking.
At the end you will have:
- An Azure Functions endpoint that answers the validation handshake, checks
clientStateand queues notifications. - Subscriptions on standard API v2.0 entities and, if you need them, on custom API pages.
- A worker pattern that turns notifications into API reads, including
collectionnotifications. - A renewal script and a troubleshooting checklist for subscriptions that disappear.
How Business Central webhooks work
Subscriber Business Central
| POST /api/v2.0/subscriptions -------->|
|<-------- GET/POST notificationUrl?validationToken=...
| 200 OK, body = validationToken -------->| subscription registered
| |
| (record changes, 30 s delay) |
|<-------- POST notificationUrl { "value": [ ... ] }
| 200 OK -------------------------------->|
| |
| PATCH /subscriptions('{id}') every < 3 days (handshake again)Key behaviors, all from Microsoft's documentation for Business Central online:
| Behavior | Value |
|---|---|
| Subscription lifetime | 3 days unless renewed |
| Handshake | Required on create (POST) and renew (PATCH) |
| Notification delay | 30 seconds after the first change to an entity |
| Collection threshold | More than 1,000 records changed during the delay produces one collection notification |
| Retries | Several retries over 36 hours when the subscriber returns 408, 429 or 5xx |
| Other error responses | No retries, and the subscription is deleted |
| Maximum subscriptions | 200 webhook subscriptions per environment |
A single POST to your endpoint can carry notifications for several subscriptions, so always loop through the value array.
Prerequisites
- Service-to-service authentication with an app that has
API.ReadWrite.Alland an application account in Business Central with read permission on the entities you subscribe to. See Business Central OAuth 2.0 service-to-service setup. - A public HTTPS endpoint for
notificationUrl. This guide uses an Azure Functions app with the Node.js v4 programming model and a Storage queue. - The company ID, from
GET .../api/v2.0/companies. - A random
clientStatevalue, stored as an app setting on the function app. Business Central returns it in every notification, so you can use it as a shared secret.
Step 1: Confirm the entity supports webhooks
Query the supported resources for the company. The filter limits the result to v2.0 APIs:
curl -H "Authorization: Bearer $TOKEN" \
"https://api.businesscentral.dynamics.com/v2.0/$TENANT_ID/production/api/microsoft/runtime/beta/companies($COMPANY_ID)/webhookSupportedResources?\$filter=resource eq 'v2.0*'"The documented v2.0 list is: accounts, companyInformation, countriesRegions, currencies, customerPaymentJournals, customers, dimensions, employees, generalLedgerEntries, itemCategories, items, journals, paymentMethods, paymentTerms, purchaseInvoices, salesCreditMemos, salesInvoices, salesOrders, salesQuotes, shipmentMethods, unitsOfMeasure and vendors.
For document APIs, a change to a line raises a notification for the header. Editing a salesInvoiceLine notifies subscribers of the parent salesInvoice.
Custom API pages are webhook-enabled and appear in the same list, unless the page uses a temporary source table, a system table (table number above 2000000000) or the Job Queue Entry table, has a composite key, or is an API query rather than an API page. If you're building a custom API to subscribe to, key it on SystemId as described in building custom API pages and queries.
Step 2: Build the receiver in Azure Functions
The receiver has three jobs: answer the handshake, reject notifications with the wrong clientState, and hand the rest to a queue quickly. Don't call Business Central from inside the HTTP handler; a worker reads the queue and does that.
const { app, output } = require('@azure/functions');
const queueOutput = output.storageQueue({
queueName: 'bc-notifications',
connection: 'AzureWebJobsStorage'
});
app.http('bcWebhook', {
methods: ['GET', 'POST'],
authLevel: 'anonymous',
extraOutputs: [queueOutput],
handler: async (request, context) => {
// 1. Handshake on subscription create and renew
const validationToken = request.query.get('validationToken');
if (validationToken) {
return {
status: 200,
headers: { 'Content-Type': 'text/plain' },
body: validationToken
};
}
// 2. Notifications
const payload = await request.json();
const expected = process.env.BC_CLIENT_STATE;
const accepted = (payload.value || []).filter(n => n.clientState === expected);
if (accepted.length > 0) {
context.extraOutputs.set(queueOutput, JSON.stringify(accepted));
} else {
context.log('Notification without a valid clientState was ignored');
}
// 3. Always acknowledge genuine traffic with 200
return { status: 200 };
}
});Design notes:
- Methods. Microsoft documents that the validation request passes
validationTokenon the query string but doesn't state the HTTP method, so the function accepts both GET and POST and checks the query first. - Authorization level. The example uses
anonymousand relies onclientState, because any key you add must also survive the handshake and every notification. If you usefunctionkeys instead (?code=in the URL or thex-functions-keyheader), test that subscription creation still succeeds. - Status codes. Return
200even when you drop a notification. If you return401or403to Business Central, for example after rotatingclientStatewithout updating the subscription, it deletes the subscription instead of retrying. - Speed. Azure Functions returns
502if an HTTP-triggered function runs longer than 230 seconds. Writing to a queue and returning keeps the handler well inside that, and the queue absorbs bursts.
Deploy the function and note its URL, for example https://bc-hooks-contoso.azurewebsites.net/api/bcWebhook.
Step 3: Create the subscription
Send the subscription request to the environment's API root. The resource path includes the company and the entity set:
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"https://api.businesscentral.dynamics.com/v2.0/$TENANT_ID/production/api/v2.0/subscriptions" \
-d '{
"notificationUrl": "https://bc-hooks-contoso.azurewebsites.net/api/bcWebhook",
"resource": "/api/v2.0/companies('"$COMPANY_ID"')/customers",
"clientState": "'"$BC_CLIENT_STATE"'"
}'Business Central then calls the function with validationToken. If the handshake succeeds, the subscription is registered and the response contains the subscription with its subscriptionId, notificationUrl, resource, clientState and expirationDateTime.
For a custom API, both the subscription URL and the resource must include your publisher, group and version:
POST https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/contoso/finance/v1.0/subscriptions
resource: /api/contoso/finance/v1.0/companies({companyId})/customerCreditsStep 4: Process notifications
A notification tells you what changed, not the new values:
{
"value": [
{
"subscriptionId": "c670ea73cacb459bb51dc1740da2f1db",
"clientState": "<your clientState>",
"expirationDateTime": "2026-10-14T07:52:31Z",
"resource": "api/v2.0/companies(b18aed47-c385-49d2-b954-dbdf8ad71780)/customers(130bbd17-dbb9-4790-9b12-2b0e9c9d22c3)",
"changeType": "updated",
"lastModifiedDateTime": "2026-10-11T12:54:20.467Z"
}
]
}The queue worker handles each changeType:
| changeType | What to do |
|---|---|
created, updated | GET the resource URL (prefixed with the environment's base URL) and upsert the record downstream |
deleted | Delete or flag the record downstream by its ID; the resource no longer exists |
collection | The resource contains a filter. GET that URL, follow the paging links, and upsert every record returned |
Make the worker idempotent. Business Central coalesces changes made within the 30-second window, but you can still receive the same record more than once, for example after a retry. Upserting by the record id and comparing lastModifiedDateTime handles that.
If downstream systems need fan-out to several consumers, put the notifications on an event bus rather than calling each consumer from the worker; Kafka vs RabbitMQ vs AWS SQS for event-driven architecture compares the options.
Step 5: Renew subscriptions before they expire
Subscriptions live for three days. Renew them on a schedule well inside that window, for example daily. A PATCH needs the current ETag in If-Match and triggers the handshake again:
# $accessToken and $tenantId come from the client credentials token request
$headers = @{ Authorization = "Bearer $accessToken" }
$base = "https://api.businesscentral.dynamics.com/v2.0/$tenantId/production/api/v2.0"
$subs = (Invoke-RestMethod -Uri "$base/subscriptions" -Headers $headers).value
foreach ($s in $subs) {
$body = @{
notificationUrl = $s.notificationUrl
resource = $s.resource
clientState = $s.clientState
} | ConvertTo-Json
$patchHeaders = $headers.Clone()
$patchHeaders['If-Match'] = $s.'@odata.etag'
$renewed = Invoke-RestMethod -Method Patch -Uri "$base/subscriptions('$($s.subscriptionId)')" `
-Headers $patchHeaders -ContentType "application/json" -Body $body
"{0} renewed until {1}" -f $renewed.subscriptionId, $renewed.expirationDateTime
}Run the same script for custom API routes, replacing api/v2.0 with api/{publisher}/{group}/{version}. To remove a subscription, send DELETE to the same subscription URL.
Because retries stop after 36 hours, add a periodic reconciliation for critical data: for entities that expose lastModifiedDateTime, such as salesInvoices, read records modified since the last successful sync and compare them with your downstream copy.
Verify the setup
GET .../api/v2.0/subscriptionslists your subscription with anexpirationDateTimeabout three days ahead.- Change a test customer in a sandbox. Within roughly 30 seconds the function logs a notification and a message lands on the queue.
- Delete a test record and confirm the worker receives
changeTypedeleted. - Run the renewal script and confirm
expirationDateTimemoves forward.
Troubleshooting
Creating the subscription fails. The handshake didn't complete. Call the function URL yourself with ?validationToken=test and check that the response is 200 with exactly test as the body. Check that the URL is public and that any key or network restriction lets Business Central through.
Subscriptions disappear before three days. The receiver returned an error code other than 408, 429 or 5xx, for example 400 from a JSON parsing bug, 401 from a key mismatch or 404 after a redeploy changed the route, and Business Central deleted the subscription. Fix the receiver, return 200 for anything you choose to ignore, then create the subscription again.
Subscriptions expire. The renewal job didn't run or its handshake failed. Alert when any expirationDateTime is less than 24 hours away.
PATCH fails with a precondition error. The If-Match value doesn't match the current ETag. Read the subscription again and use its @odata.etag.
No notification after a change. Changes made by a delegated admin aren't notified until a licensed user makes a change. Changes by users who can't schedule job queues aren't notified until a user who can makes another change to the same table.
Power Automate flows don't trigger for bulk changes. The Business Central connector can't process collection notifications, so a flow doesn't run when more than 1,000 records change within 30 seconds. Use a custom receiver for bulk-import scenarios.
The subscription limit is reached. Business Central online allows 200 webhook subscriptions per environment. Subscribe per entity set rather than per record, and delete subscriptions you no longer use.
Payload parsing differs from older code. Since version 19, notifications don't include a byte order mark, in line with RFC 7159. Remove any BOM-stripping workarounds.
Checklist
- Entities confirmed in
webhookSupportedResources; custom APIs keyed onSystemIdwith a real source table. - Receiver answers the
validationTokenhandshake, checksclientState, queues work and always returns200to genuine traffic. - Subscriptions created per company and entity set, under the 200 limit.
- Worker handles
created,updated,deletedandcollection, idempotently. - Renewal runs at least daily with ETag-based PATCH, and alerts on near-expiry.
- Reconciliation job covers gaps longer than the 36-hour retry window.
References
- Working with webhooks in Business Central
- subscriptions resource type
- Update subscriptions
- Operational limits in Business Central online
- Deprecated features in the client, server, database
- API page type
- Azure Functions HTTP trigger
- Azure Functions Node.js developer guide
- Using service-to-service authentication