Unlocking Direct Microsoft Store Download Links via an Undocumented API

Get direct Microsoft Store download links straight from Microsoft’s own servers — no store.rg-adguard.net required

This one started, as these things always do, with something that had nothing to do with the Microsoft Store at all.

I recently switched my daily driver to Linux, which meant giving up the Outlook desktop app. For device management I’m using Himmelblau, an open-source identity bridge that connects the machine to Microsoft Entra ID and Intune — it enrols the device, applies compliance policy, and logs me in with my Entra ID (365) account, just like on Windows. Its himmelblaud-broker component also runs as a local D-Bus SSO broker, so my browser and other apps — like Teams for Linux — can silently acquire an authentication token off the back of that sign-in, instead of prompting me separately. If you’re thinking about making the switch yourself, it’s well worth a look.

On the mail side, the replacement I landed on is Prospect Mail, an Electron wrapper around the Outlook web app that adds desktop notifications and gives it a more native desktop-app feel, rather than feeling like a pinned browser tab.

I like familiarity with the applications I use, so having the official icons in place makes the switch feel a bit more seamless. Prospect Mail ships with its own icons, which are nice enough, but I still prefer having the official ones. That meant I wanted a custom build and deployment script for it, using the real Microsoft Outlook icons pulled straight out of the actual Outlook for Windows app.

So I needed to get hold of the real Outlook for Windows .msix package. The go-to answer here is store.rg-adguard.net, but it doesn’t offer an API — just a web page — so scripting against it would mean scraping HTML, which isn’t something I wanted to build a dependency on. I wanted my script talking to Microsoft directly instead. Turns out this is a hole I’d half-fallen down before — a few years back I’d poked at the Store’s internals, made some scrappy progress, and then life happened and it got shelved along with a folder of half-finished notes.

So I picked it back up. Reviewed the old notes, did a lot more googling, and pointed Fiddler at winget to see what it was actually sending over the wire — which first requires enabling the certificate-pinning bypass:

PowerShell
winget settings --enable BypassCertificatePinningForMicrosoftStore

A small, deliberate act of self-sabotage prevention on Microsoft’s part, which I appreciated right up until it stood between me and my own traffic capture.

Between the old notes, lots more googling, and the traffic capture, the shape of the actual pipeline started coming together — and once it clicked, reimplementing it in PowerShell with zero third-party dependencies was the easy part. So here’s how the Microsoft Store’s “download API” actually works, how to talk to it directly, and how this whole thing ends. Stick around for that bit.

There is no Microsoft Store download API

The natural first assumption is that there’s some REST endpoint somewhere that takes a Product ID and hands back a URL. The capture says otherwise. When winget installs an app it’s sourced as msstore, the first call it makes — storeedgefd.dsx.mp.microsoft.com/v9.0/packageManifests/{id} — comes back with almost nothing useful for our purposes: an installer type of msstore, the app’s PackageFamilyName, the markets it’s sold in. No download URL anywhere in it.

From there, winget effectively runs the real Store install flow: authenticate via login.live.com, check the account’s entitlements via collections.mp.microsoft.com, place a (free) order via purchase.mp.microsoft.com, fetch a DRM license from licensing.mp.microsoft.com — and only then does it make a SOAP call:

Plaintext
POST https://tas01.cwsapp.update.microsoft.com/ClientWebService/client.asmx/secured
Action: http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/GetExtendedUpdateInfo2

That’s the smoking gun: same action name, same envelope shape, same UpdateIdentity/RevisionNumber structure used to service ordinary Windows OS patches. Whatever’s issuing this download link is running the Windows Update Services protocol, not some bespoke Store API.

It’s not quite the same door this script ends up using, though, and that gap took a bit more digging to close. winget’s call lands on tas01.cwsapp.update.microsoft.com, riding a fully authenticated session off the back of that login/entitlement/licensing dance — try the same request anonymously and you just get bounced, no ticket, no dice. So: back to Google. Searching around GetExtendedUpdateInfo2 and ClientWebService turned up Microsoft’s own [MS-WUSP] protocol specification — confirmation this really is the general Windows Update Services protocol, not something Store-specific, but it stops short of anything app-category related.

The next lead came from a more mundane place: Microsoft’s own “Manage connection endpoints” documentation, published for enterprises that need to allowlist Windows traffic through their firewalls. Buried in the endpoint list is *.delivery.mp.microsoft.com, described as being used “to download operating system patches, updates, and apps from Microsoft.” Apps, right there, on the same wildcard as OS patches — which is exactly the crossover this whole theory needed. A Microsoft Q&A thread turned up shortly after, from someone else hitting certificate chain issues on the specific host fe3cr.delivery.mp.microsoft.com — enough to confirm it was a real, reachable endpoint, not a dead end.

Knowing the hostname is one thing; knowing what to send it is another. Getting the exact request shape right — the undocumented FilterAppCategoryIds parameter, the specific DeviceAttributes string format, the baseline category IDs the server silently expects — took cross-referencing against a couple of open-source projects that had clearly done the same reverse-engineering already: StoreListings and MS-Store-API. Full credit for those payload shapes below, in Step 2.

