<# .SYNOPSIS Exports completed DocuSign envelopes, including the Certificate of Completion. .DESCRIPTION Uses the DocuSign eSignature REST API to: 1. Authenticate with JWT. 2. List completed envelopes in a date range. 3. Download each envelope as either an archive ZIP or combined PDF. 4. Write a CSV export log. Prerequisites: - PowerShell 7 recommended. - A DocuSign app/integration key. - RSA private key PEM for the integration. - Consent granted for scopes: signature impersonation. - The impersonated user must have access to the target envelopes. Consent URL format: Production: https://account.docusign.com/oauth/auth?response_type=code&scope=signature%20impersonation&client_id=INTEGRATION_KEY&redirect_uri=https://www.docusign.com Demo: https://account-d.docusign.com/oauth/auth?response_type=code&scope=signature%20impersonation&client_id=INTEGRATION_KEY&redirect_uri=https://www.docusign.com .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 "2020-01-01" ` -OutputRoot "D:\DocuSignExport" ` -OutputFormat ArchiveZip #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$IntegrationKey, [Parameter(Mandatory = $true)] [string]$UserId, [Parameter(Mandatory = $true)] [string]$AccountId, [Parameter(Mandatory = $true)] [string]$PrivateKeyPath, [Parameter(Mandatory = $true)] [datetime]$FromDate, [Parameter(Mandatory = $false)] [datetime]$ToDate = (Get-Date), [Parameter(Mandatory = $true)] [string]$OutputRoot, [Parameter(Mandatory = $false)] [ValidateSet("Production", "Demo")] [string]$Environment = "Production", [Parameter(Mandatory = $false)] [ValidateSet("ArchiveZip", "CombinedPdf")] [string]$OutputFormat = "ArchiveZip", [Parameter(Mandatory = $false)] [int]$PageSize = 1000, [Parameter(Mandatory = $false)] [int]$RetryCount = 4 ) $ErrorActionPreference = "Stop" function ConvertTo-Base64Url { param([byte[]]$Bytes) [Convert]::ToBase64String($Bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_") } function ConvertFrom-Base64UrlString { param([string]$Value) $base64 = $Value.Replace("-", "+").Replace("_", "/") switch ($base64.Length % 4) { 2 { $base64 += "==" } 3 { $base64 += "=" } } [Convert]::FromBase64String($base64) } 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() } "$unsignedToken.$(ConvertTo-Base64Url $signature)" } 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 } $delaySeconds = [math]::Min(60, [math]::Pow(2, $attempt)) Write-Warning "$ActionName failed with HTTP $statusCode. Retrying in $delaySeconds seconds. Attempt $attempt of $RetryCount." Start-Sleep -Seconds $delaySeconds } } } function Get-SafeFileName { param( [string]$Text, [int]$MaxLength = 120 ) $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() } $safe } function Save-Bytes { param( [string]$Uri, [hashtable]$Headers, [string]$Path, [int]$RetryCount ) Invoke-WithRetry -RetryCount $RetryCount -ActionName "Download $Path" -ScriptBlock { Invoke-WebRequest -Uri $Uri -Headers $Headers -OutFile $Path } | Out-Null } $authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" } $tokenUri = "https://$authServer/oauth/token" Write-Host "Requesting DocuSign access 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 } } $headers = @{ Authorization = "Bearer $($tokenResponse.access_token)" } Write-Host "Discovering account base URI..." $userInfo = Invoke-WithRetry -RetryCount $RetryCount -ActionName "UserInfo request" -ScriptBlock { Invoke-RestMethod -Method Get -Uri "https://$authServer/oauth/userinfo" -Headers $headers } $account = $userInfo.accounts | Where-Object { $_.account_id -eq $AccountId } | Select-Object -First 1 if (-not $account) { throw "AccountId '$AccountId' was not found for the impersonated user." } $baseUri = $account.base_uri.TrimEnd("/") $apiBase = "$baseUri/restapi/v2.1/accounts/$AccountId" $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" $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" } $downloadHeaders = @{ Authorization = "Bearer $($tokenResponse.access_token)" Accept = $accept } $from = $FromDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") $to = $ToDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") Write-Host "Finding completed envelopes from $from to $to..." $allEnvelopes = New-Object System.Collections.Generic.List[object] $startPosition = 0 do { $query = @{ from_date = $from to_date = $to 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 = "$apiBase/envelopes?$queryString" $page = Invoke-WithRetry -RetryCount $RetryCount -ActionName "Envelope list page starting $startPosition" -ScriptBlock { Invoke-RestMethod -Method Get -Uri $uri -Headers $headers } if ($page.envelopes) { foreach ($envelope in $page.envelopes) { $allEnvelopes.Add($envelope) } } $returned = if ($page.envelopes) { @($page.envelopes).Count } else { 0 } Write-Host "Found $returned envelopes on page starting at $startPosition." $startPosition += $PageSize $totalSetSize = [int]$page.totalSetSize } while ($allEnvelopes.Count -lt $totalSetSize -and $returned -gt 0) Write-Host "Total completed envelopes found: $($allEnvelopes.Count)" $logRows = New-Object System.Collections.Generic.List[object] $counter = 0 foreach ($envelope in $allEnvelopes) { $counter++ $envelopeId = $envelope.envelopeId $subject = Get-SafeFileName -Text $envelope.emailSubject $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 = "$apiBase/envelopes/$envelopeId/documents/$documentId`?certificate=true" Write-Progress ` -Activity "Downloading completed DocuSign agreements" ` -Status "$counter of $($allEnvelopes.Count): $subject" ` -PercentComplete (($counter / [math]::Max(1, $allEnvelopes.Count)) * 100) $status = "Downloaded" $errorMessage = "" try { Save-Bytes -Uri $downloadUri -Headers $downloadHeaders -Path $filePath -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 }) } $logRows | Export-Csv -Path $logPath -NoTypeInformation -Encoding UTF8 $downloaded = @($logRows | Where-Object { $_.Status -eq "Downloaded" }).Count $failed = @($logRows | Where-Object { $_.Status -eq "Failed" }).Count Write-Host "" Write-Host "DocuSign export complete." Write-Host "Completed envelopes found: $($allEnvelopes.Count)" Write-Host "Downloaded successfully: $downloaded" Write-Host "Failed: $failed" Write-Host "Files saved to: $downloadFolder" Write-Host "Log saved to: $logPath" if ($failed -gt 0) { Write-Warning "Some envelopes failed. Review the CSV log and rerun after resolving the errors." }