<# .SYNOPSIS Exports completed DocuSign envelopes and Certificates of Completion via the eSignature REST API. .DESCRIPTION Uses the DocuSign eSignature REST API to: 1. Authenticate with JWT Bearer assertion and handle automatic token renewal. 2. Enumerate completed envelopes across a specified date range with API pagination. 3. Download each envelope as an archive ZIP or combined PDF. 4. Support incremental runs by skipping previously downloaded files. 5. Output a detailed CSV export log and execution summary. .PARAMETER IntegrationKey The DocuSign App / Integration Key (Client ID). .PARAMETER UserId The API User ID (GUID) of the user being impersonated. .PARAMETER AccountId The DocuSign Account ID (GUID). .PARAMETER PrivateKeyPath Path to the RSA private key PEM file associated with the Integration Key. .PARAMETER FromDate Start date for querying completed envelopes. .PARAMETER ToDate End date for querying completed envelopes. Defaults to the current date/time. .PARAMETER OutputRoot Root directory where downloads and logs will be saved. .PARAMETER Environment DocuSign environment target: 'Production' or 'Demo'. Defaults to 'Production'. .PARAMETER OutputFormat Format to download documents: 'ArchiveZip' (ZIP containing all docs + certificate) or 'CombinedPdf' (Single combined PDF). .PARAMETER SkipExisting If specified, skips downloading envelopes whose target file already exists locally. Defaults to $true. .PARAMETER PageSize Number of envelopes to request per API page call. Defaults to 1000 (DocuSign max limit). .PARAMETER RetryCount Maximum retry attempts for transient API errors or rate-limit throttling. Defaults to 4. .EXAMPLE .\Export-DocuSignCompletedAgreements.ps1 ` -IntegrationKey "00000000-0000-0000-0000-000000000000" ` -UserId "11111111-1111-1111-1111-111111111111" ` -AccountId "22222222-2222-2222-2222-222222222222" ` -PrivateKeyPath "C:\Keys\docusign_private.key" ` -FromDate "2024-01-01" ` -OutputRoot "D:\DocuSignExport" ` -OutputFormat ArchiveZip .NOTES Created: 2025-03-05 Modified: 2026-10-04 #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$IntegrationKey, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$UserId, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$AccountId, [Parameter(Mandatory = $true)] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] [string]$PrivateKeyPath, [Parameter(Mandatory = $true)] [datetime]$FromDate, [Parameter(Mandatory = $false)] [datetime]$ToDate = (Get-Date), [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$OutputRoot, [Parameter(Mandatory = $false)] [ValidateSet("Production", "Demo")] [string]$Environment = "Production", [Parameter(Mandatory = $false)] [ValidateSet("ArchiveZip", "CombinedPdf")] [string]$OutputFormat = "ArchiveZip", [Parameter(Mandatory = $false)] [bool]$SkipExisting = $true, [Parameter(Mandatory = $false)] [ValidateRange(1, 1000)] [int]$PageSize = 1000, [Parameter(Mandatory = $false)] [int]$RetryCount = 4 ) $ErrorActionPreference = "Stop" # Global state tracking for OAuth token management $script:currentToken = $null $script:tokenExpiration = [DateTimeOffset]::MinValue $script:apiBaseUri = $null # Helper Functions function ConvertTo-Base64Url { param([byte[]]$Bytes) [Convert]::ToBase64String($Bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_") } function Import-RsaPrivateKey { param([string]$Path) $pem = Get-Content -Path $Path -Raw $rsa = [System.Security.Cryptography.RSA]::Create() if ($pem -match "-----BEGIN RSA PRIVATE KEY-----") { $body = $pem -replace "-----BEGIN RSA PRIVATE KEY-----", "" -replace "-----END RSA PRIVATE KEY-----", "" -replace "\s", "" $bytes = [Convert]::FromBase64String($body) [void]$rsa.ImportRSAPrivateKey($bytes, [ref]0) return $rsa } if ($pem -match "-----BEGIN PRIVATE KEY-----") { $body = $pem -replace "-----BEGIN PRIVATE KEY-----", "" -replace "-----END PRIVATE KEY-----", "" -replace "\s", "" $bytes = [Convert]::FromBase64String($body) [void]$rsa.ImportPkcs8PrivateKey($bytes, [ref]0) return $rsa } throw "Private key must be PEM formatted with 'BEGIN RSA PRIVATE KEY' or 'BEGIN PRIVATE KEY'." } function New-DocuSignJwtAssertion { param( [string]$IntegrationKey, [string]$UserId, [string]$AuthServer, [string]$PrivateKeyPath ) $now = [DateTimeOffset]::UtcNow $header = @{ alg = "RS256" typ = "JWT" } | ConvertTo-Json -Compress $payload = @{ iss = $IntegrationKey sub = $UserId aud = $AuthServer iat = $now.ToUnixTimeSeconds() exp = $now.AddMinutes(55).ToUnixTimeSeconds() scope = "signature impersonation" } | ConvertTo-Json -Compress $encodedHeader = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($header)) $encodedPayload = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($payload)) $unsignedToken = "$encodedHeader.$encodedPayload" $rsa = Import-RsaPrivateKey -Path $PrivateKeyPath try { $signature = $rsa.SignData( [Text.Encoding]::UTF8.GetBytes($unsignedToken), [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1 ) } finally { $rsa.Dispose() } return "$unsignedToken.$(ConvertTo-Base64Url $signature)" } function Get-ValidAccessToken { # Refresh token 5 minutes before actual expiry to avoid race conditions if ($null -ne $script:currentToken -and [DateTimeOffset]::UtcNow -lt $script:tokenExpiration) { return $script:currentToken } Write-Verbose "Obtaining fresh DocuSign access token..." $authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" } $tokenUri = "https://$authServer/oauth/token" $assertion = New-DocuSignJwtAssertion ` -IntegrationKey $IntegrationKey ` -UserId $UserId ` -AuthServer $authServer ` -PrivateKeyPath $PrivateKeyPath $tokenResponse = Invoke-WithRetry -RetryCount $RetryCount -ActionName "JWT Token Request" -ScriptBlock { Invoke-RestMethod ` -Method Post ` -Uri $tokenUri ` -ContentType "application/x-www-form-urlencoded" ` -Body @{ grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer" assertion = $assertion } } $script:currentToken = $tokenResponse.access_token # DocuSign tokens typically last 3600s (1 hr); refresh 5 mins early $expiresInSeconds = [int]($tokenResponse.expires_in ?? 3600) - 300 $script:tokenExpiration = [DateTimeOffset]::UtcNow.AddSeconds($expiresInSeconds) return $script:currentToken } function Invoke-WithRetry { param( [scriptblock]$ScriptBlock, [int]$RetryCount = 4, [string]$ActionName = "DocuSign API request" ) for ($attempt = 1; $attempt -le ($RetryCount + 1); $attempt++) { try { return & $ScriptBlock } catch { $response = $_.Exception.Response $statusCode = $null if ($response -and $response.StatusCode) { $statusCode = [int]$response.StatusCode } $retryable = $statusCode -in @(429, 500, 502, 503, 504) if (-not $retryable -or $attempt -gt $RetryCount) { throw } # Honor DocuSign 'Retry-After' header if provided on HTTP 429 $delaySeconds = 0 if ($statusCode -eq 429 -and $response.Headers["Retry-After"]) { [int]::TryParse($response.Headers["Retry-After"], [ref]$delaySeconds) | Out-Null } if ($delaySeconds -le 0) { $delaySeconds = [math]::Min(60, [math]::Pow(2, $attempt)) } Write-Warning "$ActionName encountered HTTP $statusCode. Retrying in $delaySeconds seconds (Attempt $attempt of $RetryCount)..." Start-Sleep -Seconds $delaySeconds } } } function Get-SafeFileName { param( [string]$Text, [int]$MaxLength = 100 ) $invalid = [Regex]::Escape(([IO.Path]::GetInvalidFileNameChars() -join "")) $safe = [Regex]::Replace($Text, "[$invalid]", "_") $safe = [Regex]::Replace($safe, "\s+", " ").Trim() if ([string]::IsNullOrWhiteSpace($safe)) { $safe = "Envelope" } if ($safe.Length -gt $MaxLength) { $safe = $safe.Substring(0, $MaxLength).Trim() } return $safe } function Save-EnvelopeDocument { param( [string]$Uri, [string]$Path, [string]$AcceptHeader, [int]$RetryCount ) Invoke-WithRetry -RetryCount $RetryCount -ActionName "Downloading document to '$Path'" -ScriptBlock { $token = Get-ValidAccessToken $headers = @{ Authorization = "Bearer $token" Accept = $AcceptHeader } Invoke-WebRequest -Uri $Uri -Headers $headers -OutFile $Path -ErrorAction Stop } | Out-Null } # Main Execution Flow # 1. Directory Initialization $downloadFolder = Join-Path $OutputRoot "completed-agreements" $logFolder = Join-Path $OutputRoot "logs" New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null New-Item -ItemType Directory -Path $logFolder -Force | Out-Null $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $logPath = Join-Path $logFolder "docusign-export-log-$timestamp.csv" # 2. Base URI Discovery $authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" } $initialToken = Get-ValidAccessToken Write-Host "Discovering account base URI for Account ID: $AccountId..." -ForegroundColor Cyan $userInfo = Invoke-WithRetry -RetryCount $RetryCount -ActionName "UserInfo Discovery" -ScriptBlock { Invoke-RestMethod -Method Get -Uri "https://$authServer/oauth/userinfo" -Headers @{ Authorization = "Bearer $initialToken" } } $account = $userInfo.accounts | Where-Object { $_.account_id -eq $AccountId } | Select-Object -First 1 if (-not $account) { throw "AccountId '$AccountId' was not found for the authenticated impersonated user." } $baseUri = $account.base_uri.TrimEnd("/") $script:apiBaseUri = "$baseUri/restapi/v2.1/accounts/$AccountId" # 3. Request Format Settings $documentId = if ($OutputFormat -eq "ArchiveZip") { "archive" } else { "combined" } $extension = if ($OutputFormat -eq "ArchiveZip") { ".zip" } else { ".pdf" } $accept = if ($OutputFormat -eq "ArchiveZip") { "application/zip" } else { "application/pdf" } $fromIso = $FromDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") $toIso = $ToDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") Write-Host "Querying completed envelopes from $fromIso to $toIso..." -ForegroundColor Cyan # 4. Enumerate Completed Envelopes (Paginated) $allEnvelopes = [System.Collections.Generic.List[object]]::new() $startPosition = 0 do { $query = @{ from_date = $fromIso to_date = $toIso status = "completed" count = $PageSize start_position = $startPosition order_by = "completed" order = "asc" } $queryString = ($query.GetEnumerator() | ForEach-Object { "{0}={1}" -f [Uri]::EscapeDataString($_.Key), [Uri]::EscapeDataString([string]$_.Value) }) -join "&" $uri = "$($script:apiBaseUri)/envelopes?$queryString" $page = Invoke-WithRetry -RetryCount $RetryCount -ActionName "Fetching Envelope List Page (Offset: $startPosition)" -ScriptBlock { $token = Get-ValidAccessToken Invoke-RestMethod -Method Get -Uri $uri -Headers @{ Authorization = "Bearer $token" } } if ($page.envelopes) { foreach ($env in $page.envelopes) { $allEnvelopes.Add($env) } } $returnedCount = if ($page.envelopes) { @($page.envelopes).Count } else { 0 } Write-Host " Retrieved $returnedCount envelopes (Page Start: $startPosition)." -ForegroundColor Gray $startPosition += $PageSize $totalSetSize = [int]($page.totalSetSize ?? 0) } while ($allEnvelopes.Count -lt $totalSetSize -and $returnedCount -gt 0) Write-Host "Total completed envelopes identified: $($allEnvelopes.Count)" -ForegroundColor Green # 5. Download Documents & Build Audit Log $logRows = [System.Collections.Generic.List[object]]::new() $counter = 0 foreach ($envelope in $allEnvelopes) { $counter++ $envelopeId = $envelope.envelopeId $subject = Get-SafeFileName -Text $envelope.emailSubject -MaxLength 80 $completedRaw = if ($envelope.completedDateTime) { $envelope.completedDateTime } else { $envelope.statusChangedDateTime } $completedDate = if ($completedRaw) { ([datetime]$completedRaw).ToString("yyyy-MM-dd") } else { "unknown-date" } $fileName = "$completedDate - $subject - $envelopeId$extension" $filePath = Join-Path $downloadFolder $fileName $downloadUri = "$($script:apiBaseUri)/envelopes/$envelopeId/documents/$documentId`?certificate=true" Write-Progress ` -Activity "Downloading DocuSign Agreements" ` -Status "Item $counter of$($allEnvelopes.Count):$subject" ` -PercentComplete (($counter / [math]::Max(1, $allEnvelopes.Count)) * 100) $status = "Downloaded" $errorMessage = "" # Check for incremental skip if ($SkipExisting -and (Test-Path -LiteralPath $filePath)) { $status = "Skipped (Exists)" Write-Verbose "Skipping existing file: $filePath" } else { try { Save-EnvelopeDocument -Uri $downloadUri -Path $filePath -AcceptHeader $accept -RetryCount $RetryCount } catch { $status = "Failed" $errorMessage = $_.Exception.Message Write-Warning "Failed to download envelope $envelopeId`: $errorMessage" } } $logRows.Add([PSCustomObject]@{ EnvelopeId = $envelopeId Status = $status EnvelopeStatus = $envelope.status CompletedDate = $completedRaw Subject = $envelope.emailSubject OutputFormat = $OutputFormat OutputPath = $filePath Error = $errorMessage }) } # 6. Save Export Summary Log $logRows \vert{} Export-Csv -Path$logPath -NoTypeInformation -Encoding UTF8 $downloadedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Downloaded" }).Count $skippedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Skipped (Exists)" }).Count $failedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Failed" }).Count Write-Host "`n========================================" -ForegroundColor Green Write-Host "DocuSign Agreement Export Completed" -ForegroundColor Green Write-Host "========================================" -ForegroundColor Green Write-Host " Total Identified : $($allEnvelopes.Count)" Write-Host " Downloaded : $downloadedCount" Write-Host " Skipped Existing : $skippedCount" Write-Host " Failed : $failedCount" Write-Host " Files Saved To : $downloadFolder" Write-Host " Audit Log Saved : $logPath`n" if ($failedCount -gt 0) { Write-Warning "One or more envelope downloads failed. Review the CSV audit log for detailed error reasons." }