That splits the problem this script needs to solve into two much more tractable ones:

  • Turn the Product ID (the 9NRX63209R7B you see in a Store link) into a WuCategoryId — a GUID identifying that app inside Windows Update’s category tree.
  • Ask Windows Update’s SOAP service for that category’s current package revisions, then ask again for a signed, time-limited download URL for each file.

Step one: DisplayCatalog, Product ID → WuCategoryId

This part is a plain, unauthenticated JSON API — no SOAP, no session state, refreshingly normal:

PowerShell
$dcatUrl = "https://displaycatalog.mp.microsoft.com/v7.0/products/9NRX63209R7B?market=US&languages=en-US,en&fieldsTemplate=Details"
$dcat = Invoke-RestMethod -Uri $dcatUrl -Method Get
$categoryId = $dcat.Product.DisplaySkuAvailabilities[0].Sku.Properties.FulfillmentData.WuCategoryId

For Outlook for Windows that resolves to a fixed GUID that never changes between requests — good, because it means the interesting part of the pipeline hasn’t even started yet.

The same endpoint also has a lookup variant that resolves a PackageFamilyName straight to a Product ID, which turned out to be the missing piece for matching rg-adguard’s input flexibility later on:

PowerShell
$lookupUrl = "https://displaycatalog.mp.microsoft.com/v7.0/products/lookup?value=Microsoft.OutlookforWindows_8wekyb3d8bbwe&market=US&languages=en-US,en&alternateId=PackageFamilyName&fieldsTemplate=Details"
$productId = (Invoke-RestMethod -Uri $lookupUrl -Method Get).Products[0].ProductId

Step two: FE3, the actual Windows Update SOAP service

This is where it gets a bit old-school. FE3 (fe3cr.delivery.mp.microsoft.com) speaks SOAP 1.2 over WS-Addressing, with WS-Security header stubs that need to be present and correctly shaped even though no real authentication happens for anonymous app lookups. Three calls, in strict order:

  • GetCookie — hands back an anonymous, encrypted session cookie. No inputs worth caring about.
  • SyncUpdates — the workhorse. Scoped to the WuCategoryId via FilterAppCategoryIds, it returns every current package “revision” for that app: filenames, SHA-1/SHA-256 digests, sizes, and stable identity GUIDs — but crucially not a download URL yet.
  • GetExtendedUpdateInfo2 — takes the revision identities from the previous call and returns the actual signed CDN URLs.

Some of this is genuinely documented: Microsoft’s own [MS-WUSP] protocol specification covers GetCookie, SyncUpdates, and GetExtendedUpdateInfo2 at the general WSUS level. What it does not cover is the specific parameter shape needed to scope a sync to a single Store app category — things like FilterAppCategoryIds, TreatAppCategoryIdsAsInstalled, and the exact DeviceAttributes string format. That part is undocumented, and the only reason I have a working implementation at all is StoreListings, an open-source, actively maintained C# project that’s clearly done the same reverse-engineering and published the real, working payload shapes. If you want to go deeper than this post, that repo is the primary source.

A trimmed SyncUpdates body looks like this:

XML
<SyncUpdates xmlns="http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService">
  <cookie>
    <Expiration>...</Expiration>
    <EncryptedData>...</EncryptedData>
  </cookie>
  <parameters>
    <InstalledNonLeafUpdateIDs><!-- ~75 fixed baseline category IDs --></InstalledNonLeafUpdateIDs>
    <FilterAppCategoryIds>
      <CategoryIdentifier><Id>9D523EC0-098C-4E19-9109-B08DC9C3829A</Id></CategoryIdentifier>
    </FilterAppCategoryIds>
    <TreatAppCategoryIdsAsInstalled>true</TreatAppCategoryIdsAsInstalled>
    <ProductsParameters>
      <DeviceAttributes>App=WU_STORE;OSSkuId=48;FlightRing=Retail;OSArchitecture=AMD64;...</DeviceAttributes>
    </ProductsParameters>
  </parameters>
</SyncUpdates>

The InstalledNonLeafUpdateIDs block deserves a special mention: it’s a fixed list of roughly 75 integer category IDs that the server apparently expects the client to already “know about” before it’ll resolve app-scoped categories properly. They’re not app-specific — they’re baked-in infrastructure IDs — but omit them and the whole thing quietly returns nothing useful. This is exactly the kind of undocumented, cargo-culted detail you’d never guess at from the spec alone, and exactly why a working reference implementation like StoreListings matters more than the official docs here.

SyncUpdates returns two parallel fragment sets — Core and Extended — keyed by a shared, but session-local, numeric ID. You match Core to Extended by that ID, combine the two XML fragments per revision, and pull the real, stable identity out of the combined result:

PowerShell
[xml]$combined = "<Xml>$extendedFragment$coreFragment</Xml>"
$updateId       = $combined.Xml.UpdateIdentity.UpdateID
$revisionNumber = $combined.Xml.UpdateIdentity.RevisionNumber
$files          = @($combined.Xml.Files.File)

