Updated September 2026 · Systems Architecture
Batch E-Signature API: How to Send Hundreds of Documents at Scale (2026)
Dispatching 50 offer letters, 200 vendor NDAs, or 1,000 quarterly renewal contracts requires a resilient batch pipeline. Here is how to structure concurrency pools, prevent duplicate dispatches with idempotency keys, handle partial failures, and collate asynchronous webhook completions.
Founder, Signbee
TL;DR
Most signing APIs handle one document per HTTP call. Naive loops with Promise.all trigger fatal unhandled rejections and 429 rate limit errors when one contract fails. Production batch architectures require: (1) Concurrency limits via p-limit or worker pools, (2) Deterministic idempotency keys to avoid double-sending, (3) Promise.allSettled isolation, and (4) Asynchronous webhook aggregation. At Signbee's 1,000 req/min rate limit, 500 documents finish in under 2 minutes.
Why High-Volume Batch Signing Breaks Naive Scripts
Batch signing is standard operational procedure for modern SaaS companies, procurement departments, and staffing agencies:
- HR & Staffing: Disagreeing offer letters, non-solicitation covenants, or annual handbook updates to hundreds of employees simultaneously.
- Fintech & Insurance: Mass policy renewal packets dispatched at the start of each underwriting cycle.
- Procurement & Vendor Management: Annual Information Security Addenda (ISA) distributed across hundreds of suppliers.
When developers attempt to fire a raw loop of 500 fetch() calls simultaneously, three failures inevitably emerge: socket exhaustion on the client runtime, instant 429 rate limiting by the upstream API, and complete loss of failure tracking when one request aborts the entire execution promise chain.
Batch Architecture Patterns Compared
| Pattern | Throughput (500 docs) | Memory Usage | Failure Isolation | Recommended? |
|---|---|---|---|---|
| Sequential Loop (await in for) | ~15–20 minutes | Very Low (<10MB) | Fragile (halts on error) | No (too slow) |
| Unbounded Promise.all | Crashes with 429 / ENOTFOUND | High (OOM risk) | Zero (one fail aborts all) | Dangerous |
| Chunked Promise.allSettled | ~90 seconds | Controlled (~35MB) | Complete (per-item) | Yes (scripts & jobs) |
| Queue Worker (BullMQ / Redis) | ~60–90 seconds | Constant | Enterprise Grade | Yes (large SaaS) |
Production TypeScript Pattern: Chunked Concurrency & Idempotency
This production script accepts an arbitrary array of contracts, batches them in chunks of 15 concurrent dispatches, applies an SHA-256 idempotency hash, and partitions successes from failures:
import crypto from "crypto";
export interface BatchContractItem {
id: string; // Database internal ID
recipientName: string;
recipientEmail: string;
markdownContent: string;
metadata?: Record<string, any>;
}
export interface BatchDispatchResult {
successful: Array<{ id: string; documentId: string; signingUrl: string }>;
failed: Array<{ id: string; error: string }>;
}
export async function executeBatchSigning(
contracts: BatchContractItem[],
concurrencyLimit: number = 15,
delayBetweenChunksMs: number = 750
): Promise<BatchDispatchResult> {
const successful: BatchDispatchResult["successful"] = [];
const failed: BatchDispatchResult["failed"] = [];
const apiKey = process.env.SIGNBEE_API_KEY!;
const endpoint = "https://signb.ee/api/v1/send";
// Chunk array into manageable concurrency blocks
for (let i = 0; i < contracts.length; i += concurrencyLimit) {
const chunk = contracts.slice(i, i + concurrencyLimit);
const chunkPromises = chunk.map(async (contract) => {
// Deterministic idempotency key: prevents double dispatches on retries
const idempotencyKey = crypto
.createHash("sha256")
.update(`${contract.id}-${contract.recipientEmail}`)
.digest("hex");
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
markdown: contract.markdownContent,
recipient_name: contract.recipientName,
recipient_email: contract.recipientEmail,
metadata: {
...contract.metadata,
batch_id: "batch_2026_q3_renewals",
internal_contract_id: contract.id,
},
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
return {
id: contract.id,
documentId: data.document_id,
signingUrl: data.signing_url,
};
});
// Promise.allSettled prevents one bad email from terminating the entire batch
const results = await Promise.allSettled(chunkPromises);
results.forEach((res, index) => {
const contract = chunk[index];
if (res.status === "fulfilled") {
successful.push(res.value);
} else {
failed.push({
id: contract.id,
error: res.reason instanceof Error ? res.reason.message : String(res.reason),
});
}
});
// Rate-limit buffer between chunks to maintain stable socket pool
if (i + concurrencyLimit < contracts.length) {
await new Promise((resolve) => setTimeout(resolve, delayBetweenChunksMs));
}
}
return { successful, failed };
}Python Implementation: Asyncio Semaphore
In Python, achieve the exact same controlled throughput using an asyncio.Semaphore with the high-performance httpx client:
import asyncio
import httpx
from typing import List, Dict, Any
async def send_single_agreement(client: httpx.AsyncClient, sem: asyncio.Semaphore, item: Dict[str, Any], api_key: str):
async with sem:
try:
res = await client.post(
"https://signb.ee/api/v1/send",
headers={"Authorization": f"Bearer {api_key}"},
json={
"markdown": item["markdown"],
"recipient_name": item["name"],
"recipient_email": item["email"],
"metadata": {"batch_id": "batch_annual_nda", "user_id": item["id"]}
},
timeout=15.0
)
res.raise_for_status()
data = res.json()
return {"id": item["id"], "success": True, "document_id": data["document_id"]}
except Exception as e:
return {"id": item["id"], "success": False, "error": str(e)}
async def dispatch_all_batch(items: List[Dict[str, Any]], api_key: str, max_concurrent: int = 15):
sem = asyncio.Semaphore(max_concurrent)
async with httpx.AsyncClient() as client:
tasks = [send_single_agreement(client, sem, item, api_key) for item in items]
results = await asyncio.gather(*tasks)
return resultsWebhook Collation: Tracking Completion Across Thousands of Contracts
Once documents are dispatched in batch, the signing ceremony happens out-of-band over hours or days. Do not poll status endpoints for 500 documents; configure an asynchronous webhook listener that updates your database on document.completed events:
Event Fan-Out Architecture
When Signbee delivers an event webhook to your endpoint, extract the metadata.batch_id andmetadata.internal_contract_id. Update your local database record to signed and increment the batch completed counter. When completed_count === total_batch_count, fire an internal alert (Slack or email) announcing that the entire batch has reached 100% execution.
Handling Partial Batch Failures & Automated Reconciliations
When dispatching 500 contracts, real-world failures happen: invalid counterparty email syntax, DNS lookup failures on corporate mail servers, or upstream mailbox quotas. High-throughput pipelines must never abort the entire batch when individual items fail.
Instead, isolate failed items into a dedicated retry table while permitting valid agreements to proceed through the signing pipeline uninterrupted:
-- Master batch record tracking overall progress
CREATE TABLE contract_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
total_count INT NOT NULL,
dispatched_count INT DEFAULT 0,
signed_count INT DEFAULT 0,
failed_count INT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
-- Individual items within the batch
CREATE TABLE batch_contracts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
batch_id UUID REFERENCES contract_batches(id) ON DELETE CASCADE,
recipient_email VARCHAR(255) NOT NULL,
recipient_name VARCHAR(255) NOT NULL,
signbee_document_id VARCHAR(64),
status VARCHAR(32) DEFAULT 'queued', -- queued, dispatched, signed, failed
error_message TEXT,
retry_count INT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
signed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_batch_contracts_lookup ON batch_contracts(batch_id, status);
CREATE INDEX idx_batch_signbee_doc ON batch_contracts(signbee_document_id);Automated Selective Re-Drive for Failed Contract Dispatches
With this relational design, recovering from failures is trivial. Operators can query all records where status = 'failed', correct typos in recipient email addresses via your internal admin panel, and invoke a targeted re-drive script that dispatches only the unfulfilled subset without disturbing the hundreds of agreements already signed:
export async function redriveFailedBatchContracts(batchId: string) {
const failedItems = await db.select()
.from(batchContracts)
.where(and(
eq(batchContracts.batchId, batchId),
eq(batchContracts.status, "failed")
));
console.log(`Found ${failedItems.length} failed agreements to re-drive in batch ${batchId}`);
// Re-run batch dispatch using standard concurrency limiter
for (const contract of failedItems) {
try {
const res = await sendSingleAgreement(contract);
await db.update(batchContracts)
.set({ status: "dispatched", signbeeDocumentId: res.document_id, error_message: null })
.where(eq(batchContracts.id, contract.id));
} catch (err: any) {
await db.update(batchContracts)
.set({ retry_count: contract.retry_count + 1, error_message: err.message })
.where(eq(batchContracts.id, contract.id));
}
}
}For more architectural details on rate limits and webhook security, read our Rate Limits & Retry Strategies Guide and our Electronic Signature API Guide.
Frequently Asked Questions
Can I send hundreds of documents for e-signature in a single batch operation?
Most e-signature APIs operate on a single-document transaction model where each agreement is created via an individual HTTP POST request. To execute batch dispatches of 50, 500, or 5,000 agreements, developers use asynchronous concurrency pooling patterns like Promise.allSettled with chunking in Node.js or asyncio.gather with an asyncio.Semaphore in Python. At Signbee's typical sub-second API latency and 1,000 requests per minute throughput ceiling, a batch of 500 personalized agreements can be dispatched in approximately 60 to 90 seconds while staying safely beneath rate limits.
How do I prevent duplicate document dispatches during batch retries?
Network timeouts or transient HTTP 5xx errors can cause a client to retry a request that the server actually received and processed. To prevent sending duplicate signing emails and confusing signers, high-volume batch pipelines must implement idempotency keys. By passing a unique client-generated hash (such as SHA-256 of the user ID, document template version, and billing cycle) in an Idempotency-Key request header or database transaction record, the signing API recognizes duplicate submissions and safely returns the existing document ID and signing URL rather than creating a second agreement.
How should a system collate status updates for hundreds of batch-dispatched contracts?
Never poll status endpoints repeatedly across hundreds of documents; polling generates extreme API traffic and risks 429 throttling. Instead, use an asynchronous webhook fan-out architecture. When dispatching each batch item, attach a parent batch_id or metadata tag to the request payload. As recipients open, review, and sign documents over subsequent hours or days, the e-signature API sends event webhooks back to your endpoint. Your webhook listener records individual document completions in your database and checks whether all items associated with that batch_id have reached terminal signed state.
Scale your document pipeline without per-seat fees — $0.50 per completed document, 5 free docs/month.
Last updated: September 2026 · Batch architecture tested across Node.js 20+ and Python 3.12 runtimes. Michael Beckett is the founder of Signbee and B2bee Ltd.