Skip to main content

Bulk Sending

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

tip

This example loops over envelopes from your own code. If you started a bulk send in the Propper web app, see Tracking and Recovering Bulk Sends to check its progress, remind signers, and cancel unsent copies over the API.

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}/oauth2/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​