That UpdateID + RevisionNumber pair is the thing you actually want — more on why that distinction matters later, because getting it wrong cost me an afternoon.

Finally, GetExtendedUpdateInfo2 takes that identity and returns a signed CDN URL shaped like:

Plaintext
http://tlu.dl.delivery.mp.microsoft.com/filestreamingservice/files/{guid}?P1={unix_expiry}&P2=404&P3=2&P4={hmac_signature}

P1 is a Unix timestamp for when the signature expires — decode it and you’ll see it lines up almost exactly with your request time plus a fixed TTL. P4 is an HMAC you have no hope of generating yourself; you have to get the whole URL from Microsoft pre-signed, which is the entire reason this three-call dance exists.

Matching (and enhancing) rg-adguard’s flexibility

rg-adguard accepts a Store URL, a Product ID, a PackageFamilyName, or a raw CategoryId, plus a release ring (Retail / RP / Fast / Slow). I wanted feature parity, so the script auto-detects what you’ve given it:

PowerShell
if ($Value -match '^https?://') { $resolvedType = 'Url' }
elseif ($Value -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { $resolvedType = 'CategoryId' }
elseif ($Value -match '_[0-9a-z]{13}$') { $resolvedType = 'PackageFamilyName' }
else { $resolvedType = 'ProductId' }

Ring is just another field inside that same DeviceAttributes string (FlightRing=Fast;IsFlightingEnabled=1;...), so wiring it up was trivial.

I also went a step further than rg-adguard and added an architecture filter (x86 / x64 / arm / arm64 / neutral). Architecture isn’t its own field anywhere in the response — it’s embedded inside the package identity string itself, e.g. Microsoft.OutlookForWindows_1.2026.520.0_x64__8wekyb3d8bbwe — so it has to be pulled out with a regex:

PowerShell
$archMatch = [regex]::Match($revisionIdentifier, '_(x86|x64|arm64|arm|neutral)__')
if ($archMatch.Success) { $revisionArch = $archMatch.Groups[1].Value }

Worth noting: arm64 has to be checked before the shorter arm alternative in that regex, or every ARM64 package gets misclassified as plain ARM. Small trap, easy to miss, exactly the sort of thing that looks fine until you test it against a real multi-architecture app.

Filtering happens before the final URL-resolution step, not after — so asking for just -Architecture x64 also means fewer FE3 round-trips, not just a shorter results table.

What’s actually inside a BlockMap

rg-adguard lists a .BlockMap file alongside every package, and it’s easy to assume it’s some kind of extra download you can skip. It’s not really separate content — it’s a manifest, delivered as an AppxBlockMap.xml that describes the package in roughly 64KB blocks, each with its own hash. It’s what enables:

  • Integrity verification at the block level, not just a single whole-file hash.
  • Differential updates — Windows can diff block hashes between versions and only fetch the blocks that actually changed.
  • Streaming installs — an app can start launching before every block has finished downloading, because already-verified blocks are usable immediately.

It’s identified server-side by PatchingType="DynamicMetadata" on the file entry, which is what the script filters on rather than trusting the filename.

Putting it together

The finished script — Get-StoreDirectLinks.ps1 — chains all of the above into four steps and prints a table matching rg-adguard’s output shape (filename, architecture, expiry, SHA-1, size), with the signed URL available on each returned object. Here it is in full:

PowerShell
[CmdletBinding()]
param(
  [Parameter(Mandatory, Position = 0)]
  [Alias('ProductId', 'Url', 'PackageFamilyName', 'CategoryId')]
  [string]$Id,
  [ValidateSet('Auto', 'Url', 'ProductId', 'PackageFamilyName', 'CategoryId')]
  [string]$IdType = 'Auto',
  [string]$Lang = 'en',
  [string]$Market = 'US',
  [ValidateSet('Retail', 'RP', 'Fast', 'Slow')]
  [string]$Ring = 'Retail',
  [ValidateSet('All', 'x86', 'x64', 'arm', 'arm64', 'neutral')]
  [string]$Architecture = 'All',
  [switch]$Download,
  [string]$OutputPath = (Join-Path (Get-Location) 'StoreDownloads'),
  [switch]$Raw
)
$ErrorActionPreference = 'Stop'
$Fe3Base = 'https://fe3cr.delivery.mp.microsoft.com/ClientWebService/client.asmx'
$Fe3Secured = "$Fe3Base/secured"
$WuNs = 'http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService'
$BaselineNonLeafIds = @(
  1, 2, 3, 10, 11, 17, 19, 2359974, 2359977, 5143990, 5169043, 5169044, 5169047,
  8788830, 8806526, 9125350, 9154769, 10809856, 23110993, 23110994, 23110995,
  23110996, 23110999, 23111000, 23111001, 23111002, 23111003, 23111004,
  24513870, 28880263, 30077688, 30486944, 59830006, 59830007, 59830008,
  60484010, 62450018, 62450019, 62450020, 98959022, 98959023, 98959024,
  98959025, 98959026, 105939029, 105995585, 106017178, 107825194, 117765322,
  129905029, 130040030, 130040031, 130040032, 130040033, 133399034, 138372035,
  138372036, 139536037, 139536038, 139536039, 139536040, 142045136, 158941041,
  158941042, 158941043, 158941044, 159776047, 160733048, 160733049, 160733050,
  160733051, 160733055, 160733056, 161870057, 161870058, 161870059
)
$FlightEnabled = 0
if ($Ring -ne 'Retail') { $FlightEnabled = 1 }
$OsVersion = '10.0.22621.1'
$DeviceAttributes = "BranchReadinessLevel=CB;CurrentBranch=;OEMModel=Virtual Machine;FlightRing=$Ring;AttrDataVer=21;SystemManufacturer=Microsoft Corporation;InstallLanguage=$Lang-$Market;OSUILocale=$Lang-$Market;InstallationType=Client;FlightingBranchName=;FirmwareVersion=Hyper-V UEFI Release v2.5;SystemProductName=Virtual Machine;OSSkuId=48;FlightContent=Mainline;App=WU_STORE;OEMName_Uncleaned=Microsoft Corporation;AppVer=0.0.0.0;OSArchitecture=AMD64;SystemSKU=None;UpdateManagementGroup=2;IsFlightingEnabled=$FlightEnabled;IsDeviceRetailDemo=0;TelemetryLevel=3;OSVersion=$OsVersion;DeviceFamily=Windows.Desktop;"
function Write-RawDump {
  param([string]$Name, [string]$Content)
  if ($Raw) {
    $path = Join-Path (Get-Location) "$Name.xml"
    Set-Content -Path $path -Value $Content -Encoding UTF8
    Write-Verbose "Dumped $Name -> $path"
  }
}
function Invoke-Fe3Soap {
  param([string]$Uri, [string]$Body, [string]$Name)
  $headers = @{
    'User-Agent' = 'Windows-Update-Agent/10.0.10011.16384 Client-Protocol/2.1'
    'Connection' = 'keep-alive'
  }
  try {
    $resp = Invoke-WebRequest -Uri $Uri -Method Post -Headers $headers `
      -ContentType 'application/soap+xml; charset=utf-8' -Body $Body -UseBasicParsing
    Write-RawDump -Name $Name -Content $resp.Content
    return $resp.Content
  }
  catch {
    $errBody = $null
    if ($_.Exception.Response) {
      try {
        $stream = $_.Exception.Response.GetResponseStream()
        $reader = New-Object System.IO.StreamReader($stream)
        $errBody = $reader.ReadToEnd()
      }
      catch {}
    }
    if ($errBody) { Write-RawDump -Name "$Name-fault" -Content $errBody }
    $msg = "SOAP call '$Name' failed: $($_.Exception.Message)"
    if ($errBody) { $msg += "`n$errBody" }
    throw $msg
  }
}
function Get-XmlValue {
  param([string]$Text, [string]$Tag)
  $m = [regex]::Match($Text, "<$Tag>([^<]*)</$Tag>")
  if ($m.Success) { return [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) }
  return $null
}
function Get-XmlBlock {
  param([string]$Text, [string]$Tag)
  $m = [regex]::Match($Text, "<$Tag>([\s\S]*?)</$Tag>")
  if ($m.Success) { return $m.Groups[1].Value }
  return $null
}
function ConvertTo-HexDigest {
  param([string]$Base64Digest)
  if (-not $Base64Digest) { return $null }
  try {
    $bytes = [Convert]::FromBase64String($Base64Digest)
    return ([BitConverter]::ToString($bytes) -replace '-', '').ToLower()
  }
  catch {
    return $Base64Digest
  }
}
function Format-FileSize {
  param([long]$Bytes)
  if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) }
  if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) }
  return "$Bytes B"
}
function Get-UrlExpiry {
  param([string]$Url)
  $m = [regex]::Match($Url, '[?&]P1=(\d+)')
  if ($m.Success) {
    $epoch = [long]$m.Groups[1].Value
    return [DateTimeOffset]::FromUnixTimeSeconds($epoch).UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss') + ' GMT'
  }
  return '1970-01-01 00:00:00 GMT'
}
function Resolve-ProductIdFromUrl {
  param([string]$Url)
  $m = [regex]::Match($Url, '\b([0-9A-Za-z]{12})\b(?:[/?#]|$)')
  if (-not $m.Success) {
    throw "Couldn't find a 12-character Product ID in URL '$Url'."
  }
  return $m.Groups[1].Value.ToUpper()
}
function Resolve-WuCategoryId {
  param(
    [string]$Value,
    [string]$Type,
    [string]$Market,
    [string]$Lang
  )
  $resolvedType = $Type
  if ($resolvedType -eq 'Auto') {
    if ($Value -match '^https?://') {
      $resolvedType = 'Url'
    }
    elseif ($Value -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
      $resolvedType = 'CategoryId'
    }
    elseif ($Value -match '_[0-9a-z]{13}$') {
      $resolvedType = 'PackageFamilyName'
    }
    else {
      $resolvedType = 'ProductId'
    }
  }
  Write-Verbose "Resolved input type: $resolvedType"
  if ($resolvedType -eq 'Url') {
    $productId = Resolve-ProductIdFromUrl -Url $Value
    return Resolve-WuCategoryId -Value $productId -Type 'ProductId' -Market $Market -Lang $Lang
  }
  if ($resolvedType -eq 'CategoryId') {
    return [PSCustomObject]@{ WuCategoryId = $Value; PackageIdentityName = $null }
  }
  if ($resolvedType -eq 'PackageFamilyName') {
    $lookupUrl = "https://displaycatalog.mp.microsoft.com/v7.0/products/lookup?value=$Value&market=$Market&languages=$Lang-$Market,$Lang&alternateId=PackageFamilyName&fieldsTemplate=Details"
    $lookup = Invoke-RestMethod -Uri $lookupUrl -Method Get
    if (-not $lookup.Products -or $lookup.Products.Count -eq 0) {
      throw "No product found for PackageFamilyName '$Value'."
    }
    $productId = $lookup.Products[0].ProductId
    Write-Verbose "PackageFamilyName '$Value' -> ProductId '$productId'"
    return Resolve-WuCategoryId -Value $productId -Type 'ProductId' -Market $Market -Lang $Lang
  }
  $dcatUrl = "https://displaycatalog.mp.microsoft.com/v7.0/products/$Value`?market=$Market&languages=$Lang-$Market,$Lang&fieldsTemplate=Details"
  $dcat = Invoke-RestMethod -Uri $dcatUrl -Method Get
  if (-not $dcat.Product) {
    throw "DisplayCatalog returned no product for '$Value'. Check the ProductId."
  }
  $sku = $dcat.Product.DisplaySkuAvailabilities[0].Sku
  $categoryId = $sku.Properties.FulfillmentData.WuCategoryId
  if (-not $categoryId) {
    throw "No WuCategoryId found for '$Value' - this product may not ship as an MSIX/Appx package."
  }
  return [PSCustomObject]@{
    WuCategoryId        = $categoryId
    PackageIdentityName = $dcat.Product.Properties.PackageIdentityName
  }
}
Write-Host "[1/4] Resolving WuCategoryId..." -ForegroundColor Cyan
$resolved = Resolve-WuCategoryId -Value $Id -Type $IdType -Market $Market -Lang $Lang
$wuCategoryId = $resolved.WuCategoryId
Write-Host "      WuCategoryId = $wuCategoryId" -ForegroundColor DarkGray
Write-Host "[2/4] Requesting anonymous FE3 cookie..." -ForegroundColor Cyan
$now = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
$getCookieBody = @"
<Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://www.w3.org/2003/05/soap-envelope">
  <Header>
    <Action d3p1:mustUnderstand="1" xmlns:d3p1="http://www.w3.org/2003/05/soap-envelope" xmlns="http://www.w3.org/2005/08/addressing">http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/GetCookie</Action>
    <MessageID xmlns="http://www.w3.org/2005/08/addressing">urn:uuid:$([guid]::NewGuid())</MessageID>
    <To d3p1:mustUnderstand="1" xmlns:d3p1="http://www.w3.org/2003/05/soap-envelope" xmlns="http://www.w3.org/2005/08/addressing">https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx</To>
    <Security d3p1:mustUnderstand="1" xmlns:d3p1="http://www.w3.org/2003/05/soap-envelope" xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
      <Timestamp xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
        <Created>$now</Created>
        <Expires>2044-08-02T20:09:03Z</Expires>
      </Timestamp>
      <WindowsUpdateTicketsToken d4p1:id="ClientMSA" xmlns:d4p1="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" xmlns="http://schemas.microsoft.com/msus/2014/10/WindowsUpdateAuthorization"></WindowsUpdateTicketsToken>
    </Security>
  </Header>
  <Body>
    <GetCookie xmlns="$WuNs">
      <oldCookie>
        <Expiration>2016-07-27T07:18:09Z</Expiration>
      </oldCookie>
      <lastChange>2015-10-21T17:01:07.1472913Z</lastChange>
      <currentTime>$now</currentTime>
      <protocolVersion>2.50</protocolVersion>
    </GetCookie>
  </Body>
