Skip to main content

Bulk Sending

Send the same document to multiple recipients efficiently with rate limiting and error handling.

Overview

This example demonstrates:

  1. Reading recipients from a CSV file
  2. Creating envelopes with rate limiting
  3. Handling failures gracefully
  4. Tracking bulk send progress
  5. 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

// 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();

Running the Example

# Install dependencies
npm install csv-parse

# Run
node bulk-send.js ./contract.pdf ./recipients.csv "Please sign: {{name}}"

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:

  1. Checkpointing - Save progress to resume after failures
  2. Parallel processing - Use worker pools (respect rate limits)
  3. 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