Deploying a full suite of Business Central extensions shouldn’t feel like defusing a bomb, but it often does. Between dependency chains, version mismatches, and the slow, careful dance of installing one app at a time, manual deployment becomes a fragile and error‑prone process. In this post, we take the Admin API work from earlier articles and push it further building a fully automated, dependency‑sorted, version‑aware deployment pipeline using PowerShell. If you’ve ever wanted to point at a folder of .app files and let the system handle the rest, this is the workflow you’ve been waiting for.
Here is the scenario, we have created an extensive series of Business Central Extensions in our UAT environment. We would like to deploy them all to production. This is a bit of a task, first off, we want to ensure that we are installing them in the right order based on dependencies. Second, we need to install them each in order but wait for Business Central to finish the installation each time. What I want is to grab a folder if extensions and upload them all.
To be honest, this one got away from me. I wanted another simple demo to tie things together, and I ended up here.
Here is a refresher of where we are in the API process.
A prerequisite for this process is that we will need 7-Zip installed. Business Central APP packages are LZMA packages, which is a type of Zip file, and 7-Zip has worked well for me in the past to handle these file types.
Here are our requirements:
- 1. Provide a target Environment to install to.
- 2. Provide a folder containing several .app files to install.
- 3. The system should install them in dependency order.
- 4. The system should not attempt to install older versions of the .app file.
- 5. The system should stop on install error.
I think that is a solid list of requirements. It should be good enough to deploy a well-planned system from UAT to Production or perform a large-scale update or even a refresh and environment. All we need to do is store our .App files in a single directory.
This script performs the following tasks:
- 1. Authenticates against Azure AD using client credentials.
- 2. Reads a folder of
.appfiles. - 3. Extracts metadata from each extension, including dependencies.
- 4. Sorts the extensions in dependency order.
- 5. Retrieves currently installed extensions from the target environment.
- 6. Compares versions and installs only newer packages.
- 7. Uploads each extension using the
pteInstallendpoint. - 8. Monitors installation status until completion.
- 9. Retrieving Installed Extensions
- 10. Installing Extensions in Dependency Order
- 11. Monitoring Installation Status
Here is the complete script, if you are familiar with PowerShell, you can snag this and stop scrolling. We will dive into the components a little further down.
# ============================================================
# Sort Business Central .app files by dependency (ASCII only)
# ============================================================
$tenantId = "..."
$clientId = "..."
$clientSecret = "..."
$baseUri = "https://api.businesscentral.dynamics.com"
$url = $baseUri+"/admin/v2.29"
# Function for identifing if a version number is newer
function Is-NewerVersion {
param(
[string]$IncomingVersion,
[string]$InstalledVersion
)
$vIncoming = [version]$IncomingVersion
$vInstalled = [version]$InstalledVersion
return ($vIncoming -gt $vInstalled)
}
# Function for identifying dependancy order by counting the visits to a given ID by other extensions
function Visit($id) {
if ($visited[$id]) { return }
if ($visiting[$id]) { throw "Circular dependency detected at $id" }
$visiting[$id] = $true
foreach ($dep in $graph[$id]) {
if ($graph.ContainsKey($dep)) {
Visit $dep
}
}
$visiting[$id] = $false
$visited[$id] = $true
$null = $sorted.Add($id)
}
$body = @{
grant_type = "client_credentials"
client_id = $clientId
client_secret = $clientSecret
scope = "https://api.businesscentral.dynamics.com/.default"
}
$token = Invoke-RestMethod -Method Post `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-Body $body
$headers = @{ Authorization = "Bearer $($token.access_token)" }
# Prompt for environment name
$envName = Read-Host "Enter the environment name (e.g., Production)"
Add-Type -AssemblyName System.Windows.Forms
# Folder Picker
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
$dialog.Description = "Select the folder containing .app files"
$dialog.ShowNewFolderButton = $false
$null = $dialog.ShowDialog()
if ([string]::IsNullOrWhiteSpace($dialog.SelectedPath)) {
Write-Host "No folder selected. Exiting."
exit
}
$sourceFolder = $dialog.SelectedPath
Write-Host "Using source folder: $sourceFolder"
# Auto-detect 7-Zip
$SevenZipCandidates = @(
"$env:ProgramFiles\7-Zip\7z.exe",
"$env:ProgramFiles(x86)\7-Zip\7z.exe"
)
$SevenZip = $SevenZipCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $SevenZip) {
Write-Host "7z.exe not found. Enter full path:"
$SevenZip = Read-Host "Path to 7z.exe"
if (-not (Test-Path $SevenZip)) {
Write-Host "Invalid path. Exiting."
exit
}
}
Write-Host "Using 7-Zip at: $SevenZip"
# Extract metadata from each .app
$apps = @()
Get-ChildItem $sourceFolder -Filter *.app | ForEach-Object {
$appPath = $_.FullName
$appName = $_.Name
$temp = "temp_extract"
if (Test-Path $temp) { Remove-Item $temp -Recurse -Force }
mkdir $temp | Out-Null
# Extract NavxManifest.xml
& $SevenZip e $appPath "NavxManifest.xml" "-o$temp" -y | Out-Null
$manifestPath = Join-Path $temp "NavxManifest.xml"
if (-not (Test-Path $manifestPath)) {
Write-Host "$appName has no NavxManifest.xml. Skipping."
return
}
[xml]$xml = Get-Content $manifestPath
$appNode = $xml.Package.App
$dependencyIds = @()
if ($xml.Package.Dependencies.Dependency) {
foreach ($dep in $xml.Package.Dependencies.Dependency) {
$dependencyIds += $dep.Id
}
}
$apps += [PSCustomObject]@{
FileName = $appName
Path = $appPath
Id = $appNode.Id
Name = $appNode.Name
Publisher= $appNode.Publisher
Version = $appNode.Version
DependsOn= $dependencyIds
}
Remove-Item $temp -Recurse -Force
}
# Build dependency graph
$graph = @{}
$apps | ForEach-Object {
$graph[$_.Id] = $_.DependsOn
}
# Topological Sort
$sorted = New-Object System.Collections.ArrayList
$visited = @{}
$visiting = @{}
foreach ($app in $apps) {
Visit $app.Id
}
# Output sorted list
Write-Host ""
Write-Host "============================================"
Write-Host "Retrieving Currently Installed Apps"
Write-Host "============================================"
$InstalledApps = Invoke-RestMethod -Method Get `
-Uri $url"/applications/BusinessCentral/environments/$envName/apps" `
-Headers $headers
Write-Host ""
Write-Host "============================================"
Write-Host "Dependency-Sorted Install Order"
Write-Host "============================================"
foreach ($id in $sorted) {
$app = $apps | Where-Object { $_.Id -eq $id }
Write-Host $app.FileName
$appFilePath = $app.Path
Write-Host "Selected APP file: $appFilePath"
# Validating that the App to be installed is a newer version than the currently installed version
$Installed = $installedApps.value | Where-Object { $_.id -eq $app.id }
if ($Installed) {
if (Is-NewerVersion -IncomingVersion $app.Version -InstalledVersion $Installed.version) {
Write-Host "Installing newer version of $($app.Name)"
}
else {
Write-Host "Skipping $($Installed.Name). Installed version ($($Installed.version)) is newer or equal."
continue
}
}
Write-Host ""
Write-Host "============================================"
Write-Host "Uploading APP to Business Central"
Write-Host "============================================"
# JSON body required by pteInstall
$jsonBody = @{
deploymentSchedule = "Immediate"
syncMode = "Add"
languageId = "en-US"
acceptIsvEula = $true
installOrUpdateNeededDependencies = $false
} | ConvertTo-Json -Depth 5
$boundary = [System.Guid]::NewGuid().ToString()
$lf = "`r`n"
$ms = New-Object System.IO.MemoryStream
$writer = New-Object System.IO.StreamWriter($ms)
# extensionFile (binary)
$writer.Write("--$boundary$lf")
$writer.Write("Content-Disposition: form-data; name=`"extensionFile`"; filename=`"$(Split-Path $appFilePath -Leaf)`"$lf")
$writer.Write("Content-Type: application/octet-stream$lf$lf")
$writer.Flush()
$ms.Write([System.IO.File]::ReadAllBytes($appFilePath), 0, (Get-Item $appFilePath).Length)
$writer.Write($lf)
# deploymentSchedule
$writer.Write("--$boundary$lf")
$writer.Write("Content-Disposition: form-data; name=`"deploymentSchedule`"$lf$lf")
$writer.Write("Immediate$lf")
# syncMode
$writer.Write("--$boundary$lf")
$writer.Write("Content-Disposition: form-data; name=`"syncMode`"$lf$lf")
$writer.Write("Add$lf")
# languageId
$writer.Write("--$boundary$lf")
$writer.Write("Content-Disposition: form-data; name=`"languageId`"$lf$lf")
$writer.Write("en-US$lf")
# acceptIsvEula
$writer.Write("--$boundary$lf")
$writer.Write("Content-Disposition: form-data; name=`"acceptIsvEula`"$lf$lf")
$writer.Write("true$lf")
# installOrUpdateNeededDependencies
$writer.Write("--$boundary$lf")
$writer.Write("Content-Disposition: form-data; name=`"installOrUpdateNeededDependencies`"$lf$lf")
$writer.Write("false$lf")
# closing boundary
$writer.Write("--$boundary--$lf")
$writer.Flush()
$ms.Position = 0
$Results = Invoke-RestMethod `
-Uri "$url/applications/BusinessCentral/environments/$envName/apps/pteInstall" `
-Method Post `
-Headers $headers `
-ContentType "multipart/form-data; boundary=$boundary" `
-Body $ms
$AppID = $Results.appId
$InstallSuccessful = $true
Write-Host ""
Write-Host "============================================"
Write-Host "Monitoring Install Status"
Write-Host "============================================"
while ($true) {
$status = Invoke-RestMethod -Method Get `
-Uri "$url/applications/BusinessCentral/environments/$envName/apps/$AppId/operations" `
-Headers $headers
# Get the most recent operation
$op = $status.value | Sort-Object createdOn -Descending | Select-Object -First 1
Write-Host "Operation $($op.id): $($op.status)"
if ($op.status -eq "Succeeded") {
Write-Host "App install/update succeeded."
break # <-- breaks the Monitor WHILE
}
if ($op.status -eq "Failed") {
Write-Host "App install/update failed."
Write-Host "Error: $($op.errorMessage)"
$InstallSuccessful = $false
break # <-- breaks the Monitor WHILE
}
if ($op.status -eq "Canceled") {
Write-Host "Operation was canceled."
$InstallSuccessful = $false
break # <-- breaks the Monitor WHILE
}
Start-Sleep -Seconds 15
}
if ($InstallSuccessful -eq $false) {
break # <-- breaks the Install ForEach
}
}
The result is a fully automated, unattended deployment pipeline for Business Central extensions. Here is the breakdown of the steps and the supporting PowerShell scripts.
1. Authentication
The script begins by requesting an OAuth2 token using the client credentials flow. This token is required for all subsequent Admin API calls.
$tenantId = "..."
$clientId = "..."
$clientSecret = "..."
$baseUri = "https://api.businesscentral.dynamics.com"
$url = $baseUri + "/admin/v2.29"
$body = @{
grant_type = "client_credentials"
client_id = $clientId
client_secret = $clientSecret
scope = "https://api.businesscentral.dynamics.com/.default"
}
$token = Invoke-RestMethod -Method Post `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-Body $body
$headers = @{ Authorization = "Bearer $($token.access_token)" }
2. Version Comparison
Before installing an extension, the script checks whether the version in the .app file is newer than the version already installed.
function Is-NewerVersion {
param(
[string]$IncomingVersion,
[string]$InstalledVersion
)
$vIncoming = [version]$IncomingVersion
$vInstalled = [version]$InstalledVersion
return ($vIncoming -gt $vInstalled)
}
3. Dependency Sorting
Business Central extensions often depend on other extensions. Installing them in the wrong order can cause failures. The script extracts dependency information from each .app file by reading the NavxManifest.xml inside the package.
It then performs a topological sort to determine the correct installation order.
function Visit($id) {
if ($visited[$id]) { return }
if ($visiting[$id]) { throw "Circular dependency detected at $id" }
$visiting[$id] = $true
foreach ($dep in $graph[$id]) {
if ($graph.ContainsKey($dep)) {
Visit $dep
}
}
$visiting[$id] = $false
$visited[$id] = $true
$null = $sorted.Add($id)
}
4. Selecting the Target Environment
The script prompts for the environment name:
$envName = Read-Host "Enter the environment name (e.g., Production)"
5. Selecting the Folder of .app Files
A Windows folder picker is used to select the directory containing the extension packages.
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
$dialog.Description = "Select the folder containing .app files"
$dialog.ShowNewFolderButton = $false
$null = $dialog.ShowDialog()
$sourceFolder = $dialog.SelectedPath
6. Extracting Metadata from Each Extension
The script uses 7‑Zip to extract NavxManifest.xml from each .app file.
& $SevenZip e $appPath "NavxManifest.xml" "-o$temp" -y
It then reads:
- Extension ID
- Name
- Publisher
- Version
- Dependencies
and stores them in the $apps variable.
7. Building the Dependency Graph
$graph = @{}
$apps | ForEach-Object {
$graph[$_.Id] = $_.DependsOn
}
8. Performing the Topological Sort
foreach ($app in $apps) {
Visit $app.Id
}
The result is a list of extension IDs in the correct installation order.
9. Retrieving Installed Extensions
Before installing anything, the script retrieves the list of currently installed apps.
$InstalledApps = Invoke-RestMethod -Method Get `
-Uri $url"/applications/BusinessCentral/environments/$envName/apps" `
-Headers $headers
10. Installing Extensions in Dependency Order
For each extension:
- 1. Check if it is already installed.
- 2. Compare versions.
- 3. Skip if the installed version is newer or equal.
- 4. Upload the
.appfile using multipart form‑data. - 5. Monitor installation status until completion.
The upload is performed using the pteInstall endpoint:
$Results = Invoke-RestMethod `
-Uri "$url/applications/BusinessCentral/environments/$envName/apps/pteInstall" `
-Method Post `
-Headers $headers `
-ContentType "multipart/form-data; boundary=$boundary" `
-Body $ms
11. Monitoring Installation Status
The script polls the Admin API until the extension installation succeeds or fails.
$status = Invoke-RestMethod -Method Get `
-Uri "$url/applications/BusinessCentral/environments/$envName/apps/$AppId/operations" `
-Headers $headers
It checks:
- Succeeded
- Failed
- Canceled
and stops accordingly.
With this script in place, deploying Business Central extensions becomes predictable, repeatable, and, most importantly, unattended. You get dependency‑correct ordering, version validation, structured uploads, and continuous monitoring without babysitting the process. Whether you’re promoting a full UAT build to production or rolling out a large update across environments, this pipeline removes the friction and lets you focus on building rather than deploying. If you’re experimenting with your own automation or using the Admin API in other creative ways, I’d love to hear about it—drop a comment and share what you’re building.
Source code can be downloaded from GitHub AardvarkMan/Business-Central-Powershell-Examples: Examples of PowerShell commands to manage Business Central.





Leave a comment