Architecture

Business Central API Webhooks: Create, Validate and Renew Subscriptions

Receive Business Central change notifications instead of polling: build a validating receiver in Azure Functions, create a subscription, process notifications and renew before the three-day expiry.

10 min read
On this page

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 clientState and 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 collection notifications.
  • 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:

BehaviorValue
Subscription lifetime3 days unless renewed
HandshakeRequired on create (POST) and renew (PATCH)
Notification delay30 seconds after the first change to an entity
Collection thresholdMore than 1,000 records changed during the delay produces one collection notification
RetriesSeveral retries over 36 hours when the subscriber returns 408, 429 or 5xx
Other error responsesNo retries, and the subscription is deleted
Maximum subscriptions200 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.All and 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 clientState value, 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 validationToken on 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 anonymous and relies on clientState, because any key you add must also survive the handshake and every notification. If you use function keys instead (?code= in the URL or the x-functions-key header), test that subscription creation still succeeds.
  • Status codes. Return 200 even when you drop a notification. If you return 401 or 403 to Business Central, for example after rotating clientState without updating the subscription, it deletes the subscription instead of retrying.
  • Speed. Azure Functions returns 502 if 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})/customerCredits

Step 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:

changeTypeWhat to do
created, updatedGET the resource URL (prefixed with the environment's base URL) and upsert the record downstream
deletedDelete or flag the record downstream by its ID; the resource no longer exists
collectionThe 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

  1. GET .../api/v2.0/subscriptions lists your subscription with an expirationDateTime about three days ahead.
  2. Change a test customer in a sandbox. Within roughly 30 seconds the function logs a notification and a message lands on the queue.
  3. Delete a test record and confirm the worker receives changeType deleted.
  4. Run the renewal script and confirm expirationDateTime moves 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 on SystemId with a real source table.
  • Receiver answers the validationToken handshake, checks clientState, queues work and always returns 200 to genuine traffic.
  • Subscriptions created per company and entity set, under the 200 limit.
  • Worker handles created, updated, deleted and collection, 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

Questions people ask

How long does a Business Central webhook subscription last?

Three days. Renew it before expirationDateTime with a PATCH request to the subscription, which repeats the validationToken handshake. On-premises, the lifetime is set by the ApiSubscriptionExpiration server setting.

What happens if my webhook endpoint is down?

Business Central retries over the next 36 hours if your endpoint responds with 408, 429 or a 5xx code. If it responds with any other error code, no retries are attempted and the subscription is deleted, so never return 401, 403 or 404 for genuine notifications.

Why did I receive a collection notification instead of individual changes?

Business Central waits 30 seconds after the first change before sending. If more than 1,000 records change in that window, it sends one collection notification whose resource URL contains a filter, and you read the changed records with a GET on that URL.

Can I subscribe to webhooks on a custom API page?

Yes, if the page is webhook-enabled. Send the subscription to api/{publisher}/{group}/{version}/subscriptions. Webhooks aren't supported for API queries, temporary or system source tables, composite keys, or the Job Queue Entry table.

Business CentralWebhooksREST API v2.0Azure Functions
  1. Business Central SOAP Retirement in v29: Migrate Page Web Services to APIs

    Version 29 removes SOAP endpoints on Microsoft pages in Business Central. Find the integrations that still call them with telemetry and move each one to API v2.0, a custom API or OData.

    Architecture12 min read
  2. Microsoft Graph webhooks: validate, renew and recover missed notifications

    Build a Microsoft Graph change notification endpoint that passes validation, renews subscriptions on time, handles lifecycle events and resyncs missed changes with delta query.

    Architecture14 min read
  3. Webhooks that don't double-charge: retries, idempotency and dead letters

    Build webhook receivers that survive retries and duplicate deliveries: verify, deduplicate on the event ID, queue to Azure Service Bus, process idempotently and handle dead letters.

    Architecture12 min read