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. 1. Provide a target Environment to install to.
  2. 2. Provide a folder containing several .app files to install.
  3. 3. The system should install them in dependency order.
  4. 4. The system should not attempt to install older versions of the .app file.
  5. 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. 1. Authenticates against Azure AD using client credentials.
  2. 2. Reads a folder of .app files.
  3. 3. Extracts metadata from each extension, including dependencies.
  4. 4. Sorts the extensions in dependency order.
  5. 5. Retrieves currently installed extensions from the target environment.
  6. 6. Compares versions and installs only newer packages.
  7. 7. Uploads each extension using the pteInstall endpoint.
  8. 8. Monitors installation status until completion.
  9. 9. Retrieving Installed Extensions
  10. 10. Installing Extensions in Dependency Order
  11. 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. 1. Check if it is already installed.
  2. 2. Compare versions.
  3. 3. Skip if the installed version is newer or equal.
  4. 4. Upload the .app file using multipart form‑data.
  5. 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

Trending