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.

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

PatternThroughput (500 docs)Memory UsageFailure IsolationRecommended?
Sequential Loop (await in for)~15–20 minutesVery Low (<10MB)Fragile (halts on error)No (too slow)
Unbounded Promise.allCrashes with 429 / ENOTFOUNDHigh (OOM risk)Zero (one fail aborts all)Dangerous
Chunked Promise.allSettled~90 secondsControlled (~35MB)Complete (per-item)Yes (scripts & jobs)
Queue Worker (BullMQ / Redis)~60–90 secondsConstantEnterprise GradeYes (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:

TypeScript (Node.js) — Resilient Batch Document Dispatcher
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:

Python — Asyncio Batch E-Signature Dispatcher
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 results

Webhook 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:

PostgreSQL — High-Volume Batch Schema with Status Tracking
-- 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:

TypeScript — Selective Re-Drive Function
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.

Related resources