|
| 1 | +# Maintain and Replace SharePoint List Webhook Subscriptions |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +This PowerShell script helps maintain and replace SharePoint list webhook subscriptions across multiple sites. It identifies existing webhook subscriptions, removes old webhook URLs, and replaces them with updated Power Automate webhook subscriptions. The script includes retry logic for reliability and exports detailed results to a CSV file. |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | +The script provides a comprehensive solution for managing webhook subscriptions in SharePoint, ensuring that organizations can keep their integrations up to date and functioning smoothly. |
| 10 | + |
| 11 | +# [PnP PowerShell](#tab/pnpps) |
| 12 | + |
| 13 | +```powershell |
| 14 | +
|
| 15 | +<# |
| 16 | +.SYNOPSIS |
| 17 | + Maintain and replace SharePoint list webhook subscriptions across multiple sites. |
| 18 | +
|
| 19 | +.DESCRIPTION |
| 20 | + This script checks SharePoint sites for a specific list, identifies existing webhook subscriptions, |
| 21 | + removes old webhook URLs, and replaces them with updated Power Automate webhook subscriptions. |
| 22 | + |
| 23 | + Features: |
| 24 | + - Connects to multiple SharePoint sites |
| 25 | + - Identifies and removes old webhook URLs |
| 26 | + - Adds new webhook subscriptions with configurable expiration |
| 27 | + - Implements retry logic for reliability |
| 28 | + - Exports detailed results to CSV |
| 29 | + - Provides comprehensive logging |
| 30 | +
|
| 31 | +.PARAMETER SiteUrls |
| 32 | + Array of SharePoint site URLs to process. Can be provided directly or from a CSV file. |
| 33 | +
|
| 34 | +.PARAMETER ListName |
| 35 | + Name of the SharePoint list to check for webhooks (default: "Documents") |
| 36 | +
|
| 37 | +.PARAMETER OldWebhookUrl |
| 38 | + The old webhook URL that should be replaced (optional) |
| 39 | +
|
| 40 | +.PARAMETER NewWebhookUrl |
| 41 | + The new webhook URL to add |
| 42 | +
|
| 43 | +.PARAMETER ClientId |
| 44 | + Azure AD Application (Client) ID for authentication |
| 45 | +
|
| 46 | +.PARAMETER Tenant |
| 47 | + Tenant name (e.g., contoso.onmicrosoft.com) |
| 48 | +
|
| 49 | +.PARAMETER Thumbprint |
| 50 | + Certificate thumbprint for authentication (optional, if using certificate auth) |
| 51 | +
|
| 52 | +.PARAMETER ExpirationDays |
| 53 | + Number of days until webhook expiration (default: 179, max: 180) |
| 54 | +
|
| 55 | +.PARAMETER MaxRetries |
| 56 | + Maximum number of retry attempts for adding webhooks (default: 3) |
| 57 | +
|
| 58 | +.PARAMETER RetryDelaySeconds |
| 59 | + Delay in seconds between retry attempts (default: 5) |
| 60 | +
|
| 61 | +.PARAMETER OutputPath |
| 62 | + Path for the output CSV report (default: current directory) |
| 63 | +
|
| 64 | +.EXAMPLE |
| 65 | + .\script.ps1 -SiteUrls @("https://contoso.sharepoint.com/sites/site1", "https://contoso.sharepoint.com/sites/site2") ` |
| 66 | + -ListName "Documents" ` |
| 67 | + -NewWebhookUrl "https://prod.westeurope.logic.azure.com:443/workflows/abc123/triggers/manual/paths/invoke..." ` |
| 68 | + -ClientId "12345678-1234-1234-1234-123456789abc" ` |
| 69 | + -Tenant "contoso.onmicrosoft.com" ` |
| 70 | + -Thumbprint "ABC123..." |
| 71 | +
|
| 72 | +.EXAMPLE |
| 73 | + # Using interactive authentication |
| 74 | + .\script.ps1 -SiteUrls @("https://contoso.sharepoint.com/sites/site1") ` |
| 75 | + -ListName "Media" ` |
| 76 | + -OldWebhookUrl "https://old-webhook-url.com/..." ` |
| 77 | + -NewWebhookUrl "https://new-webhook-url.com/..." |
| 78 | +
|
| 79 | +.NOTES |
| 80 | + Author: Valeras Narbutas |
| 81 | + GitHub: ValerasNarbutas |
| 82 | + Requires: PnP PowerShell module |
| 83 | +#> |
| 84 | +
|
| 85 | +[CmdletBinding()] |
| 86 | +param( |
| 87 | + [Parameter(Mandatory = $true, HelpMessage = "Array of SharePoint site URLs to process")] |
| 88 | + [ValidateNotNullOrEmpty()] |
| 89 | + [string[]]$SiteUrls, |
| 90 | + |
| 91 | + [Parameter(Mandatory = $false, HelpMessage = "Name of the SharePoint list")] |
| 92 | + [string]$ListName = "Documents", |
| 93 | + |
| 94 | + [Parameter(Mandatory = $false, HelpMessage = "Old webhook URL to replace (optional)")] |
| 95 | + [string]$OldWebhookUrl, |
| 96 | + |
| 97 | + [Parameter(Mandatory = $true, HelpMessage = "New webhook URL to add")] |
| 98 | + [ValidateNotNullOrEmpty()] |
| 99 | + [string]$NewWebhookUrl, |
| 100 | + |
| 101 | + [Parameter(Mandatory = $false, HelpMessage = "Azure AD Application (Client) ID")] |
| 102 | + [string]$ClientId, |
| 103 | + |
| 104 | + [Parameter(Mandatory = $false, HelpMessage = "Tenant name (e.g., contoso.onmicrosoft.com)")] |
| 105 | + [string]$Tenant, |
| 106 | + |
| 107 | + [Parameter(Mandatory = $false, HelpMessage = "Certificate thumbprint for authentication")] |
| 108 | + [string]$Thumbprint, |
| 109 | + |
| 110 | + [Parameter(Mandatory = $false, HelpMessage = "Number of days until webhook expiration")] |
| 111 | + [ValidateRange(1, 180)] |
| 112 | + [int]$ExpirationDays = 179, |
| 113 | + |
| 114 | + [Parameter(Mandatory = $false, HelpMessage = "Maximum number of retry attempts")] |
| 115 | + [ValidateRange(1, 10)] |
| 116 | + [int]$MaxRetries = 3, |
| 117 | + |
| 118 | + [Parameter(Mandatory = $false, HelpMessage = "Delay in seconds between retries")] |
| 119 | + [ValidateRange(1, 60)] |
| 120 | + [int]$RetryDelaySeconds = 5, |
| 121 | + |
| 122 | + [Parameter(Mandatory = $false, HelpMessage = "Output path for CSV report")] |
| 123 | + [string]$OutputPath = "." |
| 124 | +) |
| 125 | +
|
| 126 | +# Initialize client state with timestamp |
| 127 | +$clientState = "WebhookUpdate-" + (Get-Date -Format "yyyyMMdd-HHmmss") |
| 128 | +
|
| 129 | +# Initialize results array |
| 130 | +$results = @() |
| 131 | +
|
| 132 | +Write-Host "========================================" -ForegroundColor Cyan |
| 133 | +Write-Host "SharePoint Webhook Subscription Maintenance" -ForegroundColor Cyan |
| 134 | +Write-Host "========================================" -ForegroundColor Cyan |
| 135 | +Write-Host "" |
| 136 | +Write-Host "Configuration:" -ForegroundColor Yellow |
| 137 | +Write-Host " - List Name: $ListName" -ForegroundColor Gray |
| 138 | +Write-Host " - Sites to process: $($SiteUrls.Count)" -ForegroundColor Gray |
| 139 | +Write-Host " - Expiration days: $ExpirationDays" -ForegroundColor Gray |
| 140 | +Write-Host " - Max retries: $MaxRetries" -ForegroundColor Gray |
| 141 | +Write-Host "" |
| 142 | +
|
| 143 | +# Process each site |
| 144 | +foreach ($siteUrl in $SiteUrls) { |
| 145 | + Write-Host "Processing site: $siteUrl" -ForegroundColor Cyan |
| 146 | + |
| 147 | + try { |
| 148 | + # Connect to site using appropriate authentication method |
| 149 | + if ($ClientId -and $Tenant -and $Thumbprint) { |
| 150 | + Write-Host " - Connecting with certificate authentication..." -ForegroundColor Gray |
| 151 | + Connect-PnPOnline -Url $siteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint |
| 152 | + } |
| 153 | + elseif ($ClientId -and $Tenant) { |
| 154 | + Write-Host " - Connecting with interactive authentication..." -ForegroundColor Gray |
| 155 | + Connect-PnPOnline -Url $siteUrl -ClientId $ClientId -Tenant $Tenant -Interactive |
| 156 | + } |
| 157 | + else { |
| 158 | + Write-Host " - Connecting with interactive authentication..." -ForegroundColor Gray |
| 159 | + Connect-PnPOnline -Url $siteUrl -Interactive |
| 160 | + } |
| 161 | + |
| 162 | + # Check if the specified list exists |
| 163 | + $list = Get-PnPList -Identity $ListName -ErrorAction Stop |
| 164 | + |
| 165 | + if ($null -ne $list) { |
| 166 | + Write-Host " - List '$ListName' found" -ForegroundColor Green |
| 167 | + |
| 168 | + # Get existing webhooks for the list |
| 169 | + $webhooks = Get-PnPWebhookSubscriptions -List $ListName |
| 170 | + |
| 171 | + # Initialize tracking variables |
| 172 | + $oldWebhookFound = $false |
| 173 | + $oldWebhookRemoved = $false |
| 174 | + $newWebhookAdded = $false |
| 175 | + $otherWebhooksCount = 0 |
| 176 | + $removedWebhookId = "" |
| 177 | + $newWebhookId = "" |
| 178 | + $newWebhookExpiration = "" |
| 179 | + $newWebhookAlreadyExists = $false |
| 180 | + $retryCount = 0 |
| 181 | + $webhookError = "" |
| 182 | + |
| 183 | + if ($webhooks.Count -gt 0) { |
| 184 | + Write-Host " - Found $($webhooks.Count) existing webhook(s)" -ForegroundColor Gray |
| 185 | + |
| 186 | + # Check existing webhooks |
| 187 | + foreach ($webhook in $webhooks) { |
| 188 | + # Check for old webhook URL (if specified) |
| 189 | + if ($OldWebhookUrl -and $webhook.NotificationUrl -eq $OldWebhookUrl) { |
| 190 | + $oldWebhookFound = $true |
| 191 | + $removedWebhookId = $webhook.Id |
| 192 | + |
| 193 | + Write-Host " - Found old webhook, removing..." -ForegroundColor Yellow |
| 194 | + Remove-PnPWebhookSubscription -List $ListName -Identity $webhook.Id -Force |
| 195 | + $oldWebhookRemoved = $true |
| 196 | + Write-Host " Removed webhook ID: $($webhook.Id)" -ForegroundColor Gray |
| 197 | + } |
| 198 | + # Check if new webhook already exists |
| 199 | + elseif ($webhook.NotificationUrl -eq $NewWebhookUrl) { |
| 200 | + $newWebhookAlreadyExists = $true |
| 201 | + $newWebhookId = $webhook.Id |
| 202 | + $newWebhookExpiration = $webhook.ExpirationDateTime |
| 203 | + Write-Host " - New webhook already exists (ID: $($webhook.Id))" -ForegroundColor Green |
| 204 | + } |
| 205 | + else { |
| 206 | + $otherWebhooksCount++ |
| 207 | + Write-Host " - Found other webhook: $($webhook.Id)" -ForegroundColor Gray |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | + else { |
| 212 | + Write-Host " - No existing webhooks found" -ForegroundColor Gray |
| 213 | + } |
| 214 | + |
| 215 | + # Add new webhook if it doesn't already exist (with retry logic) |
| 216 | + if (-not $newWebhookAlreadyExists) { |
| 217 | + $webhookAddSuccess = $false |
| 218 | + |
| 219 | + for ($retryCount = 0; $retryCount -lt $MaxRetries; $retryCount++) { |
| 220 | + try { |
| 221 | + if ($retryCount -gt 0) { |
| 222 | + Write-Host " - Retry attempt $retryCount of $($MaxRetries - 1)..." -ForegroundColor Yellow |
| 223 | + Start-Sleep -Seconds $RetryDelaySeconds |
| 224 | + } |
| 225 | + else { |
| 226 | + Write-Host " - Adding new webhook..." -ForegroundColor Yellow |
| 227 | + } |
| 228 | + |
| 229 | + # Set expiration date |
| 230 | + $expirationDate = (Get-Date).AddDays($ExpirationDays).ToUniversalTime() |
| 231 | + |
| 232 | + # Add webhook subscription |
| 233 | + $newWebhook = Add-PnPWebhookSubscription -List $ListName ` |
| 234 | + -NotificationUrl $NewWebhookUrl ` |
| 235 | + -ExpirationDate $expirationDate ` |
| 236 | + -ClientState $clientState |
| 237 | + |
| 238 | + $newWebhookAdded = $true |
| 239 | + $newWebhookId = $newWebhook.Id |
| 240 | + $newWebhookExpiration = $newWebhook.ExpirationDateTime |
| 241 | + $webhookAddSuccess = $true |
| 242 | + Write-Host " - New webhook created successfully!" -ForegroundColor Green |
| 243 | + Write-Host " Webhook ID: $newWebhookId" -ForegroundColor Gray |
| 244 | + Write-Host " Expires: $newWebhookExpiration" -ForegroundColor Gray |
| 245 | + break |
| 246 | + } |
| 247 | + catch { |
| 248 | + $webhookError = $_.Exception.Message |
| 249 | + Write-Host " - Failed to add webhook (attempt $($retryCount + 1)): $webhookError" -ForegroundColor Red |
| 250 | + |
| 251 | + if ($retryCount -eq ($MaxRetries - 1)) { |
| 252 | + Write-Host " - Max retries reached. Webhook addition failed." -ForegroundColor Red |
| 253 | + } |
| 254 | + } |
| 255 | + } |
| 256 | + } |
| 257 | + |
| 258 | + # Determine action taken |
| 259 | + $action = "No action" |
| 260 | + if ($oldWebhookRemoved -and $newWebhookAdded) { |
| 261 | + $action = "Replaced old webhook" |
| 262 | + } |
| 263 | + elseif ($oldWebhookRemoved -and $newWebhookAlreadyExists) { |
| 264 | + $action = "Removed old webhook (new already existed)" |
| 265 | + } |
| 266 | + elseif ($newWebhookAdded -and -not $oldWebhookFound) { |
| 267 | + $action = "Added new webhook" |
| 268 | + } |
| 269 | + elseif ($newWebhookAlreadyExists -and -not $oldWebhookFound) { |
| 270 | + $action = "New webhook already exists" |
| 271 | + } |
| 272 | + elseif (-not $newWebhookAlreadyExists -and -not $newWebhookAdded) { |
| 273 | + $action = "Failed to add webhook after retries" |
| 274 | + } |
| 275 | + |
| 276 | + Write-Host " - Action: $action" -ForegroundColor $(if ($action -like "*Failed*" -or $action -eq "No action") { "Yellow" } else { "Green" }) |
| 277 | + |
| 278 | + # Add to results |
| 279 | + $results += [PSCustomObject]@{ |
| 280 | + SiteUrl = $siteUrl |
| 281 | + ListName = $ListName |
| 282 | + Action = $action |
| 283 | + OldWebhookFound = $oldWebhookFound |
| 284 | + OldWebhookRemoved = $oldWebhookRemoved |
| 285 | + RemovedWebhookId = if ($removedWebhookId) { $removedWebhookId } else { "N/A" } |
| 286 | + NewWebhookAdded = $newWebhookAdded |
| 287 | + NewWebhookAlreadyExists = $newWebhookAlreadyExists |
| 288 | + NewWebhookId = if ($newWebhookId) { $newWebhookId } else { "N/A" } |
| 289 | + NewWebhookExpiration = if ($newWebhookExpiration) { $newWebhookExpiration } else { "N/A" } |
| 290 | + OtherWebhooksCount = $otherWebhooksCount |
| 291 | + RetryAttempts = $retryCount |
| 292 | + ClientState = $clientState |
| 293 | + UpdatedDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 294 | + WebhookError = if ($webhookError) { $webhookError } else { "N/A" } |
| 295 | + } |
| 296 | + } |
| 297 | + else { |
| 298 | + Write-Host " - List '$ListName' not found in this site" -ForegroundColor Yellow |
| 299 | + |
| 300 | + $results += [PSCustomObject]@{ |
| 301 | + SiteUrl = $siteUrl |
| 302 | + ListName = $ListName |
| 303 | + Action = "List not found" |
| 304 | + OldWebhookFound = $false |
| 305 | + OldWebhookRemoved = $false |
| 306 | + RemovedWebhookId = "N/A" |
| 307 | + NewWebhookAdded = $false |
| 308 | + NewWebhookAlreadyExists = $false |
| 309 | + NewWebhookId = "N/A" |
| 310 | + NewWebhookExpiration = "N/A" |
| 311 | + OtherWebhooksCount = 0 |
| 312 | + RetryAttempts = 0 |
| 313 | + ClientState = "N/A" |
| 314 | + UpdatedDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 315 | + WebhookError = "List '$ListName' not found" |
| 316 | + } |
| 317 | + } |
| 318 | + } |
| 319 | + catch { |
| 320 | + $errorMessage = $_.Exception.Message |
| 321 | + Write-Host " - Failed to process site: $errorMessage" -ForegroundColor Red |
| 322 | + |
| 323 | + $results += [PSCustomObject]@{ |
| 324 | + SiteUrl = $siteUrl |
| 325 | + ListName = $ListName |
| 326 | + Action = "Error" |
| 327 | + OldWebhookFound = $false |
| 328 | + OldWebhookRemoved = $false |
| 329 | + RemovedWebhookId = "N/A" |
| 330 | + NewWebhookAdded = $false |
| 331 | + NewWebhookAlreadyExists = $false |
| 332 | + NewWebhookId = "N/A" |
| 333 | + NewWebhookExpiration = "N/A" |
| 334 | + OtherWebhooksCount = 0 |
| 335 | + RetryAttempts = 0 |
| 336 | + ClientState = "N/A" |
| 337 | + UpdatedDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 338 | + WebhookError = $errorMessage |
| 339 | + } |
| 340 | + } |
| 341 | + finally { |
| 342 | + # Disconnect to avoid connection limits |
| 343 | + try { |
| 344 | + Disconnect-PnPOnline -ErrorAction SilentlyContinue |
| 345 | + } |
| 346 | + catch { |
| 347 | + # Ignore disconnect errors |
| 348 | + } |
| 349 | + } |
| 350 | + |
| 351 | + Write-Host "" |
| 352 | +} |
| 353 | +
|
| 354 | +# Export results to CSV |
| 355 | +$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' |
| 356 | +$outputFile = Join-Path $OutputPath "WebhookUpdate_$timestamp.csv" |
| 357 | +$results | Export-Csv -Path $outputFile -NoTypeInformation -Encoding UTF8 |
| 358 | +
|
| 359 | +# Display summary |
| 360 | +Write-Host "========================================" -ForegroundColor Cyan |
| 361 | +Write-Host "Summary Report" -ForegroundColor Cyan |
| 362 | +Write-Host "========================================" -ForegroundColor Cyan |
| 363 | +Write-Host "" |
| 364 | +Write-Host "Results exported to: $outputFile" -ForegroundColor Green |
| 365 | +Write-Host "" |
| 366 | +Write-Host "Statistics:" -ForegroundColor Yellow |
| 367 | +Write-Host " Total sites processed: $($results.Count)" -ForegroundColor White |
| 368 | +Write-Host " Old webhooks replaced: $(($results | Where-Object { $_.Action -eq 'Replaced old webhook' }).Count)" -ForegroundColor Green |
| 369 | +Write-Host " New webhooks added: $(($results | Where-Object { $_.Action -eq 'Added new webhook' }).Count)" -ForegroundColor Green |
| 370 | +Write-Host " New webhook already existed: $(($results | Where-Object { $_.Action -eq 'New webhook already exists' }).Count)" -ForegroundColor Yellow |
| 371 | +Write-Host " Old webhook removed (new existed): $(($results | Where-Object { $_.Action -eq 'Removed old webhook (new already existed)' }).Count)" -ForegroundColor Yellow |
| 372 | +Write-Host " List not found: $(($results | Where-Object { $_.Action -eq 'List not found' }).Count)" -ForegroundColor Yellow |
| 373 | +Write-Host " Failed after retries: $(($results | Where-Object { $_.Action -eq 'Failed to add webhook after retries' }).Count)" -ForegroundColor Red |
| 374 | +Write-Host " Errors: $(($results | Where-Object { $_.Action -eq 'Error' }).Count)" -ForegroundColor Red |
| 375 | +Write-Host "" |
| 376 | +Write-Host "========================================" -ForegroundColor Cyan |
| 377 | +
|
| 378 | +``` |
| 379 | +[!INCLUDE [More about PnP PowerShell](../../docfx/includes/MORE-PNPPS.md)] |
| 380 | +*** |
| 381 | + |
| 382 | + |
| 383 | +## Contributors |
| 384 | + |
| 385 | +| Author(s) | |
| 386 | +|-----------| |
| 387 | +| [Valeras Narbutas](https://github.com/ValerasNarbutas) | |
| 388 | + |
| 389 | + |
| 390 | +[!INCLUDE [DISCLAIMER](../../docfx/includes/DISCLAIMER.md)] |
| 391 | +<img src="https://m365-visitor-stats.azurewebsites.net/script-samples/scripts/spo-webhook-subscription-maintenance" aria-hidden="true" /> |
0 commit comments