</Envelope>
"@
$cookieResp = Invoke-Fe3Soap -Uri $Fe3Base -Body $getCookieBody -Name 'GetCookie'
$cookieData = Get-XmlValue -Text $cookieResp -Tag 'EncryptedData'
$cookieExp = Get-XmlValue -Text $cookieResp -Tag 'Expiration'
if (-not $cookieData) { throw "Failed to obtain FE3 cookie. Run with -Raw to inspect the response." }
Write-Host "[3/4] Syncing update metadata for category..." -ForegroundColor Cyan
$installedNonLeaf = ($BaselineNonLeafIds | ForEach-Object { "<int>$_</int>" }) -join "`n"
$syncBody = @"
<s:Envelope xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:s="http://www.w3.org/2003/05/soap-envelope">
  <s:Header>
    <a:Action s:mustUnderstand="1">http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/SyncUpdates</a:Action>
    <a:MessageID>urn:uuid:$([guid]::NewGuid())</a:MessageID>
    <a:To s:mustUnderstand="1">https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx</a:To>
    <o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
      <Timestamp xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
        <Created>$now</Created>
        <Expires>2044-08-02T20:09:03Z</Expires>
      </Timestamp>
      <wuws:WindowsUpdateTicketsToken wsu:id="ClientMSA" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" xmlns:wuws="http://schemas.microsoft.com/msus/2014/10/WindowsUpdateAuthorization"></wuws:WindowsUpdateTicketsToken>
    </o:Security>
  </s:Header>
  <s:Body>
    <SyncUpdates xmlns="$WuNs">
      <cookie>
        <Expiration>$cookieExp</Expiration>
        <EncryptedData>$cookieData</EncryptedData>
      </cookie>
      <parameters>
        <ExpressQuery>false</ExpressQuery>
        <InstalledNonLeafUpdateIDs>
          $installedNonLeaf
        </InstalledNonLeafUpdateIDs>
        <OtherCachedUpdateIDs></OtherCachedUpdateIDs>
        <SkipSoftwareSync>false</SkipSoftwareSync>
        <NeedTwoGroupOutOfScopeUpdates>true</NeedTwoGroupOutOfScopeUpdates>
        <FilterAppCategoryIds>
          <CategoryIdentifier>
            <Id>$wuCategoryId</Id>
          </CategoryIdentifier>
        </FilterAppCategoryIds>
        <TreatAppCategoryIdsAsInstalled>true</TreatAppCategoryIdsAsInstalled>
        <AlsoPerformRegularSync>false</AlsoPerformRegularSync>
        <ComputerSpec />
        <ExtendedUpdateInfoParameters>
          <XmlUpdateFragmentTypes>
            <XmlUpdateFragmentType>Extended</XmlUpdateFragmentType>
            <XmlUpdateFragmentType>Published</XmlUpdateFragmentType>
            <XmlUpdateFragmentType>Core</XmlUpdateFragmentType>
          </XmlUpdateFragmentTypes>
          <Locales>
            <string>$Lang-$Market</string>
            <string>$Lang</string>
          </Locales>
        </ExtendedUpdateInfoParameters>
        <ClientPreferredLanguages>
          <string>$Lang-$Market</string>
        </ClientPreferredLanguages>
        <ProductsParameters>
          <SyncCurrentVersionOnly>false</SyncCurrentVersionOnly>
          <DeviceAttributes>$DeviceAttributes</DeviceAttributes>
          <CallerAttributes>Interactive=1;IsSeeker=0;</CallerAttributes>
          <Products />
        </ProductsParameters>
      </parameters>
    </SyncUpdates>
  </s:Body>
