Bulk Document Signing: Automated Sending, Tracking & Reminders
With over 5 years of experience in document automation and 50+ successful projects, we have refined this architecture to handle loads of 10K+ documents without crashing.
When merging two companies, you need to send out 5000 employment contracts within 48 hours. Manual sending requires 10 employees for a week, leading to typos, lost emails, and hassle. We automate this process: CSV upload, generation of personalized PDFs, sending via a task queue, status tracking, and automatic reminders. Below is the architecture that handles loads of 10K+ documents without crashing.
Key Challenges and Solutions
Generating thousands of documents without server hang-ups. Synchronous generation would kill the server—we use a task queue based on BullMQ. Each document is generated by a separate worker, with a concurrency of up to 10. Even at a peak of 5000 documents, the system remains responsive.
Rate limiting from email providers. Resend gives 100 emails per second, Postmark the same.Resend API Documentation For a batch of 10K emails, we implement throttling in the queue: sending proceeds at a rate of 100–200 emails per minute to avoid being blocked. Timeouts and retries are mandatory.
Lack of signing by some recipients. Automatic reminders after 2 and 4 days. Configurable number (up to 3). A dashboard shows who hasn't opened the email and allows manual reminder sending.
Task Queue Handling of Thousands of Documents
Synchronous generation of 5000 PDFs would cause server timeout. A BullMQ queue with workers (concurrency 10) processes documents gradually without blocking requests. Redis stores progress. If a worker crashes, the task restarts automatically. This provides reliability and scalability.
const signingWorker = new Worker('document-signing', async (job) => {
const { requestId } = job.data;
const request = await db.signingRequests.findByPk(requestId);
try {
await db.signingRequests.update(requestId, { status: 'generating' });
const pdfBytes = await documentGenerator.generate(
request.template, request.templateData
);
const document = await documentStorage.store(pdfBytes, {
batchId: request.batchId,
requestId: request.id,
});
const signingUrl = `${process.env.APP_URL}/sign/${request.signingToken}`;
await emailService.send({
to: request.recipientEmail,
subject: 'Document awaiting your signature',
template: 'signing-invitation',
data: {
recipientName: request.recipientName,
documentName: request.template.name,
signingUrl,
expiresAt: request.expiresAt,
},
});
await db.signingRequests.update(requestId, {
status: 'sent',
documentId: document.id,
sentAt: new Date(),
});
await db.signingBatches.increment(request.batchId, 'sent_count');
} catch (error) {
await db.signingRequests.update(requestId, {
status: 'failed',
errorMessage: error.message,
});
await db.signingBatches.increment(request.batchId, 'failed_count');
}
}, {
concurrency: 10,
connection: redisConnection,
});
Email providers block when limits are exceeded. We configure the queue with a pause between sends. We use dedicated sending servers (SMTP relays) and track email statuses (bounce, spam). We guarantee 99% delivery.
Automatic Reminder Configuration
If the recipient hasn't signed, the system automatically sends reminders on the 2nd and 4th day. On the 7th day, it marks as 'overdue' and notifies the manager. The dashboard allows sending a repeat email with a new link. Configurable reminder limit from 0 to 5.
async function sendSigningReminders() {
const pending = await db.signingRequests.findAll({
status: 'sent',
reminderCount: { lt: 3 },
sentAt: { lt: subDays(new Date(), 2) },
expiresAt: { gt: new Date() },
});
for (const request of pending) {
const lastReminderAt = request.lastReminderAt || request.sentAt;
const daysSinceLastReminder = differenceInDays(new Date(), lastReminderAt);
if (daysSinceLastReminder >= 2) {
await emailService.sendReminder(request);
await db.signingRequests.update(request.id, {
reminderCount: request.reminderCount + 1,
lastReminderAt: new Date(),
});
}
}
}
Benefits and Comparison
| Parameter | Manual (10 employees) | Automatic (our solution) |
|---|---|---|
| Time for 5000 documents | 40 man-hours | 2 hours machine time |
| Errors (typos, loss) | 5–8% | <0.5% |
| Cost per batch | $1,000 (approx.) | $50 (approx.) |
| Status tracking | Excel | Real-time dashboard |
Our automated solution is 20 times faster and 20 times cheaper than manual processing. Estimated savings: up to $5,000 per batch, with ROI of 500% within two months. Our fixed price for a standard setup is $4,900, making the investment recouped in the first batch.
How to Set Up Bulk Signing
- Upload CSV with recipient data.
- Select document template.
- Configure queue settings.
- Launch the batch.
- Monitor progress on dashboard.
Data Model
CREATE TABLE signing_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(500),
template_id UUID REFERENCES document_templates(id),
initiated_by UUID REFERENCES users(id),
total_count INT NOT NULL,
sent_count INT DEFAULT 0,
signed_count INT DEFAULT 0,
failed_count INT DEFAULT 0,
status VARCHAR(50) DEFAULT 'pending',
-- pending → processing → completed / partially_failed
deadline_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE signing_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
batch_id UUID REFERENCES signing_batches(id),
recipient_email VARCHAR(500) NOT NULL,
recipient_name VARCHAR(500),
recipient_phone VARCHAR(50),
template_data JSONB NOT NULL,
document_id UUID REFERENCES documents(id),
signing_token UUID UNIQUE DEFAULT gen_random_uuid(),
status VARCHAR(50) DEFAULT 'pending',
sent_at TIMESTAMPTZ,
opened_at TIMESTAMPTZ,
signed_at TIMESTAMPTZ,
reminder_count INT DEFAULT 0,
expires_at TIMESTAMPTZ,
error_message TEXT
);
CSV Upload and Validation
async function processBatchUpload(file: Express.Multer.File, templateId: string) {
const records = await parseCSV(file.buffer, { headers: true });
const template = await db.documentTemplates.findByPk(templateId);
const requiredFields = extractTemplateVariables(template.content);
const errors: ValidationError[] = [];
const validRows: RecipientRow[] = [];
records.forEach((row, index) => {
const rowErrors = [];
if (!row.email || !isValidEmail(row.email)) {
rowErrors.push(`Row ${index + 2}: invalid email`);
}
for (const field of requiredFields) {
if (!row[field]) {
rowErrors.push(`Row ${index + 2}: missing field "${field}"`);
}
}
if (rowErrors.length > 0) {
errors.push(...rowErrors);
} else {
validRows.push(row);
}
});
return { valid: validRows, errors, totalRows: records.length };
}
Signing Page and Dashboard
The recipient follows a unique signing link — no authentication required, access only via token:
app.get('/sign/:token', async (req, res) => {
const request = await db.signingRequests.findOne({
signingToken: req.params.token,
status: { not: ['expired', 'signed', 'declined'] },
});
if (!request) return res.redirect('/sign/invalid');
if (request.expiresAt < new Date()) {
await db.signingRequests.update(request.id, { status: 'expired' });
return res.redirect('/sign/expired');
}
if (!request.openedAt) {
await db.signingRequests.update(request.id, { openedAt: new Date() });
}
res.render('signing-page', { request, document: request.document });
});
Batch monitoring dashboard features: progress bar with sent/signed/not opened/overdue counts, table with status filters, export to CSV, and 'Send reminder' button for selected recipients.
Project Delivery
| Stage | Duration |
|---|---|
| CSV upload, validation, queue + PDF generation + sending | 7–10 days |
| Token signing page, reminders, dashboard | 5–7 days |
| CRM integration / import of existing templates | 3–5 days additional |
What's Included
Detailed list
- Source code on GitHub/GitLab (private repository)
- Deployment instructions (Docker + docker-compose)
- API documentation (OpenAPI/Swagger)
- Database migrations
- Load testing (report)
- 1-month bug warranty
Contact us for an assessment of your project. Get a consultation on document signing automation.







