Files
asg/Export-DocuSignCompletedAgreements.ps1
T

379 lines
12 KiB
PowerShell

<#
.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."
}