</s:Envelope>
"@
$syncResp = Invoke-Fe3Soap -Uri $Fe3Base -Body $syncBody -Name 'SyncUpdates'
$newCookieBlock = Get-XmlBlock -Text $syncResp -Tag 'NewCookie'
if ($newCookieBlock) {
  $syncCookieData = Get-XmlValue -Text $newCookieBlock -Tag 'EncryptedData'
  $syncCookieExp = Get-XmlValue -Text $newCookieBlock -Tag 'Expiration'
  if ($syncCookieData) {
    $cookieData = $syncCookieData
    $cookieExp = $syncCookieExp
  }
}
$newUpdatesBlock = Get-XmlBlock -Text $syncResp -Tag 'NewUpdates'
$extendedBlock = Get-XmlBlock -Text $syncResp -Tag 'ExtendedUpdateInfo'
if (-not $newUpdatesBlock -or -not $extendedBlock) {
  Write-Warning "SyncUpdates returned no updates for this category/ring. Run with -Raw and check SyncUpdates.xml - Microsoft may have changed the request shape, or this app has no packages for Ring '$Ring'."
  return
}
$coreById = @{}
foreach ($m in [regex]::Matches($newUpdatesBlock, '<UpdateInfo>([\s\S]*?)</UpdateInfo>')) {
  $id = Get-XmlValue -Text $m.Groups[1].Value -Tag 'ID'
  $xml = Get-XmlValue -Text $m.Groups[1].Value -Tag 'Xml'
  if ($id) { $coreById[$id] = $xml }
}
$extById = @{}
$extUpdatesBlock = Get-XmlBlock -Text $extendedBlock -Tag 'Updates'
foreach ($m in [regex]::Matches($extUpdatesBlock, '<Update>([\s\S]*?)</Update>')) {
  $id = Get-XmlValue -Text $m.Groups[1].Value -Tag 'ID'
  $xml = Get-XmlValue -Text $m.Groups[1].Value -Tag 'Xml'
  if ($id) { $extById[$id] = $xml }
}
$packages = New-Object System.Collections.Generic.List[object]
foreach ($id in $extById.Keys) {
  if (-not $coreById.ContainsKey($id)) { continue }
  $extXml = $extById[$id]
  $coreXml = $coreById[$id]
  if ($extXml -notmatch '<Files' -or $coreXml -notmatch 'SecuredFragment') { continue }
  try {
    [xml]$combined = "<Xml>$extXml$coreXml</Xml>"
  }
  catch {
    Write-Verbose "Skipping revision $id - couldn't parse combined fragment: $_"
    continue
  }
  $identity = $combined.Xml.UpdateIdentity
  if (-not $identity) { continue }
  $updateId = $identity.UpdateID
  $revisionNumber = $identity.RevisionNumber
  $extProps = $combined.Xml.ExtendedProperties
  $pkgIdentity = $extProps.PackageIdentityName
  $files = @($combined.Xml.Files.File)
  $revisionIdentifier = $pkgIdentity
  foreach ($f in $files) {
    if ($f -and $f.InstallerSpecificIdentifier) {
      $revisionIdentifier = $f.InstallerSpecificIdentifier
      break
    }
  }
  $revisionArch = 'unknown'
  $archMatch = [regex]::Match($revisionIdentifier, '_(x86|x64|arm64|arm|neutral)__')
  if ($archMatch.Success) { $revisionArch = $archMatch.Groups[1].Value }
  foreach ($file in $files) {
    if (-not $file) { continue }
    $origName = $file.FileName
    $identifier = $file.InstallerSpecificIdentifier
    $digest = $file.Digest
    $digestAlgo = $file.DigestAlgorithm
    [long]$sizeBytes = 0
    if ($file.Size) { [void][long]::TryParse($file.Size, [ref]$sizeBytes) }
    $isBlockMap = ($file.PatchingType -eq 'DynamicMetadata') -or ($origName -match '^Abm_')
    if ($isBlockMap) {
      $realName = "$revisionIdentifier.BlockMap"
    }
    elseif ($origName -match '\.cab$') {
      continue
    }
    else {
      $ext = [System.IO.Path]::GetExtension($origName)
      $realName = $origName
      if ($identifier) { $realName = "$identifier$ext" }
    }
    $packages.Add([PSCustomObject]@{
        FileName            = $realName
        UpdateID            = $updateId
        RevisionNumber      = $revisionNumber
        Digest              = $digest
        DigestAlgorithm     = $digestAlgo
        SizeBytes           = $sizeBytes
        PackageIdentityName = $pkgIdentity
        Architecture        = $revisionArch
      })
  }
}
if ($packages.Count -eq 0) {
  Write-Warning "No installable files found for this product/ring. Run with -Raw and inspect SyncUpdates.xml."
  return
}
if ($Architecture -ne 'All') {
  $availableArch = ($packages.Architecture | Sort-Object -Unique) -join ', '
  $filteredPackages = @($packages | Where-Object { $_.Architecture -eq $Architecture })
  if ($filteredPackages.Count -eq 0) {
    Write-Warning "No files found for architecture '$Architecture'. Available for this app/ring: $availableArch"
    return
  }
  $packages = $filteredPackages
  Write-Host "      Filtered to architecture '$Architecture' ($($packages.Count) file(s)); available: $availableArch" -ForegroundColor DarkGray
}
else {
  Write-Host "      Found $($packages.Count) candidate file(s)." -ForegroundColor DarkGray
}
Write-Host "[4/4] Resolving direct download URLs..." -ForegroundColor Cyan
$results = New-Object System.Collections.Generic.List[object]
$byRevision = $packages | Group-Object -Property UpdateID, RevisionNumber
foreach ($group in $byRevision) {
  $first = $group.Group[0]
  $infoBody = @"
<s:Envelope xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:s="http://www.w3.org/2003/05/soap-envelope">
  <s:Header>
    <a:Action s:mustUnderstand="1">http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/GetExtendedUpdateInfo2</a:Action>
    <a:MessageID>urn:uuid:$([guid]::NewGuid())</a:MessageID>
    <a:To s:mustUnderstand="1">https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx/secured</a:To>
    <o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
      <Timestamp xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
        <Created>$now</Created>
        <Expires>2044-08-02T20:09:03Z</Expires>
      </Timestamp>
      <wuws:WindowsUpdateTicketsToken wsu:id="ClientMSA" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" xmlns:wuws="http://schemas.microsoft.com/msus/2014/10/WindowsUpdateAuthorization"></wuws:WindowsUpdateTicketsToken>
    </o:Security>
  </s:Header>
  <s:Body>
    <GetExtendedUpdateInfo2 xmlns="$WuNs">
      <cookie>
        <Expiration>$cookieExp</Expiration>
        <EncryptedData>$cookieData</EncryptedData>
      </cookie>
      <updateIDs>
        <UpdateIdentity>
          <UpdateID>$($first.UpdateID)</UpdateID>
          <RevisionNumber>$($first.RevisionNumber)</RevisionNumber>
        </UpdateIdentity>
      </updateIDs>
      <infoTypes>
        <XmlUpdateFragmentType>FileUrl</XmlUpdateFragmentType>
        <XmlUpdateFragmentType>FileDecryption</XmlUpdateFragmentType>
      </infoTypes>
      <deviceAttributes>$DeviceAttributes</deviceAttributes>
    </GetExtendedUpdateInfo2>
  </s:Body>
</s:Envelope>
"@
  $infoResp = Invoke-Fe3Soap -Uri $Fe3Secured -Body $infoBody -Name "GetExtendedUpdateInfo2-$($first.UpdateID)"
  $fileLocations = [regex]::Matches($infoResp, '<FileLocation>([\s\S]*?)</FileLocation>')
  foreach ($pkg in $group.Group) {
    $url = $null
    foreach ($locMatch in $fileLocations) {
      $digest = Get-XmlValue -Text $locMatch.Groups[1].Value -Tag 'FileDigest'
      if ($digest -eq $pkg.Digest) {
        $url = Get-XmlValue -Text $locMatch.Groups[1].Value -Tag 'Url'
        break
      }
    }
    if ($url) {
      $results.Add([PSCustomObject]@{
          FileName     = $pkg.FileName
          Architecture = $pkg.Architecture
          Expire       = Get-UrlExpiry -Url $url
          SHA1         = ConvertTo-HexDigest -Base64Digest $pkg.Digest
          Size         = Format-FileSize -Bytes $pkg.SizeBytes
          Url          = $url
        })
    }
    else {
      Write-Verbose "No URL resolved for $($pkg.FileName) (digest mismatch or not in FileLocations)."
    }
  }
}
if ($results.Count -eq 0) {
  Write-Warning "Resolved 0 direct URLs. Run with -Raw and inspect GetExtendedUpdateInfo2-*.xml."
  return
}
if ($Download) {
  if (-not (Test-Path $OutputPath)) {
    New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
  }
  foreach ($item in $results) {
    $dest = Join-Path $OutputPath $item.FileName
    Write-Host "Downloading $($item.FileName)..." -ForegroundColor Yellow
    Invoke-WebRequest -Uri $item.Url -OutFile $dest
  }
  Write-Host "Done. Files saved to $OutputPath" -ForegroundColor Green
}
return $results

