Building a Production-Grade DocuSign Alternative — Document Lifecycle, Cryptographic Integrity & Legal Compliance
1. Introduction & Why E-Signature Platforms Matter
The global e-signature market is projected to reach $35 billion by 2030, growing at a compound annual growth rate of over 25%. Every real estate transaction, employment contract, insurance claim, financial agreement, and government filing increasingly depends on digital signing. Platforms like DocuSign, Adobe Sign, HelloSign (now Dropbox Sign), and PandaDoc have transformed what was once a paper-heavy, ink-and-mail process into a seamless digital experience. The e-signature market is not a niche — it is foundational infrastructure for modern business.
Designing an e-signature platform from scratch is a deceptively deep systems engineering challenge. On the surface, it appears simple: upload a document, place some fields, send it to signers, collect signatures, done. But beneath this simplicity lies a complex web of requirements: cryptographic document integrity (the completed document must be tamper-evident), legal compliance (the ESIGN Act, eIDAS, and UETA impose specific requirements), identity verification (who is actually signing?), audit trail integrity (every action must be logged immutably), multi-party coordination (what if one of five signers declines?), document rendering (PDFs must render identically across devices), field placement (drag-and-drop that works precisely on every screen size), template engines (reusable document templates with merge fields), payment collection (sign-and-pay in a single flow), bulk sending (thousands of documents in one batch), and enterprise integrations (Salesforce, SAP, custom CRMs). Each of these areas is a substantial engineering domain on its own.
The stakes are high. In 2023, DocuSign processed over 1 billion envelope transactions annually across 188 countries. A single security breach or legal challenge to document integrity could expose millions of organizations to liability. The platform must be simultaneously easy enough for a small business owner to use without training, yet robust enough to satisfy the evidentiary requirements of federal courts and international regulatory bodies. This tension between simplicity and rigor is the central design challenge.
Real-World Case Studies
Understanding how major players approach e-signature systems reveals important design patterns and tradeoffs:
| Company | System | Scale | Key Innovation |
|---|---|---|---|
| DocuSign | E-signature + Agreement Cloud | 1B+ envelopes/year, 188 countries | Tamper-evident audit trail, ID verification, Agreement AI |
| Adobe Sign | Document Cloud signing | 8B+ signatures/year | Deep PDF integration, Adobe ecosystem synergy |
| HelloSign (Dropbox) | SMB-focused e-signature | Millions of signatures/month | Developer-first API, simple UX, Dropbox integration |
| PandaDoc | Document automation + signing | Millions of docs/month | Proposals, quotes, and contracts in one flow, payment collection |
| Zoho Sign | Enterprise e-signature | Millions of signatures/year | Zoho ecosystem integration, eIDAS compliance |
DocuSign's architecture is built on a microservices foundation where the "envelope" (their term for a signing transaction) is the primary entity. Each envelope goes through a well-defined state machine with over a dozen states, and every state transition triggers events that update the audit trail, send notifications, and potentially invoke webhook callbacks. Their signing ceremony is a carefully orchestrated sequence of identity verification, document presentation, field completion, and cryptographic sealing — all rendered in a web-based or mobile-optimized UI that must work reliably across browsers, operating systems, and screen sizes.
Adobe Sign leverages Adobe's deep PDF expertise. When a document is uploaded, it is converted to PDF (if not already), and the signing ceremony uses Adobe's PDF rendering engine to display the document with pixel-perfect fidelity. The completed document is sealed with a PAdES (PDF Advanced Electronic Signatures) digital signature that cryptographically binds the signature to the document content. This means any subsequent modification to the PDF — even changing a single byte — will invalidate the signature, providing tamper evidence that holds up in court.
HelloSign (acquired by Dropbox for $230 million in 2019) took a developer-first approach, offering one of the most elegant e-signature APIs in the market. Their API design influenced the entire industry: clean resource-oriented endpoints, webhook-driven event notification, and an embeddable SDK that allowed any web application to add signing functionality in under 100 lines of code. This approach demonstrated that the API itself could be the primary product, with the web UI as a secondary interface.
PandaDoc expanded beyond pure e-signature into document automation, combining templates, content libraries, pricing tables, and e-signature into a single workflow. Their innovation was recognizing that in many businesses, the document lifecycle starts long before signing — with proposal creation, negotiation, and approval. By integrating these stages, PandaDoc reduced the total time from contract initiation to execution from weeks to hours. Their payment collection feature, which allows signers to pay via credit card as part of the signing ceremony, eliminates the separate invoicing step and improves cash flow.
2. Functional & Non-Functional Requirements
Functional Requirements
- Document Upload & Management: Users can upload PDF, Word, and image documents, which are converted to PDF for signing. Documents support versioning and are stored encrypted at rest. Supported formats include PDF, DOCX, DOC, PNG, JPG, and TIFF. Each upload is checksummed before and after conversion to ensure integrity.
- Envelope Creation: Users create envelopes containing one or more documents, specify signers, define signing order (sequential or parallel), and place signing fields on documents. Envelopes support custom metadata, email subject/message customization, and configurable expiration periods.
- Signing Ceremony: Signers access documents through a secure web portal or mobile-optimized interface, complete their fields, and apply signatures. The ceremony supports multiple authentication methods, real-time field validation, and provides a legally compliant consent-to-electronic-records step.
- Signature Types: Support for drawn signatures (finger or mouse input with stroke data capture), uploaded image signatures (PNG/JPG with background removal), adopt-a-signature (select from pre-made cursive/print styles), and click-to-sign (I agree checkbox with timestamp and IP logging).
- Field Types: Signatures, initials, dates (auto-populated and editable), text fields (with regex validation), checkboxes, radio buttons, dropdown menus, formula fields (computed from other field values), and attachment fields (where signers upload supporting documents).
- Signing Orders: Sequential (signer B waits for signer A to complete), parallel (all signers receive the document simultaneously), and hybrid (group A members receive in parallel, then group B members receive sequentially after group A completes). Groups support up to 10 levels of nesting.
- Identity Verification: SMS passcode (6-digit code sent to registered phone), email verification (one-time link + code), knowledge-based authentication (3-5 questions from public records), ID document verification (photograph government-issued ID), and biometric face match (selfie compared to ID photo).
- Access Control: Access codes (6-8 digit codes shared through out-of-band channels), email authentication (magic link + code), phone verification (SMS or IVR), and multi-factor authentication for high-security signing ceremonies. Rate limiting prevents brute-force attempts (5 failed attempts → 15-minute lockout).
- Template System: Reusable document templates with placeholder fields, merge fields for bulk operations, conditional visibility rules (show/hide fields based on other field values), and template sharing across teams and departments within an organization.
- Bulk Send: Send the same template to hundreds or thousands of recipients with personalized data from a CSV/Excel upload. Each recipient gets an individual envelope with their specific data merged into the document and fields. Progress tracking shows per-recipient status in real-time.
- Audit Trail: Immutable, detailed event log recording every action: envelope creation, sending, delivery, viewing, field completion, signing, completion, downloading, and accessing. Each event includes precise UTC timestamps (millisecond precision), IP addresses, user agent strings, and actor identification. Events are cryptographically chained using hash references.
- Tamper-Evident Seal: Completed documents are digitally signed with a cryptographic seal using the platform's certificate issued by a trusted Certificate Authority. The seal binds together document content, all signatures, the audit trail hash, and the completion timestamp. Any modification after sealing invalidates the seal and is immediately detectable through verification.
- Reminders & Expiry: Automated reminder emails/SMS to pending signers at configurable intervals (daily, every 3 days, weekly). Configurable expiration dates (7, 14, 30, 60, 90 days). Automatic voiding of expired envelopes with notification to all parties. Reminder frequency can be adjusted per-recipient.
- Payment Collection: Collect payments (credit card via Stripe, ACH bank transfer) as part of the signing flow. Payment is conditional on document completion. Supports partial payments, deposits, and installments. Payment receipts are attached to the audit trail.
- Document Workflows: Multi-step routing with conditional logic (if contract value > $10K, route to VP for approval), approval gates, automatic escalation (if approver doesn't respond in 48 hours, escalate to their manager), and parallel approval paths.
- Integrations: REST API with OAuth 2.0 authentication, webhooks for real-time event notification, Salesforce native connector (bidirectional sync), CRM integrations (HubSpot, Microsoft Dynamics), and embeddable JavaScript SDK for embedding signing into third-party applications.
- In-Person Signing: A single device (tablet, kiosk) collects signatures from multiple signers in the same physical location. The host manages the signing flow without requiring signers to have individual email addresses. Supports offline signing with sync-on-reconnect for environments with unreliable internet.
- Notification System: Email (via SendGrid/AWS SES), SMS (via Twilio), and push notifications for all lifecycle events: invitation to sign, reminders, completion notices, document access notifications, and workflow step changes. Notification templates are customizable per-tenant.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.99% | Legal and business-critical — downtime means missed contract deadlines and lost deals |
| Envelope Throughput | 10,000 envelopes/minute peak | Enterprise bulk send and end-of-quarter peak loads |
| Signing Ceremony Latency | < 2 seconds page load | Signers abandon slow experiences — every second of delay reduces completion rate |
| Document Storage | Encrypted at rest (AES-256-GCM), in transit (TLS 1.3) | Legal documents demand maximum security — encryption is non-negotiable |
| Data Residency | Multi-region (US, EU, APAC) | GDPR and data sovereignty requirements mandate in-region storage |
| Audit Trail Immutability | Append-only, cryptographically chained | Evidentiary requirements for legal proceedings — no modifications allowed |
| Tamper Evidence | Cryptographic seal on every completed document | Proof of document integrity post-signing — essential for court admissibility |
| Compliance | ESIGN Act, eIDAS, UETA, SOC 2 Type II, ISO 27001, HIPAA | Legal validity and enterprise customer requirements across jurisdictions |
| Retention | 7 years minimum (configurable up to 25 years per tenant) | Legal hold and regulatory retention requirements vary by industry |
| API Rate Limit | 1,000 requests/min per API key, 10,000/min for enterprise | Protect platform from abuse while enabling high-volume integrations |
| Recovery Time Objective (RTO) | < 5 minutes for core signing services | Extended downtime directly impacts business-critical transactions |
| Recovery Point Objective (RPO) | < 1 minute (zero data loss for signed documents) | Every signed document is a legal record — no data loss acceptable |
3. Capacity Estimation & Scale
Envelope Volume
- Active customers: 500,000 organizations
- Average envelopes per organization per month: 200
- Total envelopes per month: 100 million
- Total envelopes per day: ~3.3 million
- Peak QPS (business hours, Mon-Fri 9am-5pm): 3,000 envelopes/second
- Off-peak QPS (nights, weekends): ~200 envelopes/second
- Average signers per envelope: 2.5
- Average documents per envelope: 2
- Average fields per recipient: 5
- Signing ceremony sessions (concurrent peak): ~50,000
Storage
- Average document size: 500 KB (2 pages typical business document)
- Large document size (100+ page contracts): ~5 MB average
- Weighted average document size: 800 KB
- Daily document uploads: 3.3M envelopes × 2 docs = 6.6M documents
- Daily storage growth: 6.6M × 800 KB = ~5.3 TB/day
- Annual storage: ~1.9 PB/year (before deduplication)
- With deduplication (identical templates): ~1.2 PB/year
- Compressed storage (2:1 ratio): ~600 TB/year
- Audit trail records: 3.3M envelopes × 15 events average = ~50M events/day
- Audit trail storage: 50M × 0.5 KB = 25 GB/day, ~9 TB/year
- Metadata database (PostgreSQL): ~500 GB/year
- Redis session data: ~50 GB (hot), ~200 GB (with history)
- Thumbnail storage (PNG pages for signing ceremony): 6.6M × 3 pages × 100 KB = ~2 TB/day
Compute
- API Gateway: 50-node cluster (handles 3,000 QPS peak, each node ~60 QPS)
- Signing Ceremony Service: 100-node cluster (50K concurrent sessions, 500 per node)
- Document Processing Workers: 200-node cluster (PDF conversion, rendering, field extraction)
- Notification Service: 30-node cluster (email, SMS, push dispatch)
- Audit Trail Service: 20-node cluster (append-only event logging with hash chaining)
- Seal Service: 10-node cluster (HSM-intensive, lower throughput per node)
- Metadata Database: PostgreSQL cluster (6 nodes: 3 primary + 3 read replicas)
- Document Storage: S3-compatible object storage (multi-region replication)
- Cache Layer: Redis cluster (12 nodes, 3 shards with replicas)
- Message Queue: Kafka (12 brokers, 64 partitions per topic)
Network & Bandwidth
- Document uploads: 6.6M × 800 KB = ~5.3 TB/day inbound
- Document downloads (signing ceremony page renders + completion): ~15 TB/day outbound
- API traffic: ~50 GB/day inbound, ~100 GB/day outbound
- Notification traffic: ~2 GB/day (emails + SMS metadata)
- Cross-region replication: ~8 TB/day (document and audit trail replication)
- CDN traffic (signing ceremony pages, thumbnails): ~20 TB/day outbound
- Kafka internal traffic: ~5 GB/day (event streaming between services)
4. Data Model & Storage Schema
The data model centers on the "envelope" — the primary entity representing a signing transaction. An envelope contains documents, recipients, and signing fields, and transitions through a well-defined state machine. The schema is designed for both transactional integrity (PostgreSQL) and high-volume append-only logging (Kafka → time-series database).
SQL
CREATE TABLE envelopes (
envelope_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
sender_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
-- draft, sent, delivered, viewed, in_progress, signed,
-- completed, declined, voided, expired
subject VARCHAR(500),
message TEXT,
email_subject VARCHAR(500),
signing_order VARCHAR(20) DEFAULT 'parallel',
-- parallel, sequential, hybrid
expiration_days INTEGER DEFAULT 30,
reminder_interval_days INTEGER DEFAULT 3,
enable_reminders BOOLEAN DEFAULT TRUE,
access_code VARCHAR(100),
authentication_method VARCHAR(30),
jurisdiction VARCHAR(10) DEFAULT 'US',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
sent_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
voided_at TIMESTAMPTZ,
voided_reason TEXT,
declined_at TIMESTAMPTZ,
declined_reason TEXT,
expired_at TIMESTAMPTZ
);
CREATE TABLE envelope_documents (
document_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
envelope_id UUID NOT NULL REFERENCES envelopes(envelope_id)
ON DELETE CASCADE,
document_order INTEGER NOT NULL DEFAULT 0,
original_name VARCHAR(500) NOT NULL,
file_type VARCHAR(20) NOT NULL,
file_size BIGINT NOT NULL,
storage_key VARCHAR(1000) NOT NULL,
content_hash VARCHAR(128) NOT NULL, -- SHA-512 of original
pdf_storage_key VARCHAR(1000),
page_count INTEGER,
document_version INTEGER DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE recipients (
recipient_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
envelope_id UUID NOT NULL REFERENCES envelopes(envelope_id)
ON DELETE CASCADE,
recipient_order INTEGER NOT NULL DEFAULT 0,
recipient_group INTEGER DEFAULT 0,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(50),
role VARCHAR(50) NOT NULL,
-- signer, approver, cc, in-person-signer
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending, sent, delivered, viewed, signed, declined, completed
access_code VARCHAR(100),
authentication_method VARCHAR(30),
auth_verified BOOLEAN DEFAULT FALSE,
auth_verified_at TIMESTAMPTZ,
ip_address INET,
user_agent TEXT,
signed_at TIMESTAMPTZ,
declined_at TIMESTAMPTZ,
declined_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE signing_fields (
field_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES envelope_documents(document_id)
ON DELETE CASCADE,
recipient_id UUID NOT NULL REFERENCES recipients(recipient_id)
ON DELETE CASCADE,
field_type VARCHAR(30) NOT NULL,
-- signature, initials, date_signed, text, checkbox,
-- radio, dropdown, formula, attachment
field_name VARCHAR(255),
required BOOLEAN DEFAULT TRUE,
page_number INTEGER NOT NULL,
x_position DECIMAL(10,2) NOT NULL,
y_position DECIMAL(10,2) NOT NULL,
width DECIMAL(10,2) NOT NULL,
height DECIMAL(10,2) NOT NULL,
font_family VARCHAR(50),
font_size INTEGER,
font_color VARCHAR(20),
validation JSONB,
value TEXT,
completed_at TIMESTAMPTZ,
tab_order INTEGER,
conditional_logic JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
SQL
-- Audit trail with cryptographic chaining
CREATE TABLE audit_trail (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
envelope_id UUID NOT NULL REFERENCES envelopes(envelope_id),
event_type VARCHAR(50) NOT NULL,
actor_id UUID,
actor_type VARCHAR(30),
-- sender, signer, system, integrator
actor_email VARCHAR(255),
ip_address INET,
user_agent TEXT,
event_details JSONB,
previous_hash VARCHAR(128) NOT NULL,
event_hash VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tamper-evident seal records
CREATE TABLE completed_seals (
seal_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
envelope_id UUID NOT NULL REFERENCES envelopes(envelope_id),
seal_hash VARCHAR(128) NOT NULL,
digital_signature BYTEA NOT NULL,
certificate_id UUID NOT NULL,
sealed_at TIMESTAMPTZ NOT NULL,
verification_url VARCHAR(1000)
);
-- Templates
CREATE TABLE templates (
template_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
folder_id UUID,
document_id UUID,
field_definitions JSONB,
recipient_definitions JSONB,
is_shared BOOLEAN DEFAULT FALSE,
tags TEXT[],
usage_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Performance indexes
CREATE INDEX idx_envelopes_tenant_status
ON envelopes(tenant_id, status);
CREATE INDEX idx_envelopes_sender
ON envelopes(sender_id);
CREATE INDEX idx_envelopes_expiration
ON envelopes(status, expiration_days, sent_at)
WHERE status IN ('sent', 'delivered', 'viewed');
CREATE INDEX idx_recipients_email
ON recipients(email);
CREATE INDEX idx_recipients_envelope_status
ON recipients(envelope_id, status);
CREATE INDEX idx_audit_trail_envelope
ON audit_trail(envelope_id, created_at);
CREATE INDEX idx_audit_trail_hash_chain
ON audit_trail(envelope_id, event_id);
CREATE INDEX idx_signing_fields_document
ON signing_fields(document_id, recipient_id);
CREATE INDEX idx_templates_tenant
ON templates(tenant_id, is_shared);
previous_hash and event_hash columns in the audit trail form a cryptographic chain — each event references the hash of its predecessor, making it mathematically impossible to insert, remove, or modify any event in the chain without breaking the chain. This is the same principle behind blockchain, applied to audit logging.
Document Storage Strategy
Documents are stored in a tiered storage architecture optimized for access patterns and cost:
| Tier | Storage Type | Duration | Access Time | Cost/GB/Month |
|---|---|---|---|---|
| Hot | S3 Standard | 0-90 days after completion | Instant | $0.023 |
| Warm | S3 Infrequent Access | 90 days - 1 year | Minutes | $0.0125 |
| Cold | S3 Glacier Flexible | 1-5 years | Hours | $0.004 |
| Archive | S3 Glacier Deep Archive | 5+ years | 12-48 hours | $0.00099 |
5. High-Level Architecture Overview
Architecture Principles
The architecture follows an event-driven microservices pattern where the envelope is the central entity. All state transitions flow through the Envelope Service, which emits events to Kafka. Downstream services (audit trail, notifications, reminders, webhooks) subscribe to these events and process them asynchronously. The signing ceremony is the most latency-sensitive path — it must load the document, render fields, collect input, and seal the result in real-time. This path is optimized with aggressive caching (Redis holds pre-rendered document pages, field coordinates, and session state) and dedicated connection pools.
The separation of the Tamper-Evident Seal Service into its own microservice is deliberate. Sealing requires access to a Hardware Security Module (HSM) that stores the platform's signing certificate, and HSM operations are slow (100-500ms per operation). By isolating this service, we prevent HSM latency from blocking the signing ceremony while still ensuring that every completed document is sealed before it is marked as complete. The Seal Worker processes seal requests from a dedicated Kafka topic, ensuring ordering guarantees and exactly-once processing via consumer group coordination.
The document service handles the most data-intensive operations: upload, conversion, rendering, and retrieval. It maintains a pipeline architecture where uploaded documents flow through sequential processing stages: validation → checksum → storage → PDF conversion → page rendering → thumbnail generation. Each stage is independently scalable and failure-isolated — if thumbnail generation fails, the document is still available for signing (just without previews). The pipeline uses a saga pattern with compensating transactions to ensure consistency: if PDF conversion fails after the original document is stored, the pipeline automatically cleans up the stored document and marks the upload as failed.
The notification service operates on a fan-out pattern. When an envelope state changes, a single event is published to Kafka, and the notification service fans it out to the appropriate channels: email invitations, SMS reminders, webhook callbacks, Salesforce sync, and push notifications. Each channel has its own retry logic, delivery tracking, and rate limiting. The service maintains per-tenant notification preferences and throttling rules — some tenants may want real-time notifications, while others prefer batched daily digests.
Data Flow: End-to-End Signing Process
6. API Design
The API follows RESTful conventions with resource-oriented design. Every resource (envelope, document, recipient, field) has a clear URI pattern and supports standard CRUD operations plus domain-specific actions. Authentication uses OAuth 2.0 with API keys for server-to-server integrations and session tokens for the signing ceremony.
Core Endpoints
REST API
# Envelope Operations
POST /api/v1/envelopes # Create new envelope
GET /api/v1/envelopes/{id} # Get envelope details
PUT /api/v1/envelopes/{id} # Update envelope (draft only)
DELETE /api/v1/envelopes/{id} # Void envelope
POST /api/v1/envelopes/{id}/send # Send envelope to recipients
POST /api/v1/envelopes/{id}/void # Void with reason
POST /api/v1/envelopes/{id}/resend # Resend to pending recipients
GET /api/v1/envelopes/{id}/status # Get detailed status
# Document Operations
POST /api/v1/envelopes/{id}/documents # Upload document (multipart)
GET /api/v1/envelopes/{id}/documents # List documents
GET /api/v1/envelopes/{id}/documents/{did} # Download signed document
GET /api/v1/envelopes/{id}/documents/{did}/pages/{page} # Render page
# Recipient Operations
POST /api/v1/envelopes/{id}/recipients # Add recipient
PUT /api/v1/envelopes/{id}/recipients/{rid} # Update recipient
DELETE /api/v1/envelopes/{id}/recipients/{rid} # Remove recipient
# Field Operations
POST /api/v1/envelopes/{id}/documents/{did}/fields # Add field
PUT /api/v1/envelopes/{id}/documents/{did}/fields/{fid} # Update field
DELETE /api/v1/envelopes/{id}/documents/{did}/fields/{fid} # Remove field
# Signing Ceremony (Signer-facing, authenticated via session token)
POST /api/v1/signing/{envelopeId}/authenticate # Authenticate signer
GET /api/v1/signing/{envelopeId}/documents # Get signing documents
PUT /api/v1/signing/{envelopeId}/fields/{fieldId} # Complete a field
POST /api/v1/signing/{envelopeId}/complete # Submit signed envelope
POST /api/v1/signing/{envelopeId}/decline # Decline to sign
# Templates
POST /api/v1/templates # Create template
GET /api/v1/templates # List templates
GET /api/v1/templates/{id} # Get template
POST /api/v1/templates/{id}/create-envelope # Create envelope from template
# Bulk Send
POST /api/v1/bulk-send # Create bulk send job
GET /api/v1/bulk-send/{id} # Get bulk send status
GET /api/v1/bulk-send/{id}/recipients # Per-recipient status
# Audit Trail
GET /api/v1/envelopes/{id}/audit-trail # Get audit trail
GET /api/v1/envelopes/{id}/audit-trail/verify # Verify integrity
# Webhooks
POST /api/v1/webhooks # Register webhook
GET /api/v1/webhooks # List webhooks
DELETE /api/v1/webhooks/{id} # Delete webhook
# Verification (Public - no auth required)
GET /api/v1/verify/{envelopeId} # Verify document integrity
Envelope Creation Request
JSON
{
"emailSubject": "Please sign: NDA Agreement",
"emailBlurb": "Please review and sign the attached agreement.",
"signingOrder": "sequential",
"expirationDays": 14,
"reminderIntervalDays": 3,
"enableReminders": true,
"documents": [
{
"documentBase64": "JVBERi0xLjQK...",
"name": "NDA_Agreement.pdf",
"fileExtension": "pdf"
}
],
"recipients": [
{
"name": "Alice Johnson",
"email": "alice@example.com",
"role": "signer",
"recipientOrder": 1,
"accessCode": "123456",
"authenticationMethod": "sms",
"phoneNumber": "+1-555-0101"
},
{
"name": "Bob Smith",
"email": "bob@company.com",
"role": "signer",
"recipientOrder": 2,
"authenticationMethod": "email"
}
],
"fields": [
{
"documentIndex": 0,
"fieldName": "signature_alice",
"fieldType": "signature",
"pageNumber": 3,
"xPosition": 15.0,
"yPosition": 72.5,
"width": 30.0,
"height": 3.0,
"recipientEmail": "alice@example.com",
"required": true
}
]
}
Webhook Event Payload
JSON
{
"eventTimestamp": "2025-01-15T14:32:01.123Z",
"eventType": "envelope.completed",
"envelopeId": "550e8400-e29b-41d4-a716-446655440000",
"tenantId": "org_abc123",
"data": {
"status": "completed",
"completedAt": "2025-01-15T14:32:01.100Z",
"documents": [
{
"documentId": "doc_xyz789",
"name": "NDA_Agreement.pdf",
"signedUrl": "https://api.example.com/v1/envelopes/550e8400/documents/doc_xyz789/download?token=eyJ..."
}
],
"recipients": [
{
"email": "alice@example.com",
"status": "signed",
"signedAt": "2025-01-15T14:15:22.456Z"
},
{
"email": "bob@company.com",
"status": "signed",
"signedAt": "2025-01-15T14:32:01.089Z"
}
],
"auditTrailUrl": "https://api.example.com/v1/envelopes/550e8400/audit-trail"
}
}
Error Response Format
JSON
{
"error": {
"code": "MISSING_REQUIRED_FIELDS",
"message": "Cannot send envelope: 2 required fields are not completed.",
"details": [
{
"fieldId": "field_abc123",
"fieldName": "signature_alice",
"documentName": "NDA_Agreement.pdf",
"pageNumber": 3
}
],
"envelopeId": "550e8400-e29b-41d4-a716-446655440000",
"requestId": "req_xyz789"
}
}
requestId field for distributed tracing. The signing ceremony endpoints use a separate authentication path (session tokens) from the management API (OAuth 2.0 + API keys). The public verification endpoint (/api/v1/verify/{envelopeId}) requires no authentication — document verification must be publicly accessible for legal defensibility. Rate limiting applies per API key with separate limits for management operations (1,000/min) and signing ceremony operations (10,000/min).
7. Document Upload & Rendering (PDF Generation)
Document upload is the entry point for every signing transaction. The upload pipeline must handle multiple file formats, convert them to PDF, extract metadata, split pages for rendering, and store both the original and converted versions securely. The pipeline is designed for reliability — every uploaded document is checksummed before and after conversion to ensure integrity.
Upload Pipeline
PDF Conversion Service (C#)
C#
public class DocumentConversionService : IDocumentConversionService
{
private readonly IBlobStorage _blobStorage;
private readonly IDocumentRepository _documentRepository;
private readonly ILogger<DocumentConversionService> _logger;
private readonly IHashCalculator _hashCalculator;
public async Task<ConversionResult> ConvertToPdfAsync(
DocumentUploadRequest request,
CancellationToken cancellationToken)
{
var originalBytes = await _blobStorage.DownloadAsync(
request.OriginalStorageKey, cancellationToken);
var contentHash = _hashCalculator.ComputeSha512(originalBytes);
if (request.FileType.Equals("pdf",
StringComparison.OrdinalIgnoreCase))
{
var pageCount = await GetPdfPageCountAsync(originalBytes);
return new ConversionResult
{
PdfStorageKey = request.OriginalStorageKey,
OriginalStorageKey = request.OriginalStorageKey,
ContentHash = contentHash,
PageCount = pageCount,
ConversionType = "identity"
};
}
var pdfBytes = await ConvertUsingLibreOfficeAsync(
originalBytes, request.FileType, cancellationToken);
var pdfHash = _hashCalculator.ComputeSha512(pdfBytes);
var pdfPageCount = await GetPdfPageCountAsync(pdfBytes);
var pdfKey = $"{request.EnvelopeId}/{request.DocumentId}/converted.pdf";
await _blobStorage.UploadAsync(
pdfKey, pdfBytes, "application/pdf", cancellationToken);
await RenderPageThumbnailsAsync(
pdfBytes, pdfPageCount,
request.EnvelopeId, request.DocumentId,
cancellationToken);
_logger.LogInformation(
"Document {DocId} converted: {Type} to PDF, {Pages} pages",
request.DocumentId, request.FileType, pdfPageCount);
return new ConversionResult
{
PdfStorageKey = pdfKey,
OriginalStorageKey = request.OriginalStorageKey,
ContentHash = contentHash,
PageCount = pdfPageCount,
ConversionType = request.FileType
};
}
private async Task<byte[]> ConvertUsingLibreOfficeAsync(
byte[] fileBytes, string fileType,
CancellationToken cancellationToken)
{
var tempInput = Path.GetTempFileName() + $".{fileType}";
var tempOutput = Path.GetTempFileName() + ".pdf";
try
{
await File.WriteAllBytesAsync(
tempInput, fileBytes, cancellationToken);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "soffice",
Arguments = $"--headless --convert-to pdf " +
$"--outdir \"{Path.GetDirectoryName(tempOutput)}\" " +
$"\"{tempInput}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync(
cancellationToken);
throw new ConversionException(
$"LibreOffice conversion failed: {error}");
}
return await File.ReadAllBytesAsync(
tempOutput, cancellationToken);
}
finally
{
if (File.Exists(tempInput)) File.Delete(tempInput);
if (File.Exists(tempOutput)) File.Delete(tempOutput);
}
}
private async Task RenderPageThumbnailsAsync(
byte[] pdfBytes, int pageCount,
string envelopeId, string documentId,
CancellationToken cancellationToken)
{
using var pdfDocument = PdfDocument.Open(pdfBytes);
for (int i = 0; i < pageCount; i++)
{
var page = pdfDocument.GetPage(i + 1);
var imageBytes = page.Render(300, 300);
var thumbnailKey =
$"{envelopeId}/{documentId}/pages/{i + 1}.png";
await _blobStorage.UploadAsync(
thumbnailKey, imageBytes, "image/png",
cancellationToken);
}
}
}
Document Rendering for Signing Ceremony
When a signer opens the signing ceremony, the document must be rendered quickly and accurately. Rather than sending the entire PDF to the client, the system pre-renders each page as a PNG image at the optimal DPI for the signer's device. This approach guarantees pixel-perfect rendering across all browsers and devices — a critical requirement for legal documents where field placement precision matters.
C#
public class DocumentRenderingService
{
private readonly IBlobStorage _blobStorage;
private readonly ICacheService _cache;
private const int StandardDpi = 150;
private const int HighDpi = 300;
public async Task<DocumentRenderResult> RenderForSigningAsync(
string documentId, string recipientId,
DeviceInfo deviceInfo, CancellationToken ct)
{
var cacheKey =
$"doc_render:{documentId}:{recipientId}:{deviceInfo.Dpi}";
var cached = await _cache.GetAsync<DocumentRenderResult>(
cacheKey, ct);
if (cached != null) return cached;
var document = await _documentRepository
.GetByIdAsync(documentId, ct);
var fields = await _fieldRepository
.GetByDocumentAsync(documentId, ct);
var recipientFields = fields
.Where(f => f.RecipientId == recipientId)
.ToList();
var dpi = deviceInfo.IsMobile ? HighDpi : StandardDpi;
var pages = new List<RenderedPage>();
for (int page = 1; page <= document.PageCount; page++)
{
var storageKey =
$"{document.EnvelopeId}/{documentId}/pages/{page}.png";
var imageBytes = await _blobStorage.DownloadAsync(
storageKey, ct);
var pageFields = recipientFields
.Where(f => f.PageNumber == page)
.Select(f => new FieldOverlay
{
FieldId = f.Id,
FieldType = f.FieldType,
X = f.XPosition,
Y = f.YPosition,
Width = f.Width,
Height = f.Height,
Required = f.Required,
IsCompleted = f.Value != null
})
.ToList();
pages.Add(new RenderedPage
{
PageNumber = page,
ImageUrl = await GetSignedUrlAsync(storageKey, ct),
Fields = pageFields
});
}
var result = new DocumentRenderResult
{
DocumentId = documentId,
Pages = pages,
TotalPages = document.PageCount
};
await _cache.SetAsync(
cacheKey, result, TimeSpan.FromMinutes(30), ct);
return result;
}
}
8. Envelope Lifecycle: Draft → Sent → Viewed → Signed → Completed
The envelope state machine is the backbone of the platform. Every envelope transitions through a well-defined sequence of states, and each transition is a critical business event that must be logged, may trigger notifications, and must be atomic. The lifecycle is more complex than it first appears — a single envelope with three sequential signers might transition through dozens of state changes before reaching completion.
State Machine Implementation
C#
public class EnvelopeStateMachine
{
private static readonly Dictionary<EnvelopeStatus,
HashSet<EnvelopeStatus>>
_validTransitions = new()
{
[EnvelopeStatus.Draft] = new()
{
EnvelopeStatus.Sent, EnvelopeStatus.Voided
},
[EnvelopeStatus.Sent] = new()
{
EnvelopeStatus.Delivered, EnvelopeStatus.Viewed,
EnvelopeStatus.Signed, EnvelopeStatus.Completed,
EnvelopeStatus.Declined, EnvelopeStatus.Voided,
EnvelopeStatus.Expired
},
[EnvelopeStatus.Delivered] = new()
{
EnvelopeStatus.Viewed, EnvelopeStatus.Signed,
EnvelopeStatus.Completed, EnvelopeStatus.Declined,
EnvelopeStatus.Voided, EnvelopeStatus.Expired
},
[EnvelopeStatus.Viewed] = new()
{
EnvelopeStatus.InProgress, EnvelopeStatus.Signed,
EnvelopeStatus.Completed, EnvelopeStatus.Declined,
EnvelopeStatus.Voided, EnvelopeStatus.Expired
},
[EnvelopeStatus.InProgress] = new()
{
EnvelopeStatus.Signed, EnvelopeStatus.Completed,
EnvelopeStatus.Declined, EnvelopeStatus.Voided
},
[EnvelopeStatus.Signed] = new()
{
EnvelopeStatus.Viewed, EnvelopeStatus.Completed
}
};
public static void ValidateTransition(
EnvelopeStatus current, EnvelopeStatus target)
{
if (!_validTransitions.ContainsKey(current) ||
!_validTransitions[current].Contains(target))
{
throw new InvalidEnvelopeTransitionException(
$"Cannot transition from {current} to {target}");
}
}
}
State Transition Rules
| Transition | Trigger | Side Effects |
|---|---|---|
| Draft → Sent | Sender clicks "Send" | Send invitation emails, start expiry timer, log audit event |
| Sent → Delivered | Email delivery confirmed by provider | Update delivery status, notify sender (optional) |
| Delivered → Viewed | Signer clicks signing link | Start session, capture IP/UA, begin inactivity timer |
| Viewed → InProgress | First field completed | Update recipient status, record first interaction timestamp |
| InProgress → Signed | All fields completed by signer | Seal signer's portion, notify next signer (sequential) |
| Signed → Completed | All signers completed | Full document seal, send completion emails, fire webhooks |
| Any → Voided | Sender voids envelope | Notify all recipients, stop all timers, log reason |
| Any → Expired | Expiration timer fires | Notify sender and all recipients, archive envelope |
| Any → Declined | Recipient declines | Notify sender, notify other recipients, log decline reason |
Envelope Service (C#)
C#
public class EnvelopeService
{
private readonly IEnvelopeRepository _repository;
private readonly IAuditTrailService _auditTrail;
private readonly IEventPublisher _eventPublisher;
private readonly INotificationService _notifications;
public async Task TransitionEnvelopeAsync(
string envelopeId, EnvelopeStatus newStatus,
string actorId, string? reason = null,
CancellationToken ct = default)
{
var envelope = await _repository.GetByIdAsync(envelopeId, ct);
EnvelopeStateMachine.ValidateTransition(
envelope.Status, newStatus);
var previousStatus = envelope.Status;
envelope.Status = newStatus;
envelope.UpdatedAt = DateTime.UtcNow;
switch (newStatus)
{
case EnvelopeStatus.Sent:
envelope.SentAt = DateTime.UtcNow;
break;
case EnvelopeStatus.Completed:
envelope.CompletedAt = DateTime.UtcNow;
break;
case EnvelopeStatus.Voided:
envelope.VoidedAt = DateTime.UtcNow;
envelope.VoidedReason = reason;
break;
case EnvelopeStatus.Declined:
envelope.DeclinedAt = DateTime.UtcNow;
envelope.DeclinedReason = reason;
break;
case EnvelopeStatus.Expired:
envelope.ExpiredAt = DateTime.UtcNow;
break;
}
await _repository.UpdateAsync(envelope, ct);
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = $"envelope.{newStatus.ToString().ToLowerInvariant()}",
ActorId = actorId,
EventDetails = new
{
previousStatus = previousStatus.ToString(),
newStatus = newStatus.ToString(),
reason
}
}, ct);
await _eventPublisher.PublishAsync(
new EnvelopeStateChangedEvent
{
EnvelopeId = envelopeId,
PreviousStatus = previousStatus,
NewStatus = newStatus,
Timestamp = DateTime.UtcNow
}, ct);
if (newStatus == EnvelopeStatus.Sent)
await _notifications.SendInvitationsAsync(
envelopeId, ct);
else if (newStatus == EnvelopeStatus.Completed)
await _notifications.SendCompletionNoticeAsync(
envelopeId, ct);
}
}
9. Signing Ceremony — Web & Mobile Experience
The signing ceremony is the most critical user-facing component of the platform. It is the moment of truth — where a signer reviews a document, applies their signature, and creates a legally binding agreement. The ceremony must be simultaneously simple (signers should not need accounts or install software) and rigorous (identity verification, tamper-evident sealing, audit logging). Every design decision balances user experience against evidentiary strength.
Signing Ceremony Flow
Signing Ceremony State Management (C#)
C#
public class SigningCeremonySession
{
public string EnvelopeId { get; set; }
public string RecipientId { get; set; }
public SigningSessionStatus Status { get; set; }
public List<CompletedField> CompletedFields { get; set; } = new();
public List<int> ViewedPages { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime LastActivityAt { get; set; }
public string IpAddress { get; set; }
public string UserAgent { get; set; }
public bool IsAuthenticated { get; set; }
public string AuthenticationMethod { get; set; }
public int TimeSpentSeconds =>
(int)(LastActivityAt - CreatedAt).TotalSeconds;
}
public class SigningCeremonyController : ControllerBase
{
private readonly ISigningCeremonyService _ceremonyService;
private readonly IAuditTrailService _auditTrail;
private readonly ISessionManager _sessionManager;
[HttpPost("signing/{envelopeId}/complete")]
public async Task<IActionResult> CompleteSigning(
string envelopeId,
[FromBody] CompleteSigningRequest request)
{
var session = await _sessionManager.GetSessionAsync(
envelopeId);
if (session == null || !session.IsAuthenticated)
return Unauthorized();
var envelope = await _ceremonyService
.GetEnvelopeAsync(envelopeId);
var recipient = envelope.Recipients
.First(r => r.Id == session.RecipientId);
var requiredFields = await _ceremonyService
.GetRequiredFieldsAsync(
envelopeId, session.RecipientId);
var completedFieldIds = session.CompletedFields
.Select(f => f.FieldId).ToHashSet();
var missingFields = requiredFields
.Where(f => !completedFieldIds.Contains(f.Id))
.ToList();
if (missingFields.Any())
{
return BadRequest(new
{
error = "missing_required_fields",
fields = missingFields.Select(f => new
{
f.Id, f.FieldType, f.PageNumber, f.FieldName
})
});
}
var signingResult = await _ceremonyService
.CompleteRecipientSigningAsync(
envelopeId, session.RecipientId,
session.CompletedFields);
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "envelope.signed",
ActorId = session.RecipientId,
ActorType = "signer",
ActorEmail = recipient.Email,
IpAddress = IPAddress.Parse(session.IpAddress),
UserAgent = session.UserAgent,
EventDetails = new
{
fieldsCompleted = session.CompletedFields.Count,
timeSpentSeconds = session.TimeSpentSeconds,
viewedPages = session.ViewedPages,
authenticationMethod = session.AuthenticationMethod
}
});
await _sessionManager.RemoveSessionAsync(envelopeId);
return Ok(new
{
status = signingResult.NextAction,
message = signingResult.NextAction == "completed"
? "All parties have signed. Document is complete."
: "Your signature has been recorded."
});
}
}
Mobile Optimization
The signing ceremony must work flawlessly on mobile devices, where over 40% of signing ceremonies are completed. Key mobile optimizations include:
- Responsive field rendering: Fields use percentage-based coordinates, and the renderer scales touch targets to a minimum of 44×44 pixels (Apple's Human Interface Guideline minimum) for mobile signers.
- Pinch-to-zoom: Signers can zoom into specific areas of the document to read fine print before signing. The field overlay remains precisely aligned during zoom operations through CSS transform matrix calculations.
- Drawn signature optimization: On mobile, the signature pad uses device accelerometer data to smooth drawn signatures and applies stroke stabilization algorithms that compensate for the imprecision of finger input.
- Offline resilience: If a signer's connection drops mid-ceremony, the client stores completed fields locally using IndexedDB and syncs when connectivity is restored, preventing lost work.
- Progressive loading: Pages are loaded lazily as the signer scrolls, with low-resolution previews shown while high-resolution images load in the background.
- Orientation handling: The ceremony adapts seamlessly to portrait and landscape orientations, repositioning fields to remain visible regardless of device orientation.
Session Security
Signing ceremony sessions are protected by multiple security mechanisms. Sessions expire after 30 minutes of inactivity (configurable per-tenant). Each session is bound to the signer's IP address and user agent — if either changes mid-session, the signer must re-authenticate. Session tokens are short-lived JWTs (15-minute expiry) that are refreshed on each field completion. The session store in Redis uses a write-through cache with 100% persistence to prevent session loss during Redis failover.
10. Signature Types — Adopted, Drawn, Uploaded & Click-to-Sign
Legal validity requires that signers have meaningful control over how they sign. Different jurisdictions and use cases demand different signature types, and the platform must support all of them while maintaining evidentiary integrity. The choice of signature type affects the cryptographic binding strength of the completed document and may influence its admissibility in court.
Signature Type Comparison
| Signature Type | Description | Evidentiary Strength | Use Case |
|---|---|---|---|
| Drawn | Signer draws signature using mouse or finger on touchscreen | Strong — unique biometric input with timing data | Legal contracts, high-value agreements |
| Adopted | Signer selects from pre-generated signature styles (cursive, print) | Moderate — font-based, not biometrically unique | Internal documents, low-risk agreements |
| Uploaded Image | Signer uploads an image file of their handwritten signature | Moderate — static image, could be reused without consent | Documents requiring specific signature styles |
| Click-to-Sign | Signer checks a box or clicks "I Agree" with timestamp | Basic — consent is clear, no signature image | Terms of service, internal approvals, NDAs |
Signature Data Model
C#
public abstract class SignatureData
{
public string SignatureId { get; set; }
public SignatureType Type { get; set; }
public DateTime CreatedAt { get; set; }
public string SignerIpAddress { get; set; }
public int TimeToSignSeconds { get; set; }
public string ContentHash { get; set; }
}
public class DrawnSignature : SignatureData
{
public override SignatureType Type => SignatureType.Drawn;
public List<StrokePoint> Strokes { get; set; }
public int CanvasWidth { get; set; }
public int CanvasHeight { get; set; }
public string StrokeColor { get; set; }
public int StrokeWidth { get; set; }
public byte[] RenderedPng { get; set; }
public long DrawingDurationMs { get; set; }
public int TotalPoints { get; set; }
public float AveragePressure { get; set; }
}
public class AdoptedSignature : SignatureData
{
public override SignatureType Type => SignatureType.Adopted;
public string FontFamily { get; set; }
public string FullName { get; set; }
public byte[] RenderedPng { get; set; }
}
public class UploadedSignature : SignatureData
{
public override SignatureType Type => SignatureType.Uploaded;
public string ImageStorageKey { get; set; }
public string ImageContentType { get; set; }
public byte[] RenderedPng { get; set; }
}
public class ClickToSignSignature : SignatureData
{
public override SignatureType Type => SignatureType.ClickToSign;
public string ConsentText { get; set; }
public string AgreementVersion { get; set; }
}
public record StrokePoint(
double X, double Y,
long TimestampMs,
float? Pressure);
public enum SignatureType
{
Drawn,
Adopted,
Uploaded,
ClickToSign
}
Drawn Signature Pad Service
C#
public class SignaturePadService
{
private readonly IImageProcessor _imageProcessor;
private readonly IHashCalculator _hashCalculator;
public SignatureResult ProcessDrawnSignature(
DrawnSignatureRequest request)
{
var points = request.Strokes
.SelectMany(s => s.Points).ToList();
ValidateSignature(points);
var smoothedPoints = SmoothStrokes(points);
var renderedBytes = RenderSignatureToPng(
smoothedPoints,
request.CanvasWidth,
request.CanvasHeight,
request.StrokeColor,
request.StrokeWidth);
var contentHash = _hashCalculator.ComputeSha512(
renderedBytes);
return new DrawnSignature
{
SignatureId = Guid.NewGuid().ToString("N"),
Type = SignatureType.Drawn,
Strokes = smoothedPoints,
CanvasWidth = request.CanvasWidth,
CanvasHeight = request.CanvasHeight,
StrokeColor = request.StrokeColor,
StrokeWidth = request.StrokeWidth,
RenderedPng = renderedBytes,
DrawingDurationMs = CalculateDuration(smoothedPoints),
TotalPoints = smoothedPoints.Count,
ContentHash = contentHash,
CreatedAt = DateTime.UtcNow,
SignerIpAddress = request.IpAddress
};
}
private void ValidateSignature(List<StrokePoint> points)
{
if (points.Count < 10)
{
throw new InvalidSignatureException(
"Signature must contain at least 10 points.");
}
var duration = points.Last().TimestampMs -
points.First().TimestampMs;
if (duration < 500)
{
throw new InvalidSignatureException(
"Signature completed too quickly (under 500ms).");
}
var uniquePositions = points
.Select(p => ($"{p.X:F1}", $"{p.Y:F1}"))
.Distinct().Count();
if (uniquePositions < points.Count * 0.3)
{
throw new InvalidSignatureException(
"Signature appears to be static rather than drawn.");
}
}
private List<StrokePoint> SmoothStrokes(
List<StrokePoint> points)
{
var smoothed = new List<StrokePoint>();
var windowSize = 3;
for (int i = 0; i < points.Count; i++)
{
var window = points
.Skip(Math.Max(0, i - windowSize))
.Take(windowSize * 2 + 1)
.ToList();
smoothed.Add(new StrokePoint(
window.Average(p => p.X),
window.Average(p => p.Y),
points[i].TimestampMs,
points[i].Pressure));
}
return smoothed;
}
}
11. Document Placement — Drag-and-Drop Field Editor
The field placement editor is where senders define what signers need to complete. It must be intuitive enough for non-technical users while supporting precise positioning for legal documents where field placement matters. The editor must handle documents with 100+ pages, support zoom and pan, and provide snapping guides for alignment.
Field Types & Configuration
| Field Type | Data Type | Validation | Required By Default |
|---|---|---|---|
| Signature | SignatureData | Must be drawn/selected | Yes |
| Initials | SignatureData (short) | Must be drawn/selected | No |
| Date Signed | DateTime | Auto-populated, editable | Yes |
| Text | string | Min/max length, regex pattern | Configurable |
| Checkbox | bool | Must be checked if required | No |
| Dropdown | string | Must select from options | Configurable |
| Radio Group | string | Must select one option | Configurable |
| Formula | decimal | Computed from other fields | N/A (auto) |
| Attachment | File | File type, size limits | No |
Field Placement Service (C#)
C#
public class FieldPlacementService
{
private readonly IFieldRepository _fieldRepository;
private readonly IDocumentRepository _documentRepository;
public async Task<FieldPlacementResult> PlaceFieldAsync(
PlaceFieldRequest request, CancellationToken ct)
{
var document = await _documentRepository.GetByIdAsync(
request.DocumentId, ct);
ValidateFieldPlacement(request, document);
var field = new SigningField
{
Id = Guid.NewGuid().ToString("N"),
DocumentId = request.DocumentId,
RecipientId = request.RecipientId,
FieldType = request.FieldType,
FieldName = request.FieldName,
Required = request.Required ?? true,
PageNumber = request.PageNumber,
XPosition = Math.Clamp(request.XPosition, 0, 100),
YPosition = Math.Clamp(request.YPosition, 0, 100),
Width = Math.Clamp(request.Width, 2, 50),
Height = Math.Clamp(request.Height, 1, 10),
FontFamily = request.FontFamily,
FontSize = request.FontSize,
FontColor = request.FontColor ?? "#000000",
Validation = request.Validation,
TabOrder = request.TabOrder,
CreatedAt = DateTime.UtcNow
};
await DetectOverlapsAsync(
field, document.Id, ct);
await _fieldRepository.CreateAsync(field, ct);
return new FieldPlacementResult
{
FieldId = field.Id,
Status = "placed",
Warnings = await GetPlacementWarningsAsync(
field, ct)
};
}
private async Task DetectOverlapsAsync(
SigningField newField, string documentId,
CancellationToken ct)
{
var existingFields = await _fieldRepository
.GetByDocumentAsync(documentId, ct);
var overlaps = existingFields
.Where(f => f.PageNumber == newField.PageNumber)
.Where(f =>
f.XPosition < newField.XPosition + newField.Width &&
f.XPosition + f.Width > newField.XPosition &&
f.YPosition < newField.YPosition + newField.Height &&
f.YPosition + f.Height > newField.YPosition)
.ToList();
if (overlaps.Any())
{
throw new FieldOverlapException(
$"Field overlaps with {overlaps.Count} existing " +
$"field(s) on page {newField.PageNumber}.");
}
}
private void ValidateFieldPlacement(
PlaceFieldRequest request, Document document)
{
if (request.PageNumber < 1 ||
request.PageNumber > document.PageCount)
{
throw new ValidationException(
$"Page {request.PageNumber} out of range. " +
$"Document has {document.PageCount} pages.");
}
if (request.FieldType == FieldType.Signature &&
request.Width < 20)
{
request.Width = 20;
request.Height = 4;
}
}
}
12. Sequential vs Parallel Signing Orders
Signing order determines the sequence in which recipients interact with the document. The choice between sequential and parallel signing has significant implications for the envelope lifecycle, notification timing, and document integrity.
Signing Order Modes
| Mode | Description | Use Case | Complexity |
|---|---|---|---|
| Parallel | All recipients receive the document simultaneously | Simple agreements, team sign-offs, NDAs | Low |
| Sequential | Recipients receive in specified order (1 → 2 → 3) | Approval chains, legal contracts, negotiated terms | Medium |
| Hybrid | Groups receive in parallel within groups, groups execute sequentially | Complex multi-department workflows | High |
Sequential Signing Orchestration (C#)
C#
public class SequentialSigningOrchestrator
{
private readonly IEnvelopeService _envelopeService;
private readonly IRecipientService _recipientService;
private readonly INotificationService _notificationService;
private readonly IAuditTrailService _auditTrail;
private readonly IEventPublisher _eventPublisher;
public async Task OnRecipientSignedAsync(
string envelopeId, string recipientId,
CancellationToken ct)
{
var envelope = await _envelopeService
.GetByIdAsync(envelopeId, ct);
if (envelope.SigningOrder != SigningOrder.Sequential &&
envelope.SigningOrder != SigningOrder.Hybrid)
return;
var allRecipients = await _recipientService
.GetByEnvelopeAsync(envelopeId, ct);
var currentRecipient = allRecipients
.First(r => r.Id == recipientId);
// Find next recipient in same group
var nextInGroup = allRecipients
.Where(r => r.Group == currentRecipient.Group)
.Where(r => r.Order > currentRecipient.Order)
.OrderBy(r => r.Order)
.FirstOrDefault();
if (nextInGroup != null)
{
await NotifyAndDeliverAsync(
envelope, nextInGroup, ct);
return;
}
// No more in this group — find first in next group
var nextGroup = allRecipients
.Where(r => r.Group > currentRecipient.Group)
.Where(r => r.Status == RecipientStatus.Pending)
.OrderBy(r => r.Group)
.ThenBy(r => r.Order)
.FirstOrDefault();
if (nextGroup != null)
{
await NotifyAndDeliverAsync(
envelope, nextGroup, ct);
}
else
{
// All recipients have signed
await _envelopeService.TransitionEnvelopeAsync(
envelopeId,
EnvelopeStatus.Completed,
recipientId,
ct: ct);
}
}
private async Task NotifyAndDeliverAsync(
Envelope envelope, Recipient recipient,
CancellationToken ct)
{
await _recipientService.UpdateStatusAsync(
recipient.Id, RecipientStatus.Sent, ct);
await _notificationService.SendSigningInvitationAsync(
new SigningInvitation
{
EnvelopeId = envelope.Id,
RecipientId = recipient.Id,
RecipientEmail = recipient.Email,
RecipientName = recipient.Name,
SenderName = envelope.SenderName,
Subject = envelope.EmailSubject,
SigningLink = GenerateSigningLink(
envelope.Id, recipient.Id),
ExpirationDate = envelope.ExpirationDate
}, ct);
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelope.Id,
EventType = "envelope.sent_to_next_signer",
ActorId = "system",
EventDetails = new
{
nextSignerEmail = recipient.Email,
signingOrder = recipient.Order,
signingGroup = recipient.Group
}
}, ct);
}
}
13. In-Person Signing & Access Code Verification
In-person signing solves the problem of collecting signatures from people who may not have an email address, smartphone, or computer. Common scenarios include retail stores (signing a lease at the property), healthcare (patient consent forms), government offices (permit applications), and events (waiver forms). The in-person signer uses a shared device (tablet, kiosk) and does not need to provide an email address — the "host" (the person running the device) manages the signing flow.
In-Person Signing Flow
Access Code Verification Service
Access codes provide an additional layer of identity verification. The sender generates a code (6-8 digits or a custom alphanumeric string) and shares it with the signer through a separate channel (phone call, text message). The signer must enter this code before accessing the signing ceremony. This ensures that the person opening the signing link is the intended recipient — possession of the email link alone is insufficient.
C#
public class AccessCodeVerificationService
{
private readonly ICacheService _cache;
private readonly IAuditTrailService _auditTrail;
private const int MaxAttempts = 5;
private const int LockoutMinutes = 15;
public async Task<VerificationResult> VerifyAccessCodeAsync(
string envelopeId, string recipientId,
string providedCode, string ipAddress,
CancellationToken ct)
{
var lockoutKey =
$"access_lockout:{envelopeId}:{recipientId}";
var attempts = await _cache.GetAsync<int>(
lockoutKey, ct);
if (attempts >= MaxAttempts)
{
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "authentication.locked_out",
ActorId = recipientId,
ActorType = "signer",
IpAddress = IPAddress.Parse(ipAddress),
EventDetails = new
{
reason = "exceeded_max_access_code_attempts",
attempts
}
}, ct);
return VerificationResult.LockedOut(
$"Account locked for {LockoutMinutes} minutes " +
$"due to {MaxAttempts} failed attempts.");
}
var storedCode = await _cache.GetAsync<string>(
$"access_code:{envelopeId}:{recipientId}", ct);
if (!string.Equals(storedCode, providedCode,
StringComparison.Ordinal))
{
await _cache.IncrementAsync(lockoutKey, ct);
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "authentication.access_code_failure",
ActorId = recipientId,
ActorType = "signer",
IpAddress = IPAddress.Parse(ipAddress),
EventDetails = new { attempt = attempts + 1 }
}, ct);
var remaining = MaxAttempts - attempts - 1;
return VerificationResult.Failed(
$"Invalid access code. {remaining} attempts remaining.");
}
// Success — clean up lockout and code
await _cache.DeleteAsync(lockoutKey, ct);
await _cache.DeleteAsync(
$"access_code:{envelopeId}:{recipientId}", ct);
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "authentication.access_code_success",
ActorId = recipientId,
ActorType = "signer",
IpAddress = IPAddress.Parse(ipAddress)
}, ct);
return VerificationResult.Success();
}
}
14. Signer Identity Verification (SMS, Email & Knowledge-Based)
Identity verification goes beyond access codes to confirm that the person signing is who they claim to be. The platform supports multiple verification methods, and the sender can require one or more depending on the document's sensitivity. Each verification method has a different cost, user friction level, and evidentiary strength.
Verification Methods
| Method | How It Works | Strength | Cost | Friction |
|---|---|---|---|---|
| Email Verification | One-time code sent to signer's email | Basic (proves email access) | ~$0.001 | Low |
| SMS Verification | 6-digit code sent via SMS to phone | Moderate (proves phone access) | ~$0.01 | Low |
| Phone IVR | Automated call reads code to signer | Moderate (proves phone possession) | ~$0.05 | Medium |
| Knowledge-Based (KBA) | 3-5 questions from public records / credit bureau | Strong (proves identity knowledge) | ~$1.50 | High |
| ID Document Verification | Signer photographs government-issued ID | Very Strong (proves physical ID possession) | ~$5.00 | High |
| Biometric Face Match | Selfie compared to ID photo via AI | Strongest (proves biometric identity) | ~$8.00 | High |
SMS Verification Service (C#)
C#
public class SmsVerificationService
{
private readonly ISmsProvider _smsProvider;
private readonly ICacheService _cache;
private readonly IAuditTrailService _auditTrail;
private readonly Random _random = new();
private const int CodeLength = 6;
private const int CodeExpiryMinutes = 10;
private const int MaxCodeAttempts = 3;
public async Task<SmsSendResult> SendVerificationCodeAsync(
string envelopeId, string recipientId,
string phoneNumber, string ipAddress,
CancellationToken ct)
{
var code = GenerateSecureCode();
var cacheKey = $"sms_code:{envelopeId}:{recipientId}";
var attemptsKey =
$"sms_attempts:{envelopeId}:{recipientId}";
await _cache.SetAsync(cacheKey, code,
TimeSpan.FromMinutes(CodeExpiryMinutes), ct);
await _cache.SetAsync(attemptsKey, 0,
TimeSpan.FromMinutes(CodeExpiryMinutes), ct);
var maskedPhone = MaskPhoneNumber(phoneNumber);
await _smsProvider.SendAsync(new SmsMessage
{
To = phoneNumber,
Body = $"Your verification code is: {code}. " +
$"It expires in {CodeExpiryMinutes} minutes. " +
$"Do not share this code with anyone."
});
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "authentication.sms_sent",
ActorId = recipientId,
ActorType = "signer",
IpAddress = IPAddress.Parse(ipAddress),
EventDetails = new
{
phoneNumberMasked = maskedPhone,
codeLength = CodeLength,
expiresAt = DateTime.UtcNow
.AddMinutes(CodeExpiryMinutes)
}
}, ct);
return new SmsSendResult
{
Success = true,
MaskedPhoneNumber = maskedPhone,
ExpiresAt = DateTime.UtcNow
.AddMinutes(CodeExpiryMinutes)
};
}
public async Task<VerificationResult> VerifyCodeAsync(
string envelopeId, string recipientId,
string providedCode, string ipAddress,
CancellationToken ct)
{
var cacheKey = $"sms_code:{envelopeId}:{recipientId}";
var attemptsKey =
$"sms_attempts:{envelopeId}:{recipientId}";
var attempts = await _cache.GetAsync<int>(
attemptsKey, ct);
if (attempts >= MaxCodeAttempts)
{
return VerificationResult.Failed(
"Maximum attempts exceeded. Request a new code.");
}
var storedCode = await _cache.GetAsync<string>(
cacheKey, ct);
if (storedCode == null)
{
return VerificationResult.Failed(
"Code expired. Request a new code.");
}
if (!string.Equals(storedCode, providedCode,
StringComparison.Ordinal))
{
await _cache.IncrementAsync(attemptsKey, ct);
var remaining = MaxCodeAttempts - attempts - 1;
return VerificationResult.Failed(
$"Invalid code. {remaining} attempts remaining.");
}
await _cache.DeleteAsync(cacheKey, ct);
await _cache.DeleteAsync(attemptsKey, ct);
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "authentication.sms_success",
ActorId = recipientId,
ActorType = "signer",
IpAddress = IPAddress.Parse(ipAddress)
}, ct);
return VerificationResult.Success();
}
private string GenerateSecureCode()
{
return string.Concat(
Enumerable.Range(0, CodeLength)
.Select(_ => _random.Next(0, 10).ToString()));
}
private string MaskPhoneNumber(string phone)
{
if (phone.Length <= 4) return "****";
return phone.Substring(0, 3) + "****" +
phone.Substring(phone.Length - 2);
}
}
15. Audit Trail — Detailed Event Logging
The audit trail is the evidentiary backbone of the platform. Every action — from envelope creation to final download — must be logged with precise timestamps, actor identification, and cryptographic integrity. In legal proceedings, the audit trail serves as the primary evidence that a valid signing process occurred. Courts have accepted e-signature audit trails as evidence in thousands of cases, and the quality of the audit trail often determines whether a digital signature is upheld.
Audit Event Types
| Event Type | Description | Data Captured |
|---|---|---|
| envelope.created | Envelope created as draft | Sender ID, document count, recipient count, template ID |
| envelope.sent | Envelope sent to recipients | Recipient list, signing order, email subject |
| envelope.delivered | Email delivered to recipient | Recipient email, delivery status, provider response |
| envelope.viewed | Recipient opened signing link | IP address, user agent, timestamp, geolocation |
| authentication.attempt | Authentication attempted | Method (SMS/email/KBA), IP address, success/failure |
| authentication.success | Authentication succeeded | Method, IP address, user agent, verification provider response |
| field.completed | Individual field filled | Field type, value hash, completion time, coordinates |
| envelope.signed | Recipient completed all fields | Fields count, total time spent, IP, user agent, pages viewed |
| envelope.completed | All recipients signed | Completion time, seal hash, document count |
| envelope.declined | Recipient declined to sign | Decline reason, IP address, timestamp |
| envelope.voided | Sender voided the envelope | Void reason, who voided, timestamp |
| document.downloaded | Signed document downloaded | Who downloaded, timestamp, IP, document version |
| seal.applied | Tamper-evident seal applied | Seal hash, certificate ID, signing algorithm, seal timestamp |
| envelope.expired | Envelope expired without completion | Expiration date, signers who hadn't completed |
Immutable Audit Trail with Cryptographic Chaining
C#
public class AuditTrailService : IAuditTrailService
{
private readonly IAuditEventRepository _repository;
private readonly IHashCalculator _hashCalculator;
private readonly object _chainLock = new();
private const string GenesisHash =
"000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000";
public async Task<AuditEvent> LogEventAsync(
AuditEvent auditEvent, CancellationToken ct)
{
lock (_chainLock)
{
var previousEvent = _repository
.GetLastEventForEnvelope(
auditEvent.EnvelopeId);
auditEvent.PreviousHash =
previousEvent?.EventHash ?? GenesisHash;
var eventHashInput =
ComputeEventHashInput(auditEvent);
auditEvent.EventHash = _hashCalculator
.ComputeSha512(eventHashInput);
}
auditEvent.CreatedAt = DateTime.UtcNow;
auditEvent.Id = Guid.NewGuid();
await _repository.InsertAsync(auditEvent, ct);
return auditEvent;
}
public async Task<AuditTrailVerificationResult>
VerifyAuditTrailAsync(
string envelopeId, CancellationToken ct)
{
var events = await _repository
.GetByEnvelopeAsync(envelopeId, ct);
var eventsOrdered = events
.OrderBy(e => e.CreatedAt).ToList();
string expectedPreviousHash = GenesisHash;
var violations = new List<AuditTrailViolation>();
for (int i = 0; i < eventsOrdered.Count; i++)
{
var current = eventsOrdered[i];
if (current.PreviousHash != expectedPreviousHash)
{
violations.Add(new AuditTrailViolation
{
EventId = current.Id,
EventType = current.EventType,
ViolationType = "broken_chain",
Description =
$"Event at index {i} has mismatched " +
$"PreviousHash"
});
}
var recomputedHash = _hashCalculator
.ComputeSha512(
ComputeEventHashInput(current));
if (recomputedHash != current.EventHash)
{
violations.Add(new AuditTrailViolation
{
EventId = current.Id,
EventType = current.EventType,
ViolationType = "hash_mismatch",
Description =
$"Event at index {i} has been tampered"
});
}
expectedPreviousHash = current.EventHash;
}
return new AuditTrailVerificationResult
{
EnvelopeId = envelopeId,
TotalEvents = eventsOrdered.Count,
IsValid = !violations.Any(),
Violations = violations,
VerifiedAt = DateTime.UtcNow,
ChainIntegrity = violations.Any()
? ChainIntegrity.Broken
: ChainIntegrity.Valid
};
}
private string ComputeEventHashInput(AuditEvent e)
{
return $"{e.EnvelopeId}|{e.EventType}|{e.ActorId}|" +
$"{e.ActorType}|{e.ActorEmail}|" +
$"{e.IpAddress}|{e.UserAgent}|" +
$"{JsonSerializer.Serialize(e.EventDetails)}|" +
$"{e.PreviousHash}|{e.CreatedAt:O}";
}
}
16. Tamper-Evident Seal & Digital Signatures on Completed Documents
The tamper-evident seal is what transforms a signed document into a legally defensible instrument. When an envelope is completed, the platform generates a cryptographic seal that binds together the document content, all signatures, the audit trail, and the timestamp. This seal is implemented as a digital signature (PKCS#7/CMS envelope) using the platform's certificate issued by a trusted Certificate Authority. Any modification to the sealed document — changing a single character, reordering pages, or altering a signature — will invalidate the seal and be immediately detectable.
Seal Service Implementation (C#)
C#
public class TamperEvidentSealService
{
private readonly IHsmClient _hsm;
private readonly IBlobStorage _blobStorage;
private readonly IAuditTrailService _auditTrail;
private readonly IHashCalculator _hashCalculator;
private readonly ILogger<TamperEvidentSealService> _logger;
public async Task<SealResult> SealEnvelopeAsync(
string envelopeId, CancellationToken ct)
{
_logger.LogInformation(
"Beginning seal for envelope {EnvelopeId}",
envelopeId);
var documents = await GetSignedDocumentsAsync(
envelopeId, ct);
var auditEvents = await _auditTrail
.GetFullTrailAsync(envelopeId, ct);
var envelope = await GetEnvelopeMetadataAsync(
envelopeId, ct);
var manifest = BuildManifest(
documents, auditEvents, envelope);
var manifestHash = _hashCalculator.ComputeSha512(
Encoding.UTF8.GetBytes(manifest));
var signatureBytes = await _hsm.SignAsync(
new HsmSignRequest
{
Data = Encoding.UTF8.GetBytes(manifestHash),
Algorithm = SigningAlgorithm.ECDSA_P384,
CertificateId = "platform-signing-cert-2025"
}, ct);
var p7sContent = BuildPkcs7Envelope(
Encoding.UTF8.GetBytes(manifestHash),
signatureBytes,
await _hsm.GetCertificateChainAsync(
"platform-signing-cert-2025", ct));
var sealKey =
$"{envelopeId}/seal/{DateTime.UtcNow:yyyyMMdd}.p7s";
await _blobStorage.UploadAsync(
sealKey, p7sContent,
"application/pkcs7-signature", ct);
var report = new VerificationReport
{
EnvelopeId = envelopeId,
SealHash = manifestHash,
DocumentHashes = documents.Select(d =>
new DocumentHash
{
DocumentId = d.DocumentId,
FileName = d.FileName,
SHA512 = d.ContentHash
}).ToList(),
AuditTrailHash = _hashCalculator.ComputeSha512(
Encoding.UTF8.GetBytes(
JsonSerializer.Serialize(auditEvents))),
SealTimestamp = DateTime.UtcNow,
CertificateSubject =
"CN=Platform Signing Certificate"
};
var reportKey = $"{envelopeId}/verification-report.json";
await _blobStorage.UploadJsonAsync(
reportKey, report, ct);
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "envelope.sealed",
ActorId = "system",
ActorType = "system",
EventDetails = new
{
sealHash = manifestHash,
documentCount = documents.Count,
auditEventCount = auditEvents.Count
}
}, ct);
return new SealResult
{
Success = true,
SealHash = manifestHash,
VerificationReportUrl = reportKey
};
}
private string BuildManifest(
List<SignedDocument> documents,
List<AuditEvent> auditEvents,
EnvelopeMetadata envelope)
{
var sb = new StringBuilder();
sb.AppendLine($"ENVELOPE_ID:{envelope.Id}");
sb.AppendLine($"TENANT_ID:{envelope.TenantId}");
sb.AppendLine($"COMPLETED_AT:{envelope.CompletedAt:O}");
sb.AppendLine("---DOCUMENTS---");
foreach (var doc in documents
.OrderBy(d => d.DocumentOrder))
{
sb.AppendLine(
$"DOC:{doc.DocumentId}:{doc.FileName}:" +
$"{doc.ContentHash}:{doc.PageCount}");
}
sb.AppendLine("---AUDIT_TRAIL---");
foreach (var evt in auditEvents
.OrderBy(e => e.CreatedAt))
{
sb.AppendLine(
$"EVENT:{evt.CreatedAt:O}:{evt.EventType}:" +
$"{evt.ActorEmail}:{evt.EventHash}");
}
return sb.ToString();
}
}
/api/v1/verify/{envelopeId} that performs this verification automatically. This endpoint is publicly accessible — no authentication required — because transparency is essential for legal trust. Any court, opposing counsel, or auditor can independently verify any document signed through the platform.
17. Template Management & Bulk Send
Templates are the efficiency multiplier for e-signature platforms. Enterprise customers don't create envelopes from scratch — they use pre-built templates with standardized fields, recipient roles, and document layouts. A real estate company might have 20 templates (purchase agreements, lease addendums, disclosure forms), each used thousands of times per month. Bulk send takes this further: instead of creating an envelope for each recipient individually, the sender uploads a spreadsheet of recipient data and the platform generates personalized envelopes from a template for each row.
Template Data Model
C#
public class DocumentTemplate
{
public string TemplateId { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List<TemplateDocument> Documents { get; set; }
public List<TemplateRecipient> Recipients { get; set; }
public List<TemplateField> Fields { get; set; }
public SigningOrder SigningOrder { get; set; }
public int ExpirationDays { get; set; } = 30;
public Dictionary<string, string> MergeFields { get; set; }
public List<ConditionalRule> ConditionalRules { get; set; }
}
public class TemplateField
{
public string FieldId { get; set; }
public FieldType FieldType { get; set; }
public string FieldName { get; set; }
public string RecipientRole { get; set; }
public int PageNumber { get; set; }
public decimal XPosition { get; set; }
public decimal YPosition { get; set; }
public decimal Width { get; set; }
public decimal Height { get; set; }
public bool Required { get; set; }
public string MergeFieldName { get; set; }
public FieldValidation Validation { get; set; }
}
public class BulkSendJob
{
public string JobId { get; set; }
public string TemplateId { get; set; }
public string TenantId { get; set; }
public BulkSendStatus Status { get; set; }
public int TotalRecipients { get; set; }
public int ProcessedCount { get; set; }
public int SuccessCount { get; set; }
public int FailureCount { get; set; }
public string DataSourceUrl { get; set; }
public List<BulkSendRecipient> Recipients { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
Bulk Send Processor
C#
public class BulkSendProcessor
{
private readonly ITemplateService _templateService;
private readonly IEnvelopeService _envelopeService;
private readonly IEventPublisher _eventPublisher;
public async Task ProcessBulkSendJobAsync(
string jobId, CancellationToken ct)
{
var job = await _bulkSendRepository
.GetByIdAsync(jobId, ct);
var template = await _templateService
.GetByIdAsync(job.TemplateId, ct);
job.Status = BulkSendStatus.Processing;
await _bulkSendRepository.UpdateAsync(job, ct);
var semaphore = new SemaphoreSlim(10);
int successCount = 0;
int failureCount = 0;
var tasks = job.Recipients.Select(async recipient =>
{
await semaphore.WaitAsync(ct);
try
{
await ProcessSingleRecipientAsync(
job, template, recipient, ct);
Interlocked.Increment(ref successCount);
recipient.Status =
BulkSendRecipientStatus.Sent;
}
catch (Exception ex)
{
Interlocked.Increment(ref failureCount);
recipient.ErrorMessage = ex.Message;
recipient.Status =
BulkSendRecipientStatus.Failed;
}
finally
{
Interlocked.Increment(ref job.ProcessedCount);
semaphore.Release();
await _bulkSendRepository
.UpdateAsync(job, ct);
}
});
await Task.WhenAll(tasks);
job.SuccessCount = successCount;
job.FailureCount = failureCount;
job.Status = BulkSendStatus.Completed;
job.CompletedAt = DateTime.UtcNow;
await _bulkSendRepository.UpdateAsync(job, ct);
await _eventPublisher.PublishAsync(
new BulkSendCompletedEvent
{
JobId = jobId,
TotalRecipients = job.TotalRecipients,
SuccessCount = successCount,
FailureCount = failureCount
}, ct);
}
private async Task ProcessSingleRecipientAsync(
BulkSendJob job, DocumentTemplate template,
BulkSendRecipient recipient, CancellationToken ct)
{
var mergeData = recipient.MergeData;
var envelopeRequest = new CreateEnvelopeRequest
{
Subject = ReplaceMergeFields(
template.EmailSubject, mergeData),
Message = ReplaceMergeFields(
template.EmailMessage, mergeData),
SigningOrder = template.SigningOrder,
ExpirationDays = template.ExpirationDays,
Documents = template.Documents.Select(d => new
{
d.DocumentBase64, d.Name
}).ToList(),
Recipients = template.Recipients.Select(r => new
{
name = ReplaceMergeFields(
r.NamePattern, mergeData),
email = ReplaceMergeFields(
r.EmailPattern, mergeData),
role = r.Role
}).ToList(),
Fields = template.Fields.Select(f => new
{
f.FieldType, f.FieldName, f.PageNumber,
f.XPosition, f.YPosition,
f.Width, f.Height, f.Required,
value = f.MergeFieldName != null
? mergeData.GetValueOrDefault(
f.MergeFieldName)
: null
}).ToList()
};
var envelope = await _envelopeService
.CreateAsync(envelopeRequest, ct);
await _envelopeService.SendAsync(envelope.Id, ct);
recipient.EnvelopeId = envelope.Id;
}
}
18. Document Workflows, Routing & Payment Collection
Document workflows extend the signing process beyond simple signature collection. A workflow might route a purchase order through department managers for approval, require legal review before signature collection, or conditionally add signers based on document content. The workflow engine is a lightweight rule-based system that orchestrates the lifecycle of an envelope through conditional steps.
Workflow Definition
JSON
{
"workflowId": "contract_approval_v2",
"name": "Contract Approval Workflow",
"trigger": "envelope_created",
"steps": [
{
"stepId": "legal_review",
"type": "approval",
"assignee": {
"type": "role",
"value": "legal_team"
},
"condition": null,
"timeout": "48h",
"escalation": {
"after": "24h",
"escalateTo": "legal_director"
}
},
{
"stepId": "vp_approval",
"type": "approval",
"assignee": {
"type": "role",
"value": "vp_sales"
},
"condition": {
"field": "contract_value",
"operator": "greater_than",
"value": 10000
},
"timeout": "72h"
},
{
"stepId": "signing",
"type": "signing",
"recipients": "from_envelope",
"signingOrder": "sequential"
},
{
"stepId": "payment",
"type": "payment",
"condition": {
"field": "payment_required",
"operator": "equals",
"value": true
},
"amount": "contract_value",
"currency": "USD",
"paymentMethods": ["credit_card", "ach"]
}
]
}
Payment Collection Service
C#
public class PaymentCollectionService
{
private readonly IPaymentGateway _paymentGateway;
private readonly IAuditTrailService _auditTrail;
public async Task<PaymentResult> CollectPaymentAsync(
string envelopeId, string recipientId,
PaymentRequest request, CancellationToken ct)
{
var envelope = await _envelopeService
.GetByIdAsync(envelopeId, ct);
ValidatePaymentRequirement(envelope, recipientId);
var paymentIntent = await _paymentGateway
.CreatePaymentIntentAsync(new PaymentIntentRequest
{
Amount = envelope.Metadata["payment_amount"],
Currency = envelope.Metadata["payment_currency"],
Description =
$"Payment for envelope {envelopeId}",
Metadata = new Dictionary<string, string>
{
["envelope_id"] = envelopeId,
["recipient_id"] = recipientId
}
}, ct);
var result = await _paymentGateway
.ConfirmPaymentAsync(
paymentIntent.Id,
request.PaymentMethodId, ct);
if (result.Status == PaymentStatus.Succeeded)
{
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "payment.collected",
ActorId = recipientId,
ActorType = "signer",
EventDetails = new
{
paymentId = result.PaymentId,
amount = paymentIntent.Amount,
currency = paymentIntent.Currency,
paymentMethod = request.PaymentMethodType,
last4 = result.CardLast4
}
}, ct);
}
return result;
}
}
19. Reminder & Expiry System
Timely completion of signing ceremonies is critical for business operations. The reminder and expiry system ensures that pending signers are nudged at appropriate intervals and that stale envelopes are automatically closed. The system must handle millions of pending envelopes efficiently without excessive database polling or notification spam.
Reminder & Expiry Architecture
Reminder & Expiry Implementation (C#)
C#
public class ReminderExpiryService
{
private readonly IEnvelopeRepository _envelopeRepository;
private readonly INotificationService _notifications;
private readonly IAuditTrailService _auditTrail;
private readonly IEventPublisher _eventPublisher;
public async Task ProcessRemindersAsync(
CancellationToken ct)
{
var pendingEnvelopes = await _envelopeRepository
.GetEnvelopesNeedingReminderAsync(
batchSize: 1000, ct);
foreach (var envelope in pendingEnvelopes)
{
var pendingRecipients = envelope.Recipients
.Where(r =>
r.Status != RecipientStatus.Completed &&
r.Status != RecipientStatus.Declined)
.ToList();
foreach (var recipient in pendingRecipients)
{
var lastReminder =
await GetLastReminderDateAsync(
envelope.Id, recipient.Id, ct);
var daysSinceLastReminder = lastReminder == null
? int.MaxValue
: (DateTime.UtcNow - lastReminder.Value)
.Days;
if (daysSinceLastReminder >=
envelope.ReminderIntervalDays)
{
await _notifications.SendReminderAsync(
new ReminderNotification
{
EnvelopeId = envelope.Id,
RecipientId = recipient.Id,
RecipientEmail = recipient.Email,
RecipientName = recipient.Name,
Subject =
$"Reminder: {envelope.Subject}",
DaysPending = (DateTime.UtcNow
- envelope.SentAt!.Value).Days
}, ct);
await RecordReminderAsync(
envelope.Id, recipient.Id, ct);
await _auditTrail.LogEventAsync(
new AuditEvent
{
EnvelopeId = envelope.Id,
EventType = "reminder.sent",
ActorId = "system",
EventDetails = new
{
recipientEmail = recipient.Email,
daysPending = (DateTime.UtcNow
- envelope.SentAt!.Value).Days
}
}, ct);
}
}
}
}
public async Task ProcessExpiriesAsync(
CancellationToken ct)
{
var expiredEnvelopes = await _envelopeRepository
.GetExpiredEnvelopesAsync(
batchSize: 500, ct);
foreach (var envelope in expiredEnvelopes)
{
await _envelopeService.TransitionEnvelopeAsync(
envelope.Id,
EnvelopeStatus.Expired,
actorId: "system",
reason:
$"Expired after {envelope.ExpirationDays} days",
ct: ct);
await _eventPublisher.PublishAsync(
new EnvelopeExpiredEvent
{
EnvelopeId = envelope.Id,
TenantId = envelope.TenantId,
SenderEmail = envelope.SenderEmail
}, ct);
}
}
}
20. Integrations — Salesforce, CRM & Embeddable SDK
Enterprise e-signature platforms must integrate seamlessly into existing business workflows. The two primary integration patterns are: (1) server-to-server API integrations with CRMs and business systems (Salesforce, HubSpot, SAP), and (2) an embeddable SDK that allows third-party applications to embed the signing ceremony directly in their UI without redirecting to an external page.
Embeddable Signing SDK
JavaScript
// Initialize the embedded signing experience
const signingSession = await ESignSDK.init({
containerId: 'signing-container',
envelopeId: '550e8400-e29b-41d4-a716-446655440000',
recipientId: 'recipient-abc-123',
authenticationToken: 'eyJhbGciOiJSUzI1NiIs...',
apiBaseUrl: 'https://api.esign.example.com',
theme: {
primaryColor: '#1a73e8',
fontFamily: 'Inter, sans-serif',
borderRadius: '8px'
},
callbacks: {
onFieldCompleted: (fieldId, value) => {
console.log(`Field ${fieldId} completed`);
},
onSigningComplete: (result) => {
console.log('Signing complete:', result.envelopeId);
window.location.href = '/thank-you';
},
onDeclined: (reason) => {
console.log('Signing declined:', reason);
},
onError: (error) => {
console.error('Signing error:', error);
}
},
options: {
allowDownload: true,
showAuditTrail: true,
enableZoom: true,
language: 'en-US'
}
});
// Later: destroy the session
signingSession.destroy();
Salesforce Integration (C#)
C#
public class SalesforceIntegrationService
{
private readonly ISalesforceClient _sfClient;
private readonly IEnvelopeService _envelopeService;
public async Task<SalesforceEnvelopeResult>
CreateEnvelopeFromOpportunityAsync(
string salesforceToken, string opportunityId,
string templateId, CancellationToken ct)
{
var opportunity = await _sfClient
.GetOpportunityAsync(
salesforceToken, opportunityId);
var mergeData = new Dictionary<string, string>
{
["opportunity_name"] = opportunity.Name,
["account_name"] = opportunity.Account.Name,
["amount"] = opportunity.Amount.ToString("C"),
["close_date"] = opportunity.CloseDate
.ToString("MM/dd/yyyy"),
["signer_email"] = opportunity.Contact.Email,
["signer_name"] = opportunity.Contact.Name
};
var envelope = await _envelopeService
.CreateFromTemplateAsync(
templateId, mergeData, ct);
await _envelopeService.SendAsync(
envelope.Id, ct);
await _sfClient.UpdateOpportunityAsync(
salesforceToken, opportunityId,
new Dictionary<string, string>
{
["ESign_Envelope_Id__c"] = envelope.Id,
["ESign_Status__c"] = "Sent"
});
return new SalesforceEnvelopeResult
{
EnvelopeId = envelope.Id,
OpportunityId = opportunityId,
Status = "sent"
};
}
public async Task HandleWebhookAsync(
EnvelopeCompletedEvent completedEvent,
CancellationToken ct)
{
var envelope = await _envelopeService
.GetByIdAsync(completedEvent.EnvelopeId, ct);
var sfToken = await GetSalesforceTokenAsync(
envelope.TenantId, ct);
var oppId = envelope.Metadata
["salesforce_opportunity_id"];
await _sfClient.UpdateOpportunityAsync(
sfToken, oppId,
new Dictionary<string, string>
{
["ESign_Status__c"] = "Completed",
["ESign_Completed_At__c"] =
completedEvent.CompletedAt.ToString("O"),
["Contract_PDF_Id__c"] =
await CreateSalesforceAttachmentAsync(
sfToken, envelope, ct)
});
}
}
21. Compliance — ESIGN Act, eIDAS, UETA & Legal Validity
Legal compliance is not optional for an e-signature platform — it is the foundation upon which the entire value proposition rests. Without legal validity, an electronic signature is just a picture on a screen. The platform must comply with multiple overlapping legal frameworks that govern electronic signatures and records across different jurisdictions.
Compliance Framework Matrix
| Framework | Jurisdiction | Key Requirements | Platform Implementation |
|---|---|---|---|
| ESIGN Act (2000) | United States (Federal) | Intent to sign, consent to electronic records, record retention, consumer disclosure | Consent checkbox, immutable storage, audit trail, 7-year retention, disclosure notice |
| UETA (1999) | United States (49 states) | Attribution, effect of change/error, record retention, automated transactions | Identity verification, tamper-evident seal, audit trail, error correction procedures |
| eIDAS (2014) | European Union | Qualified electronic signatures, trusted service providers, long-term validation, cross-border recognition | PAdES signatures, timestamping, qualified certificates, TSP registration |
| ETA (2000) | Canada | Similar to ESIGN, provincial variations (Ontario ESTA, Quebec) | Multi-province compliance, bilingual support, provincial retention rules |
| SOC 2 Type II | Global (Enterprise) | Security, availability, processing integrity, confidentiality, privacy | Annual audits, penetration testing, access controls, encryption, monitoring |
| ISO 27001 | Global (Enterprise) | Information security management system (ISMS) | ISMS implementation, risk assessment, continuous improvement, certification |
| HIPAA | United States (Healthcare) | Protected health information (PHI) handling, BAAs, access controls | BAA agreements, encryption at rest and in transit, access logging, data isolation |
| GDPR | European Union | Data minimization, right to erasure, data portability, consent management | Data residency options, consent management, export tools, deletion workflows |
Compliance Service Implementation (C#)
C#
public class ComplianceService
{
private readonly IConsentRepository _consentRepo;
private readonly IAuditTrailService _auditTrail;
private readonly IRetentionService _retention;
public async Task EnsureEsignComplianceAsync(
string envelopeId, string signerEmail,
string ipAddress, CancellationToken ct)
{
var consent = await _consentRepo.GetConsentAsync(
signerEmail,
ConsentType.ElectronicRecords, ct);
if (consent == null || consent.WithdrawnAt != null)
{
throw new ComplianceException(
"ESIGN Act requires affirmative consent to " +
"electronic records before signing. The signer " +
"must consent to receive records electronically.");
}
await _auditTrail.LogEventAsync(new AuditEvent
{
EnvelopeId = envelopeId,
EventType = "compliance.esign_consent_verified",
ActorId = signerEmail,
ActorType = "signer",
IpAddress = IPAddress.Parse(ipAddress),
EventDetails = new
{
consentId = consent.Id,
consentDate = consent.ConsentDate,
consentMethod = consent.Method,
consentVersion = consent.AgreementVersion
}
}, ct);
}
public async Task<ComplianceReport>
GenerateComplianceReportAsync(
string envelopeId, CancellationToken ct)
{
var envelope = await _envelopeService
.GetByIdAsync(envelopeId, ct);
var auditTrail = await _auditTrail
.GetFullTrailAsync(envelopeId, ct);
var seal = await _sealService
.GetSealForEnvelopeAsync(envelopeId, ct);
return new ComplianceReport
{
EnvelopeId = envelopeId,
Jurisdiction = envelope.Jurisdiction,
Frameworks = DetermineApplicableFrameworks(
envelope),
ConsentStatus = await VerifyConsentStatusAsync(
envelope, ct),
AuditTrailIntegrity = await _auditTrail
.VerifyAuditTrailAsync(envelopeId, ct),
SealIntegrity = seal != null
? await VerifySealIntegrityAsync(seal, ct)
: SealIntegrity.NotSealed,
RecordRetention = new RetentionInfo
{
RequiredYears = GetRequiredRetentionYears(
envelope.Jurisdiction),
ActualRetention = _retention
.GetRetentionPolicy(envelope.TenantId),
ExpiryDate = CalculateExpiryDate(envelope)
},
GeneratedAt = DateTime.UtcNow
};
}
private int GetRequiredRetentionYears(
string jurisdiction)
{
return jurisdiction switch
{
"US" => 7,
"EU" => 10,
"UK" => 6,
"CA" => 7,
_ => 7
};
}
}
22. Monitoring, Security, Cost Estimation & Testing Strategy
Monitoring & Alerting
The platform requires comprehensive monitoring across three dimensions: availability (is the service up?), performance (is it fast enough?), and correctness (are documents being sealed properly?). Given the legal nature of the platform, correctness monitoring is especially critical — a seal failure or audit trail corruption must trigger an immediate high-priority alert.
| Metric | Threshold | Severity | Response |
|---|---|---|---|
| API Error Rate | > 0.1% for 5 min | Critical | Page on-call, investigate immediately |
| Signing Ceremony P99 Latency | > 5 seconds | High | Investigate DB/cache performance |
| Seal Failure Rate | Any failure | Critical | Halt completions, investigate HSM connectivity |
| Audit Trail Chain Break | Any chain violation | Critical | Isolate affected envelopes, forensic review |
| Document Upload Failure | > 1% failure rate | High | Check storage service, PDF converter health |
| Email Delivery Rate | < 95% | Medium | Check email provider, IP reputation |
| DB Connection Pool | > 80% utilization | High | Scale read replicas, optimize queries |
| Kafka Consumer Lag | > 10K messages | Medium | Scale consumer instances |
| Document Storage Usage | > 85% | Medium | Archive old documents, scale storage |
| HSM Operation Latency | > 2 seconds P99 | High | Check HSM health, consider adding HSM nodes |
Security Architecture
| Layer | Mechanism | Details |
|---|---|---|
| Transport | TLS 1.3 | All API and ceremony traffic encrypted in transit |
| At Rest | AES-256-GCM | All documents and audit logs encrypted at rest |
| Authentication | OAuth 2.0 + JWT | API keys for integrations, session tokens for ceremonies |
| Authorization | RBAC + ABAC | Role-based access with attribute conditions (tenant, ownership) |
| Certificate Storage | CloudHSM (FIPS 140-2 L3) | Signing certificates in hardware security modules |
| Secrets | AWS Secrets Manager | API keys, DB credentials, encryption keys rotated quarterly |
| WAF | AWS WAF | OWASP Top 10 protection, rate limiting, geo-blocking |
| Vulnerability Scanning | Snyk + Trivy | Dependency scanning, container image scanning in CI/CD |
| Penetration Testing | Annual third-party | Certified ethical hackers, SOC 2 requirement |
| Data Isolation | Per-tenant encryption keys | Each tenant has unique encryption keys via AWS KMS |
Cost Estimation (Monthly, Cloud Infrastructure)
| Component | Specification | Monthly Cost |
|---|---|---|
| API Gateway Cluster | 50 nodes (c5.2xlarge) | $15,000 |
| Signing Ceremony Service | 100 nodes (c5.xlarge) | $15,000 |
| Document Processing Workers | 200 nodes (c5.2xlarge) | $30,000 |
| Notification Workers | 30 nodes (t3.large) | $1,500 |
| PostgreSQL Cluster | 6 nodes (db.r6g.2xlarge) + replicas | $12,000 |
| Redis Cluster | 6 nodes (r6g.xlarge) | $4,500 |
| Kafka (MSK) | 6 brokers (kafka.m5.2xlarge) | $6,000 |
| S3 Storage (Documents) | 600 TB/year ($0.023/GB) | $11,500 |
| S3 Storage (Audit + Metadata) | 10 TB | $230 |
| CloudHSM | 2 instances (FIPS 140-2 L3) | $3,000 |
| CDN (CloudFront) | 10 TB/month transfer | $850 |
| Email (SendGrid) | 10M emails/month | $1,500 |
| SMS (Twilio) | 500K SMS/month | $3,000 |
| Monitoring (Datadog) | Full stack monitoring | $2,500 |
| Identity Verification | KBA + ID Verification (100K/month) | $50,000 |
| WAF + DDoS Protection | AWS WAF + Shield | $2,000 |
| Total | ~$158,580 |
Testing Strategy
Testing an e-signature platform requires special attention because failures have legal consequences. The testing strategy covers functional correctness, cryptographic integrity, compliance validation, and performance under load.
| Test Type | Scope | Tools | Frequency |
|---|---|---|---|
| Unit Tests | State machine transitions, hash calculations, field validation | xUnit, Moq | Every commit |
| Integration Tests | API endpoints, database operations, S3 interactions | TestContainers, WireMock | Every PR |
| Cryptographic Tests | Seal generation/verification, hash chain integrity, PKCS#7 validation | OpenSSL, custom validators | Nightly |
| Compliance Tests | ESIGN consent flow, audit trail completeness, retention policies | Custom test suite | Weekly |
| Load Tests | Signing ceremony throughput, bulk send performance, seal service capacity | k6, Grafana | Before releases |
| Chaos Tests | HSM failover, database failover, Kafka partition loss | Chaos Monkey, Litmus | Monthly |
| Security Tests | OWASP Top 10, API fuzzing, authentication bypass attempts | OWASP ZAP, Burp Suite | Monthly |
| E2E Tests | Complete signing flow from upload to download | Playwright, Selenium | Every release |
C#
// Example: Cryptographic seal verification test
[Fact]
public async Task SealedDocument_Verification_ReturnsValid()
{
// Arrange
var envelope = await CreateTestEnvelopeWithSignatures();
var sealResult = await _sealService.SealEnvelopeAsync(
envelope.Id);
// Act
var verification = await _verificationService
.VerifyDocumentAsync(envelope.Id);
// Assert
Assert.Equal(VerificationStatus.Valid, verification.Status);
Assert.Equal(sealResult.SealHash, verification.SealHash);
Assert.Empty(verification.TamperViolations);
Assert.Equal(
ChainIntegrity.Valid,
verification.AuditTrailIntegrity);
}
[Fact]
public async Task TamperedDocument_Verification_DetectsTampering()
{
// Arrange
var envelope = await CreateTestEnvelopeWithSignatures();
await _sealService.SealEnvelopeAsync(envelope.Id);
// Tamper with document
await TamperWithDocumentAsync(envelope.Id);
// Act
var verification = await _verificationService
.VerifyDocumentAsync(envelope.Id);
// Assert
Assert.Equal(
VerificationStatus.Invalid, verification.Status);
Assert.NotEmpty(verification.TamperViolations);
Assert.Contains(verification.TamperViolations,
v => v.Type == "document_modified");
}
[Fact]
public async Task AuditTrail_ChainBreak_Detected()
{
// Arrange
var envelope = await CreateTestEnvelopeWithAuditTrail();
await TamperWithAuditEventAsync(envelope.Id);
// Act
var verification = await _auditTrail
.VerifyAuditTrailAsync(envelope.Id);
// Assert
Assert.False(verification.IsValid);
Assert.Contains(verification.Violations,
v => v.ViolationType == "broken_chain");
}
23. Interview Q&A Deep Dive
Here are the most commonly asked system design interview questions about e-signature platforms, along with detailed answers covering the key tradeoffs and design decisions.
Q1: How do you ensure a document hasn't been tampered with after signing?
We implement a tamper-evident seal using cryptographic digital signatures. When an envelope is completed, a manifest is computed from the SHA-512 hashes of all signed documents, the audit trail hash, the envelope metadata, and the completion timestamp. This manifest is signed using the platform's private key stored in a Hardware Security Module (HSM) compliant with FIPS 140-2 Level 3. The resulting PKCS#7/CMS signature is stored alongside the completed document. Any modification to the document — even changing a single byte — invalidates the manifest hash and therefore the digital signature. Third parties can verify document integrity using the platform's public certificate, which is issued by a trusted Certificate Authority whose root certificates are pre-installed in all major operating systems.
Q2: How do you handle the case where a signer claims they didn't sign the document?
The platform captures extensive evidence of signer identity and intent: (1) Identity verification records (SMS code, KBA answers, ID document verification). (2) IP address and user agent of the device used to sign. (3) Timestamp of every action during the signing ceremony. (4) Pages viewed and time spent on each page. (5) The drawn signature's biometric data (stroke coordinates, timing, pressure). (6) Consent to electronic records with timestamp. This evidence is compiled into an Evidence Summary Report that accompanies the audit trail and can be presented in court. The cryptographic chain ensures that none of this evidence can be fabricated or altered after the fact.
Q3: How does the system handle a signer who is in the middle of signing and their browser crashes?
The signing ceremony session is persisted in Redis with a 30-minute expiry. The session stores every completed field in real-time — when a signer fills in a field and the API confirms receipt, the field value is persisted immediately (not just in the browser). When the signer returns and re-opens the signing link, the session is restored from Redis, and they see all their previously completed fields intact. They only need to complete any fields they hadn't finished before the crash. The session recovery is transparent to the signer — they see a seamless continuation of their signing experience. If the session has expired (30 minutes of inactivity), the signer re-authenticates and starts a new session, but all previously completed fields remain saved in the envelope record.
Q4: How do you prevent two people from signing the same document in parallel when only sequential signing is configured?
The sequential signing orchestrator uses a distributed lock (Redis SETNX with TTL) per envelope. When the first signer completes, the lock prevents the orchestrator from processing concurrent state changes. The orchestrator atomically transitions the envelope state, finds the next recipient, and sends them the signing invitation. The next signer cannot access the signing ceremony until the orchestrator has explicitly activated their recipient record (status changed from "pending" to "sent"). Even if a signer somehow accesses the signing link before their turn (e.g., by guessing the URL), the Signing Ceremony Service checks the recipient's current status and rejects access if it hasn't been activated. This defense-in-depth approach (lock + state check + status gate) ensures strict sequential execution.
Q5: How do you scale the signing ceremony for millions of concurrent signers?
The signing ceremony is designed for horizontal scalability. Each signing session is stateless on the server — session state is stored in Redis, and document pages are pre-rendered as PNG images on S3 and served through CloudFront CDN. The API servers are stateless and can be scaled independently. The bottleneck is typically the database (field completion updates) rather than the API tier. We mitigate this by: (1) Using Redis as a write-through cache — field completions are written to Redis first and asynchronously flushed to PostgreSQL. (2) Batch-processing audit trail events — instead of writing each field completion to the audit trail immediately, we buffer events and write them in batches every 5 seconds. (3) Using connection pooling with per-tenant rate limiting to prevent any single tenant from overwhelming the database. At peak (50K concurrent sessions), each API server handles approximately 500 sessions, which is well within capacity for stateless request processing.
Q6: How do you handle documents that are extremely large (100+ pages) without impacting signing ceremony performance?
Large documents use a lazy loading strategy. During document upload, all pages are pre-rendered as PNG thumbnails at two resolutions: a low-resolution preview (72 DPI, ~20 KB per page) and a high-resolution version (150 DPI, ~200 KB per page). During the signing ceremony, only the low-resolution previews are loaded initially (first 5 pages eagerly, rest lazy-loaded on scroll). When a signer navigates to a specific page, the high-resolution version is loaded in the background. The signing fields are rendered as an overlay on top of the page image — this overlay is a lightweight JSON structure (~1 KB per page) that loads instantly. For a 100-page document, the initial ceremony load is approximately 200 KB (10 preview pages + field data) rather than 20 MB (all pages at full resolution). Page rendering in the background uses progressive enhancement — signers can begin interacting with fields on already-loaded pages while subsequent pages load.
Q7: How do you handle document versioning and revisions during the signing process?
Documents can only be modified while the envelope is in "draft" status. Once sent, all documents are locked — their content hash is computed and stored, and the original files are marked as immutable in the object store. If a sender needs to make changes after sending, they must void the envelope and create a new one (the audit trail preserves the voided envelope's history). For draft envelopes, document versioning is supported: each upload creates a new version, and the field placements reference a specific document version. If a document is replaced, field placements are preserved if the page count matches, or reset if the new document has a different structure. The sender is notified of any field placement changes and must re-confirm before sending.
Q8: How do you ensure the signing ceremony works for users with disabilities (ADA/WCAG compliance)?
The signing ceremony is built to WCAG 2.1 AA compliance: all interactive elements have ARIA labels, the field tab order follows document structure, color contrast ratios exceed 4.5:1 for all text, and the signature pad supports keyboard-only input (arrow keys to draw, Enter to complete). Screen readers announce each field's type, required status, and completion state. The signing ceremony supports high-contrast mode and respects user font-size preferences. Automated accessibility testing is integrated into the CI/CD pipeline using axe-core, and quarterly manual audits are conducted by accessibility consultants. In-person signing mode provides additional accommodations: larger touch targets, audio cues for field navigation, and support for external assistive devices via USB/Bluetooth.
Q9: How do you handle timezone issues for documents with deadlines and expiration dates?
All timestamps in the system are stored in UTC (ISO 8601 format) and converted to the user's local timezone only for display purposes. The expiration timer is set relative to the sent_at timestamp in UTC, not the sender's local timezone. The API accepts timezone-aware timestamps from clients and normalizes them to UTC. The signing ceremony displays times in the signer's detected timezone (via browser API) with a note indicating the UTC equivalent. For cross-border transactions where signers are in different timezones, the email notification includes the expiration time in both UTC and the recipient's local timezone. The audit trail records both UTC and the actor's detected timezone to avoid ambiguity in legal proceedings.
Q10: How would you design the system if document storage cost was the primary concern?
If cost is the primary optimization target: (1) Use aggressive deduplication — hash every uploaded document and store only unique copies, referencing by hash for duplicate uploads. This can reduce storage by 40-60% for template-heavy tenants. (2) Render signing ceremony pages at 72 DPI instead of 150 DPI, reducing thumbnail storage by 75%. (3) Implement aggressive lifecycle policies: move completed documents to Glacier after 30 days (instead of 90), Deep Archive after 6 months. (4) Use WebP instead of PNG for page thumbnails, reducing image storage by 60%. (5) Compress audit trail events using columnar compression (Parquet format in S3). (6) For tenants with high volume, offer a "compressed document" tier where completed PDFs are recompressed using maximum compression settings (reducing PDF size by 30-50% with no quality loss). These optimizations can reduce total storage costs by 60-70%, bringing the monthly storage bill from ~$12K to ~$4K.
Pre-Interview Checklist
- Understand the envelope state machine and all valid transitions
- Know how cryptographic sealing works (PKCS#7/CMS, HSM, Certificate Authority chain of trust)
- Design the audit trail with hash chaining and explain why it's append-only
- Explain the difference between parallel, sequential, and hybrid signing orders
- Know the ESIGN Act requirements (intent, consent, attribution, retention)
- Discuss signature types and their evidentiary strengths
- Explain how the signing ceremony scales (stateless API, Redis sessions, CDN-rendered pages)
- Know the identity verification hierarchy (email → SMS → KBA → ID → Biometric)
- Discuss document storage tiers and lifecycle management
- Understand webhook design for real-time integration with CRMs
- Know how to handle edge cases: signer unavailable, browser crash, document tampering
- Discuss compliance across jurisdictions (US: ESIGN/UETA, EU: eIDAS, Global: GDPR)
Key Metrics to Remember
| Metric | Value | Notes |
|---|---|---|
| DocuSign annual envelopes | 1 billion+ | Across 188 countries |
| DocuSign market share | ~70% (enterprise) | Dominant player in e-signature |
| Market size (2030 projected) | $35 billion | 25% CAGR |
| Average signing ceremony time | 3-5 minutes | Varies by document complexity |
| Signing completion rate | ~85% | 15% abandonment (slow UX, complex docs) |
| Mobile signing share | ~40% | Growing rapidly, especially in APAC |
| ESIGN Act enacted | 2000 | US Federal law establishing legal validity |
| eIDAS regulation | 2014/2016 | EU framework for electronic identification |
| Average cost per signature (enterprise) | $0.50-$5.00 | Depends on verification method |
| SOC 2 Type II audit duration | 6-12 months | Required for enterprise sales |
| Monthly infrastructure cost | ~$158K | At 100M envelopes/month scale |
| Minimum audit trail retention | 7 years | Varies by jurisdiction (US default) |