379 lines
12 KiB
PowerShell
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."
|
|
}
|