Usage Examples:

PowerShell
# Just a Product ID - resolves every architecture on the Retail ring
.\Get-StoreDirectLinks.ps1 9NRX63209R7B

# A full Store URL instead, on the Fast (Insider) ring instead of Retail
.\Get-StoreDirectLinks.ps1 "https://apps.microsoft.com/detail/9NRX63209R7B" -Ring Fast

# A PackageFamilyName instead of a Product ID (-IdType required here)
.\Get-StoreDirectLinks.ps1 "Microsoft.OutlookforWindows_8wekyb3d8bbwe" -IdType PackageFamilyName

# A WuCategoryId GUID directly, skipping DisplayCatalog, then download everything
.\Get-StoreDirectLinks.ps1 "9d523ec0-098c-4e19-9109-b08dc9c3829a" -IdType CategoryId -Download

# Only the x64 build (fewer FE3 calls), and download it straight away
.\Get-StoreDirectLinks.ps1 9NRX63209R7B -Architecture x64 -Download

It talks to nothing but displaycatalog.mp.microsoft.com and fe3cr.delivery.mp.microsoft.com, and ships with a -Raw switch that dumps every intermediate SOAP response to disk, since none of this is officially documented end-to-end and Microsoft is free to change field shapes without warning.

Final Thoughts

So, the actual reason I went down this hole: I wanted the real Microsoft Outlook new-mail notification icon for my Prospect Mail build script. Working API in hand, I built a second script to pull it straight out of the downloaded .msix, extract the icon assets, and slot them into the build.

