Bulk Sending
Send the same document to multiple recipients efficiently with rate limiting and error handling.
Overview
This example demonstrates:
- Reading recipients from a CSV file
- Creating envelopes with rate limiting
- Handling failures gracefully
- Tracking bulk send progress
- Generating a summary report
Prerequisites
- Propper API credentials
- A PDF document template
- A CSV file with recipient data
Recipient CSV Format
email,name,company,role
john@acme.com,John Smith,Acme Corp,signer
jane@globex.com,Jane Doe,Globex Inc,signer
bob@initech.com,Bob Wilson,Initech,signer
Complete Code Example
- JavaScript (Node.js)
- Python
- cURL (Loop)
// bulk-send.js
import fs from 'fs/promises';
import { parse } from 'csv-parse/sync';
const BASE_URL = 'https://api.propper.ai/restapi/v2.1';
const AUTH_URL = 'https://auth.propper.ai';
const ACCOUNT_ID = process.env.PROPPER_ACCOUNT_ID;
// Rate limiter: 10 requests per second
class RateLimiter {
constructor(requestsPerSecond = 10) {
this.minInterval = 1000 / requestsPerSecond;
this.lastRequest = 0;
}
async wait() {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequest;
if (timeSinceLastRequest < this.minInterval) {
await new Promise(resolve =>
setTimeout(resolve, this.minInterval - timeSinceLastRequest)
);
}
this.lastRequest = Date.now();
}
}
const rateLimiter = new RateLimiter(10);
// Token management
let tokenCache = null;
async function getAccessToken() {
if (tokenCache && tokenCache.expiresAt > Date.now()) {
return tokenCache.token;
}
const response = await fetch(`${AUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: process.env.PROPPER_CLIENT_ID,
client_secret: process.env.PROPPER_CLIENT_SECRET,
scope: 'sign:read sign:write',
}),
});
const { access_token, expires_in } = await response.json();
tokenCache = {
token: access_token,
expiresAt: Date.now() + (expires_in * 1000) - 60000,
};
return access_token;
}
// API request with rate limiting and retry
async function apiRequest(path, options = {}, retries = 3) {
await rateLimiter.wait();
const token = await getAccessToken();
for (let attempt = 0; attempt < retries; attempt++) {
try {
const response = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (response.status === 429) {
// Rate limited - wait and retry
const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
console.log(`Rate limited, waiting ${retryAfter}s...`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
if (!response.ok) {
const error = await response.json();
throw new Error(`${error.errorCode}: ${error.message}`);
}
return response.json();
} catch (error) {
if (attempt === retries - 1) throw error;
// Exponential backoff for other errors
const delay = Math.pow(2, attempt) * 1000;
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Load recipients from CSV
async function loadRecipients(csvPath) {
const content = await fs.readFile(csvPath, 'utf-8');
return parse(content, {
columns: true,
skip_empty_lines: true,
});
}
// Send to a single recipient
async function sendToRecipient(documentBase64, recipient, emailSubject) {
const envelope = await apiRequest(`/accounts/${ACCOUNT_ID}/envelopes`, {
method: 'POST',
body: JSON.stringify({
emailSubject: emailSubject.replace('{{name}}', recipient.name),
emailBlurb: `Dear ${recipient.name},\n\nPlease review and sign this document.`,
status: 'sent',
documents: [
{
documentId: '1',
name: 'Agreement.pdf',
documentBase64,
order: '1',
},
],
recipients: {
signers: [
{
email: recipient.email,
name: recipient.name,
recipientId: '1',
routingOrder: '1',
tabs: {
signHereTabs: [
{
documentId: '1',
pageNumber: '1',
anchorString: '/sig/',
anchorXOffset: '0',
anchorYOffset: '0',
},
],
},
},
],
},
// Custom fields for tracking
customFields: {
textCustomFields: [
{ name: 'company', value: recipient.company || '' },
{ name: 'batchId', value: new Date().toISOString().split('T')[0] },
],
},
}),
});
return {
success: true,
email: recipient.email,
name: recipient.name,
envelopeId: envelope.envelopeId,
};
}
// Main bulk send function
async function bulkSend(pdfPath, csvPath, emailSubject) {
console.log('Starting bulk send...\n');
// Load document
const pdfBuffer = await fs.readFile(pdfPath);
const documentBase64 = pdfBuffer.toString('base64');
// Load recipients
const recipients = await loadRecipients(csvPath);
console.log(`Loaded ${recipients.length} recipients\n`);
// Track results
const results = {
successful: [],
failed: [],
startTime: new Date(),
};
// Process each recipient
for (let i = 0; i < recipients.length; i++) {
const recipient = recipients[i];
const progress = `[${i + 1}/${recipients.length}]`;
try {
console.log(`${progress} Sending to ${recipient.email}...`);
const result = await sendToRecipient(documentBase64, recipient, emailSubject);
results.successful.push(result);
console.log(`${progress} ✓ Sent: ${result.envelopeId}`);
} catch (error) {
console.log(`${progress} ✗ Failed: ${error.message}`);
results.failed.push({
success: false,
email: recipient.email,
name: recipient.name,
error: error.message,
});
}
}
results.endTime = new Date();
results.duration = (results.endTime - results.startTime) / 1000;
return results;
}
// Generate summary report
function generateReport(results) {
const report = `
================================================================================
BULK SEND REPORT
================================================================================
Started: ${results.startTime.toISOString()}
Completed: ${results.endTime.toISOString()}
Duration: ${results.duration.toFixed(1)} seconds
--------------------------------------------------------------------------------
SUMMARY
--------------------------------------------------------------------------------
Total Recipients: ${results.successful.length + results.failed.length}
Successful: ${results.successful.length}
Failed: ${results.failed.length}
Success Rate: ${((results.successful.length / (results.successful.length + results.failed.length)) * 100).toFixed(1)}%
--------------------------------------------------------------------------------
SUCCESSFUL SENDS
--------------------------------------------------------------------------------
${results.successful.map(r => `✓ ${r.email.padEnd(35)} ${r.envelopeId}`).join('\n') || 'None'}
--------------------------------------------------------------------------------
FAILURES
--------------------------------------------------------------------------------
${results.failed.map(r => `✗ ${r.email.padEnd(35)} ${r.error}`).join('\n') || 'None'}
================================================================================
`;
return report;
}
// Save results to JSON
async function saveResults(results, outputPath) {
await fs.writeFile(outputPath, JSON.stringify(results, null, 2));
console.log(`\nResults saved to: ${outputPath}`);
}
// Main execution
async function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('Usage: node bulk-send.js <pdf-path> <csv-path> [email-subject]');
console.log('Example: node bulk-send.js ./contract.pdf ./recipients.csv "Please sign: {{name}}"');
process.exit(1);
}
const [pdfPath, csvPath, emailSubject = 'Document for Signature'] = args;
try {
const results = await bulkSend(pdfPath, csvPath, emailSubject);
// Print report
console.log(generateReport(results));
// Save results
const outputPath = `./bulk-send-results-${Date.now()}.json`;
await saveResults(results, outputPath);
// Exit with error code if any failures
if (results.failed.length > 0) {
process.exit(1);
}
} catch (error) {
console.error('Fatal error:', error.message);
process.exit(1);
}
}
main();
# bulk_send.py
import os
import sys
import csv
import json
import time
import base64
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict
from typing import List, Optional
import requests
BASE_URL = "https://api.propper.ai/restapi/v2.1"
AUTH_URL = "https://auth.propper.ai"
ACCOUNT_ID = os.environ["PROPPER_ACCOUNT_ID"]
@dataclass
class SendResult:
success: bool
email: str
name: str
envelope_id: Optional[str] = None
error: Optional[str] = None
class RateLimiter:
"""Simple rate limiter for API requests."""
def __init__(self, requests_per_second: int = 10):
self.min_interval = 1.0 / requests_per_second
self.last_request = 0.0
def wait(self):
now = time.time()
time_since_last = now - self.last_request
if time_since_last < self.min_interval:
time.sleep(self.min_interval - time_since_last)
self.last_request = time.time()
rate_limiter = RateLimiter(10)
# Token management
_token_cache = {"token": None, "expires_at": 0}
def get_access_token() -> str:
"""Get a valid access token."""
if _token_cache["token"] and _token_cache["expires_at"] > time.time():
return _token_cache["token"]
response = requests.post(
f"{AUTH_URL}/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": os.environ["PROPPER_CLIENT_ID"],
"client_secret": os.environ["PROPPER_CLIENT_SECRET"],
"scope": "sign:read sign:write",
},
)
response.raise_for_status()
data = response.json()
_token_cache["token"] = data["access_token"]
_token_cache["expires_at"] = time.time() + data["expires_in"] - 60
return _token_cache["token"]
def api_request(path: str, method: str = "GET", retries: int = 3, **kwargs) -> dict:
"""Make an API request with rate limiting and retry."""
rate_limiter.wait()
token = get_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
for attempt in range(retries):
try:
response = requests.request(
method, f"{BASE_URL}{path}", headers=headers, **kwargs
)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 5))
print(f"Rate limited, waiting {retry_after}s...")
time.sleep(retry_after)
continue
if not response.ok:
error = response.json()
raise Exception(f"{error.get('errorCode')}: {error.get('message')}")
return response.json()
except Exception as e:
if attempt == retries - 1:
raise
delay = (2**attempt)
print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
time.sleep(delay)
def load_recipients(csv_path: str) -> List[dict]:
"""Load recipients from a CSV file."""
with open(csv_path, "r") as f:
reader = csv.DictReader(f)
return list(reader)
def send_to_recipient(
document_base64: str, recipient: dict, email_subject: str
) -> SendResult:
"""Send an envelope to a single recipient."""
personalized_subject = email_subject.replace("{{name}}", recipient["name"])
envelope = api_request(
f"/accounts/{ACCOUNT_ID}/envelopes",
method="POST",
json={
"emailSubject": personalized_subject,
"emailBlurb": f"Dear {recipient['name']},\n\nPlease review and sign this document.",
"status": "sent",
"documents": [
{
"documentId": "1",
"name": "Agreement.pdf",
"documentBase64": document_base64,
"order": "1",
}
],
"recipients": {
"signers": [
{
"email": recipient["email"],
"name": recipient["name"],
"recipientId": "1",
"routingOrder": "1",
"tabs": {
"signHereTabs": [
{
"documentId": "1",
"pageNumber": "1",
"anchorString": "/sig/",
"anchorXOffset": "0",
"anchorYOffset": "0",
}
]
},
}
]
},
"customFields": {
"textCustomFields": [
{"name": "company", "value": recipient.get("company", "")},
{"name": "batchId", "value": datetime.now().strftime("%Y-%m-%d")},
]
},
},
)
return SendResult(
success=True,
email=recipient["email"],
name=recipient["name"],
envelope_id=envelope["envelopeId"],
)
def bulk_send(
pdf_path: str, csv_path: str, email_subject: str
) -> dict:
"""Send documents to all recipients in the CSV."""
print("Starting bulk send...\n")
# Load document
pdf_content = Path(pdf_path).read_bytes()
document_base64 = base64.b64encode(pdf_content).decode("utf-8")
# Load recipients
recipients = load_recipients(csv_path)
print(f"Loaded {len(recipients)} recipients\n")
# Track results
results = {
"successful": [],
"failed": [],
"start_time": datetime.now().isoformat(),
}
start_time = time.time()
# Process each recipient
for i, recipient in enumerate(recipients):
progress = f"[{i + 1}/{len(recipients)}]"
try:
print(f"{progress} Sending to {recipient['email']}...")
result = send_to_recipient(document_base64, recipient, email_subject)
results["successful"].append(asdict(result))
print(f"{progress} ✓ Sent: {result.envelope_id}")
except Exception as e:
print(f"{progress} ✗ Failed: {e}")
results["failed"].append(
asdict(
SendResult(
success=False,
email=recipient["email"],
name=recipient["name"],
error=str(e),
)
)
)
results["end_time"] = datetime.now().isoformat()
results["duration_seconds"] = time.time() - start_time
return results
def generate_report(results: dict) -> str:
"""Generate a human-readable report."""
total = len(results["successful"]) + len(results["failed"])
success_rate = (len(results["successful"]) / total * 100) if total > 0 else 0
successful_lines = "\n".join(
f"✓ {r['email']:<35} {r['envelope_id']}"
for r in results["successful"]
) or "None"
failed_lines = "\n".join(
f"✗ {r['email']:<35} {r['error']}"
for r in results["failed"]
) or "None"
return f"""
================================================================================
BULK SEND REPORT
================================================================================
Started: {results['start_time']}
Completed: {results['end_time']}
Duration: {results['duration_seconds']:.1f} seconds
--------------------------------------------------------------------------------
SUMMARY
--------------------------------------------------------------------------------
Total Recipients: {total}
Successful: {len(results['successful'])}
Failed: {len(results['failed'])}
Success Rate: {success_rate:.1f}%
--------------------------------------------------------------------------------
SUCCESSFUL SENDS
--------------------------------------------------------------------------------
{successful_lines}
--------------------------------------------------------------------------------
FAILURES
--------------------------------------------------------------------------------
{failed_lines}
================================================================================
"""
def save_results(results: dict, output_path: str):
"""Save results to a JSON file."""
with open(output_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nResults saved to: {output_path}")
def main():
if len(sys.argv) < 3:
print("Usage: python bulk_send.py <pdf-path> <csv-path> [email-subject]")
print('Example: python bulk_send.py ./contract.pdf ./recipients.csv "Please sign: {{name}}"')
sys.exit(1)
pdf_path = sys.argv[1]
csv_path = sys.argv[2]
email_subject = sys.argv[3] if len(sys.argv) > 3 else "Document for Signature"
try:
results = bulk_send(pdf_path, csv_path, email_subject)
# Print report
print(generate_report(results))
# Save results
output_path = f"./bulk-send-results-{int(time.time())}.json"
save_results(results, output_path)
# Exit with error code if any failures
if results["failed"]:
sys.exit(1)
except Exception as e:
print(f"Fatal error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
#!/bin/bash
# bulk-send.sh
set -e
# Configuration
PDF_PATH="${1:-./contract.pdf}"
CSV_PATH="${2:-./recipients.csv}"
EMAIL_SUBJECT="${3:-Document for Signature}"
PROPPER_BASE_URL="https://api.propper.ai/restapi/v2.1"
PROPPER_AUTH_URL="https://auth.propper.ai"
# Get access token
echo "Getting access token..."
TOKEN_RESPONSE=$(curl -s -X POST "$PROPPER_AUTH_URL/oauth/token" \
-H "Content-Type: application/json" \
-d "{
\"grant_type\": \"client_credentials\",
\"client_id\": \"$PROPPER_CLIENT_ID\",
\"client_secret\": \"$PROPPER_CLIENT_SECRET\",
\"scope\": \"sign:read sign:write\"
}")
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
if [ "$ACCESS_TOKEN" == "null" ]; then
echo "Failed to get access token"
exit 1
fi
# Encode document
echo "Encoding document..."
DOCUMENT_BASE64=$(base64 -i "$PDF_PATH")
# Initialize counters
SUCCESS_COUNT=0
FAIL_COUNT=0
RESULTS_FILE="bulk-send-results-$(date +%s).json"
echo "[]" > "$RESULTS_FILE"
# Read CSV and send to each recipient
echo "Processing recipients..."
echo ""
# Skip header row
tail -n +2 "$CSV_PATH" | while IFS=, read -r email name company role; do
# Remove quotes if present
email=$(echo "$email" | tr -d '"')
name=$(echo "$name" | tr -d '"')
echo "Sending to $email..."
# Rate limiting - 100ms between requests
sleep 0.1
# Create and send envelope
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
"$PROPPER_BASE_URL/accounts/$PROPPER_ACCOUNT_ID/envelopes" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"emailSubject\": \"$EMAIL_SUBJECT\",
\"emailBlurb\": \"Dear $name,\\n\\nPlease review and sign this document.\",
\"status\": \"sent\",
\"documents\": [{
\"documentId\": \"1\",
\"name\": \"Agreement.pdf\",
\"documentBase64\": \"$DOCUMENT_BASE64\",
\"order\": \"1\"
}],
\"recipients\": {
\"signers\": [{
\"email\": \"$email\",
\"name\": \"$name\",
\"recipientId\": \"1\",
\"routingOrder\": \"1\",
\"tabs\": {
\"signHereTabs\": [{
\"documentId\": \"1\",
\"pageNumber\": \"1\",
\"anchorString\": \"/sig/\"
}]
}
}]
}
}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -eq 201 ]; then
ENVELOPE_ID=$(echo "$BODY" | jq -r '.envelopeId')
echo " ✓ Sent: $ENVELOPE_ID"
# Append to results
jq --arg email "$email" --arg name "$name" --arg id "$ENVELOPE_ID" \
'. += [{"success": true, "email": $email, "name": $name, "envelopeId": $id}]' \
"$RESULTS_FILE" > tmp.json && mv tmp.json "$RESULTS_FILE"
((SUCCESS_COUNT++)) || true
else
ERROR=$(echo "$BODY" | jq -r '.message // "Unknown error"')
echo " ✗ Failed: $ERROR"
# Append to results
jq --arg email "$email" --arg name "$name" --arg error "$ERROR" \
'. += [{"success": false, "email": $email, "name": $name, "error": $error}]' \
"$RESULTS_FILE" > tmp.json && mv tmp.json "$RESULTS_FILE"
((FAIL_COUNT++)) || true
fi
done
echo ""
echo "=================================================================================="
echo " BULK SEND COMPLETE"
echo "=================================================================================="
echo "Results saved to: $RESULTS_FILE"
echo ""
echo "Summary:"
jq -r '
" Total: \(length)",
" Successful: \([.[] | select(.success)] | length)",
" Failed: \([.[] | select(.success | not)] | length)"
' "$RESULTS_FILE"
Running the Example
- JavaScript
- Python
- cURL
# Install dependencies
npm install csv-parse
# Run
node bulk-send.js ./contract.pdf ./recipients.csv "Please sign: {{name}}"
# Install dependencies
pip install requests
# Run
python bulk_send.py ./contract.pdf ./recipients.csv "Please sign: {{name}}"
# Make executable
chmod +x bulk-send.sh
# Run
./bulk-send.sh ./contract.pdf ./recipients.csv "Please sign"
Sample Output
Starting bulk send...
Loaded 3 recipients
[1/3] Sending to john@acme.com...
[1/3] ✓ Sent: abc123-def456-001
[2/3] Sending to jane@globex.com...
[2/3] ✓ Sent: abc123-def456-002
[3/3] Sending to bob@initech.com...
[3/3] ✗ Failed: INVALID_EMAIL: Email address is invalid
================================================================================
BULK SEND REPORT
================================================================================
Started: 2024-01-15T10:00:00.000Z
Completed: 2024-01-15T10:00:05.234Z
Duration: 5.2 seconds
--------------------------------------------------------------------------------
SUMMARY
--------------------------------------------------------------------------------
Total Recipients: 3
Successful: 2
Failed: 1
Success Rate: 66.7%
--------------------------------------------------------------------------------
SUCCESSFUL SENDS
--------------------------------------------------------------------------------
✓ john@acme.com abc123-def456-001
✓ jane@globex.com abc123-def456-002
--------------------------------------------------------------------------------
FAILURES
--------------------------------------------------------------------------------
✗ bob@initech.com INVALID_EMAIL: Email address is invalid
================================================================================
Results saved to: ./bulk-send-results-1705312805.json
Best Practices
Rate Limiting
The examples implement 10 requests/second rate limiting. Adjust based on your plan limits:
// For higher limits
const rateLimiter = new RateLimiter(50); // 50 req/sec
// For lower limits or safety
const rateLimiter = new RateLimiter(5); // 5 req/sec
Error Handling
Always implement retry logic for transient errors:
429- Rate limited (wait and retry)500,502,503- Server errors (retry with backoff)400,404- Client errors (log and skip)
Monitoring Progress
For very large batches, consider:
- Checkpointing - Save progress to resume after failures
- Parallel processing - Use worker pools (respect rate limits)
- Webhooks - Track completion status asynchronously
Template Variables
Use anchor strings in your PDF for consistent field placement:
/sig/ → Signature field
/date/ → Date signed field
/initial/ → Initials field
Next Steps
- Templates Guide - Create reusable templates
- Webhooks Guide - Track bulk send completion
- Error Handling - Handle failures gracefully