Except the icon wasn’t there.

Turns out it’s not a static asset bundled with the app at all — Outlook fetches it itself at runtime. I fired up Fiddler to inspect the app’s network traffic, and there it was:

Plaintext
GET https://res.public.onecdn.static.microsoft/assets/native-host/v2/outlook-badge-newmail-win11.ico HTTP/1.1
User-Agent: OneOutlook/1.2026.728.100
Host: res.public.onecdn.static.microsoft

Fetched from a completely different CDN and cached at %LocalAppData%\Microsoft\Olk\cache. All that SOAP archaeology, all that Fiddler, the certificate-pinning bypass, the whole reverse-engineered pipeline in this post — and the one file I actually wanted was never in the package to begin with.

I’m not even mad. This is exactly how these things go. The API works, it’s genuinely useful, and it’s a solid chunk of Microsoft’s undocumented internals mapped out and reusable — I just didn’t need any of it for the one icon I actually wanted, which turned out to be a single anonymous GET request away the entire time. Some might say this is overkill for a notification icon. Those people are correct, and I’d do it again.

Sources and credits:

  • StoreListings — the source of the real, working SOAP payload shapes used here.
  • ThomasPe/MS-Store-API — another public reverse-engineering effort, used as a cross-check.
  • [MS-WUSP] protocol specification — the officially documented parts of the underlying Windows Update SOAP service.
  • store.rg-adguard.net — the tool that made this whole area of the Store worth investigating in the first place.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *