April 2026 · Tutorial
How to Automate Contract Signing in Your App
You have an app. Users need to sign something. Here's how to add contract signing in under 10 minutes, with code you can copy today.
Founder, Signbee

TL;DR
Add e-signatures to any app in under 10 minutes with one REST API call. POST your document content (markdown or PDF URL) with sender and recipient details to Signbee's endpoint. The API handles PDF generation, email delivery, signature capture, and SHA-256 certificate creation. No SDK, no OAuth, no template setup required.
According to a 2024 Deloitte Digital Transformation Survey, 78% of enterprises cite “manual contract workflows” as their top process automation target. (Deloitte).
Key statistic
Companies that automate contract signing reduce their average deal close time by 44% and decrease contract abandonment by 31%, according to Aberdeen Group research.
“The fastest path from agreement to execution is zero clicks. If a contract can be generated, it should be signed without leaving the application.”
— Jason Fried, CEO of 37signals
What we're building
By the end of this tutorial, your app will be able to:
- Accept contract content (text, markdown, or a PDF URL)
- Send it to a recipient for legally binding e-signature
- Receive confirmation when the document is signed
- Deliver a signed PDF with a tamper-proof SHA-256 certificate
No SDK installations. No webhook servers. No template builders. One HTTP request.
Step 1: Get an API key
Sign up at signb.ee and grab your API key from the dashboard. Free tier gives you 5 documents per month — enough to build and test the integration.
Don't want to create an account? Skip the API key entirely. The API works without it — the sender just verifies via a one-time email code. Good enough for prototyping.
Step 2: Send your first document
Here's the core API call in three languages. Pick yours.
curl -X POST https://signb.ee/api/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "# Service Agreement\n\nThis agreement is between **Acme Corp** and **Jane Doe** for design consulting services at $150/hour.\n\n## Scope\n\n- Brand identity design\n- Website mockups (3 rounds)\n- Final asset delivery in Figma\n\n## Terms\n\n- Net 30 payment terms\n- Valid for 6 months from signing",
"senderName": "Michael Chen",
"senderEmail": "michael@acmecorp.com",
"recipientName": "Jane Doe",
"recipientEmail": "jane@studio.com"
}'const response = await fetch('https://signb.ee/api/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: `# Service Agreement
This agreement is between **Acme Corp** and **Jane Doe**
for design consulting at $150/hour.
## Scope
- Brand identity design
- Website mockups (3 rounds)
- Final asset delivery in Figma
## Terms
- Net 30 payment terms
- Valid for 6 months`,
senderName: 'Michael Chen',
senderEmail: 'michael@acmecorp.com',
recipientName: 'Jane Doe',
recipientEmail: 'jane@studio.com',
}),
});
const result = await response.json();
// { id: "doc_abc123", status: "pending" }import requests
response = requests.post(
'https://signb.ee/api/send',
headers={
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
json={
'content': '# Service Agreement\n\nThis agreement...',
'senderName': 'Michael Chen',
'senderEmail': 'michael@acmecorp.com',
'recipientName': 'Jane Doe',
'recipientEmail': 'jane@studio.com',
}
)
print(response.json())That's it. One request. The recipient gets an email with a signing link, signs the document, and both parties receive the signed PDF with a SHA-256 audit certificate.
Step 3: Use existing PDFs
Already have a contract template as a PDF? Use the PDF endpoint instead:
const response = await fetch('https://signb.ee/api/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
pdf_url: 'https://your-app.com/contracts/template-v2.pdf',
senderName: 'Michael Chen',
senderEmail: 'michael@acmecorp.com',
recipientName: 'Jane Doe',
recipientEmail: 'jane@studio.com',
}),
});The PDF is downloaded, prepared for signing, and sent. Your formatting is preserved exactly.
Step 4: Add it to your app's workflow
Here's a real-world pattern — a freelance marketplace where clients hire contractors:
// When a client approves a contractor's proposal
async function onProposalAccepted(proposal) {
// Generate the contract from your template
const contract = generateContract({
clientName: proposal.client.name,
clientEmail: proposal.client.email,
contractorName: proposal.contractor.name,
contractorEmail: proposal.contractor.email,
rate: proposal.hourlyRate,
scope: proposal.scopeDescription,
});
// Send for signing — one API call
const result = await fetch('https://signb.ee/api/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SIGNBEE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: contract,
senderName: proposal.client.name,
senderEmail: proposal.client.email,
recipientName: proposal.contractor.name,
recipientEmail: proposal.contractor.email,
}),
});
// Update your database
await db.proposals.update({
where: { id: proposal.id },
data: {
status: 'contract_sent',
contractId: (await result.json()).id,
},
});
}Step 5: Check document status
const status = await fetch(
`https://signb.ee/api/documents/${documentId}`,
{ headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }
);
const doc = await status.json();
// doc.status: "pending" | "sender_signed" | "completed"
// doc.certificateUrl: signed PDF (when completed)Sequential Multi-Signer Architecture
In real-world business workflows, contracts are rarely signed by just one person. Standard agreements, such as Statements of Work (SOWs), Master Service Agreements (MSAs), and employment contracts, often require a strict order of signatures. For instance, the service provider (Signer 1) might need to sign the document to lock the terms before the client (Signer 2) is notified to review and execute it.
A sequential multi-signer architecture ensures that the signing ceremony progresses in a controlled, linear fashion. Rather than blasting out signing links to all parties simultaneously—which leads to collision conflicts, out-of-order execution, and invalid audit certificates—the application orchestrates the flow using status flags and event-driven progression.
Backend Event Progression
When a sequential document is initialized, the orchestrator sets the first signer's state to ACTIVE and dispatches their notification. All subsequent signers remain in an INACTIVE state. The core engine listens for webhook signals from Signbee. When Signer 1 signs, Signbee issues a webhook event. The application verifies the webhook, marks Signer 1 as SIGNED, locates Signer 2 (whose sequence order is index 2), flags them as ACTIVE, and dispatches the link to Signer 2.
Signer State Machine
To maintain absolute database integrity, we define explicit states for each signer and the document. A signer must transition through the state machine according to the flowchart below:
+---------------------------------------------+
| Document Initialized: Status = 'pending' |
+---------------------------------------------+
|
v
+---------------------------------------------+
| Signer 1 (Order 1): Status = 'active' | <-- Email dispatch with link
| Signer 2 (Order 2): Status = 'inactive' |
+---------------------------------------------+
|
Signer 1 signs |
via Signbee Link v
+---------------------------------------------+
| Webhook Event: 'document.signer.signed' |
+---------------------------------------------+
|
v
+---------------------------------------------+
| App database updates Signer 1: 'signed' |
| App queries database for Order 2 |
+---------------------------------------------+
|
Next signer | exists
v
+---------------------------------------------+
| App POSTs to Signbee to activate Signer 2 |
| Signer 2 (Order 2): Status = 'active' | <-- Email dispatch with link
+---------------------------------------------+
|
Signer 2 signs |
via Signbee Link v
+---------------------------------------------+
| Webhook Event: 'document.signer.signed' |
+---------------------------------------------+
|
Next signer | does NOT exist
v
+---------------------------------------------+
| App database updates Signer 2: 'signed' |
| Document Status transitions to 'completed' |
| Trigger document.completed webhook |
+---------------------------------------------+If any signer declines the agreement, the document's state transitions to DECLINED. The entire signing workflow is aborted, and all remaining INACTIVE signer links are invalidated.
Relational Database Design for Sequential Workflows
Enforcing signature sequence rules requires a well-structured database schema. A flat document structure is insufficient; instead, we implement a one-to-many relationship between the Document and Signer models.
The Document table holds global information, such as the overall document status (draft, pending, completed, declined, or expired) and a secure pointer to the file itself (such as an AWS S3 key). The Signer table tracks the details of each individual signatory, including their unique order position (an integer like 1, 2, or 3), their specific signature status, their unique URL token, and their signing timestamp.
To ensure data consistency and prevent race conditions (such as two signers occupying the same order position for a single document), we configure a unique compound index on (documentId, signingOrder).
1. Prisma Schema (PostgreSQL)
The Prisma ORM definition uses native Postgres constraints. We define DocumentStatus and SignerStatus enums to type-gate our state transitions:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum DocumentStatus {
DRAFT
PENDING
COMPLETED
DECLINED
EXPIRED
}
enum SignerStatus {
INACTIVE
ACTIVE
SIGNED
DECLINED
}
model Document {
id String @id @default(uuid())
title String
fileKey String @unique
status DocumentStatus @default(PENDING)
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signers Signer[]
@@index([status])
}
model Signer {
id String @id @default(uuid())
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
name String
email String
signingOrder Int
status SignerStatus @default(INACTIVE)
signedAt DateTime?
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([documentId, signingOrder])
@@index([status, email])
@@index([token])
}2. SQLAlchemy Models (Python)
For Python backends, we define the corresponding SQLAlchemy models using declarative mapping. The compound unique constraint is explicitly configured in __table_args__.
from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, UniqueConstraint, Index, JSON
from sqlalchemy.orm import declarative_base, relationship
from datetime import datetime
import enum
Base = declarative_base()
class DocumentStatus(enum.Enum):
DRAFT = "DRAFT"
PENDING = "PENDING"
COMPLETED = "COMPLETED"
DECLINED = "DECLINED"
EXPIRED = "EXPIRED"
class SignerStatus(enum.Enum):
INACTIVE = "INACTIVE"
ACTIVE = "ACTIVE"
SIGNED = "SIGNED"
DECLINED = "DECLINED"
class Document(Base):
__tablename__ = "documents"
id = Column(String(36), primary_key=True, index=True)
title = Column(String(255), nullable=False)
file_key = Column(String(255), unique=True, nullable=False)
status = Column(Enum(DocumentStatus), default=DocumentStatus.PENDING, nullable=False)
metadata_json = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
signers = relationship("Signer", back_populates="document", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_document_status", "status"),
)
class Signer(Base):
__tablename__ = "signers"
id = Column(String(36), primary_key=True, index=True)
document_id = Column(String(36), ForeignKey("documents.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False)
email = Column(String(255), nullable=False)
signing_order = Column(Integer, nullable=False)
status = Column(Enum(SignerStatus), default=SignerStatus.INACTIVE, nullable=False)
signed_at = Column(DateTime, nullable=True)
token = Column(String(255), unique=True, nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
document = relationship("Document", back_populates="signers")
__table_args__ = (
UniqueConstraint("document_id", "signing_order", name="uq_document_signer_order"),
Index("idx_signer_status_email", "status", "email"),
Index("idx_signer_token", "token"),
)Implementing Webhook Handlers & Background Workers
Webhook processing must be fast and resilient. Because third-party HTTP requests can time out, your webhook endpoints should only verify the incoming payload's authenticity, enqueue a job in a message queue (like Redis with BullMQ or Celery), and immediately return a 200 OK status.
This decoupled architecture prevents timeouts and handles automatic retries in the background if database lookups or subsequent outbound API requests fail. Below are complete, secure implementations in both Node.js (with Express & BullMQ) and Python (with Flask & Celery).
Node.js & Express (with BullMQ & Prisma)
This Node.js server verifies the incoming webhook signature using HMAC-SHA256 before enqueuing a background job. The worker executes the signature transition inside a database transaction:
import express from 'express';
import crypto from 'crypto';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const connection = new IORedis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
// Initialize BullMQ Queue
const signingQueue = new Queue('signature-events', { connection });
const app = express();
app.use(express.json({
verify: (req: any, res, buf) => {
req.rawBody = buf;
}
}));
const WEBHOOK_SECRET = process.env.SIGNBEE_WEBHOOK_SECRET || 'super-secret-key';
// Webhook endpoint to receive Signbee completion hooks
app.post('/webhooks/signbee', async (req: any, res) => {
const signature = req.headers['signbee-signature'];
if (!signature || typeof signature !== 'string') {
return res.status(401).json({ error: 'Missing signature header' });
}
// Verify HMAC-SHA256 signature
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(req.rawBody);
const expectedSignature = hmac.digest('hex');
try {
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
return res.status(401).json({ error: 'Invalid signature' });
}
} catch (err) {
return res.status(401).json({ error: 'Signature verification failed' });
}
const event = req.body;
if (event.type === 'document.signer.signed') {
await signingQueue.add('process-signer-completion', {
documentId: event.data.documentId,
signerEmail: event.data.signerEmail,
signedAt: event.data.signedAt,
});
}
return res.status(200).json({ received: true });
});
// BullMQ Worker to process events in the background asynchronously
const worker = new Worker('signature-events', async (job) => {
const { documentId, signerEmail, signedAt } = job.data;
// Execute inside a database transaction to prevent race conditions
await prisma.$transaction(async (tx) => {
// 1. Find the current signer
const currentSigner = await tx.signer.findFirst({
where: {
documentId,
email: signerEmail,
status: 'ACTIVE',
},
});
if (!currentSigner) {
console.log(`Signer ${signerEmail} not found or already processed for doc ${documentId}`);
return;
}
// 2. Mark the current signer as SIGNED
await tx.signer.update({
where: { id: currentSigner.id },
data: {
status: 'SIGNED',
signedAt: new Date(signedAt),
},
});
// 3. Find the next signer in sequential order (currentOrder + 1)
const nextSigner = await tx.signer.findFirst({
where: {
documentId,
signingOrder: currentSigner.signingOrder + 1,
},
});
if (nextSigner) {
// 4. Update next signer status to ACTIVE
await tx.signer.update({
where: { id: nextSigner.id },
data: { status: 'ACTIVE' },
});
// 5. Trigger POST to Signbee to dispatch the signing link to Signer 2
const signbeeResponse = await fetch('https://signb.ee/api/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SIGNBEE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
pdf_url: `https://your-app-bucket.s3.amazonaws.com/contracts/${documentId}.pdf`,
senderName: 'Contract Workflow System',
senderEmail: 'noreply@yourdomain.com',
recipientName: nextSigner.name,
recipientEmail: nextSigner.email,
metadata: {
documentId: documentId,
signingOrder: nextSigner.signingOrder,
},
}),
});
if (!signbeeResponse.ok) {
throw new Error(`Failed to dispatch signing link to next signer ${nextSigner.email}`);
}
} else {
// No next signer exists — the document is fully executed
await tx.document.update({
where: { id: documentId },
data: { status: 'COMPLETED' },
});
console.log(`Document ${documentId} is fully signed by all parties.`);
}
});
}, { connection });Python & Flask (with Celery & SQLAlchemy)
In Python, the Flask route extracts the raw request body to verify the signature header, then dispatches the transaction tasks to Celery:
import hmac
import hashlib
import os
from flask import Flask, request, jsonify, abort
from celery import Celery
from database import db_session
from models import Document, Signer, DocumentStatus, SignerStatus
from datetime import datetime
import requests
app = Flask(__name__)
# Configure Celery
app.config['CELERY_BROKER_URL'] = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'])
celery.conf.update(app.config)
WEBHOOK_SECRET = os.getenv('SIGNBEE_WEBHOOK_SECRET', 'super-secret-key').encode('utf-8')
def verify_webhook_signature(data: bytes, signature: str) -> bool:
if not signature:
return False
expected = hmac.new(WEBHOOK_SECRET, data, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
@app.route('/webhooks/signbee', methods=['POST'])
def signbee_webhook():
signature = request.headers.get('Signbee-Signature')
raw_body = request.get_data()
if not verify_webhook_signature(raw_body, signature):
abort(401, description="Invalid webhook signature")
event = request.json
if event.get('type') == 'document.signer.signed':
process_signer_completion.delay(
document_id=event['data']['documentId'],
signer_email=event['data']['signerEmail'],
signed_at_str=event['data']['signedAt']
)
return jsonify({"received": True}), 200
@celery.task(bind=True, max_retries=3, default_retry_delay=10)
def process_signer_completion(self, document_id: str, signer_email: str, signed_at_str: str):
session = db_session()
try:
with session.begin():
# 1. Retrieve current signer in ACTIVE state
current_signer = session.query(Signer).filter(
Signer.document_id == document_id,
Signer.email == signer_email,
Signer.status == SignerStatus.ACTIVE
).first()
if not current_signer:
print(f"Signer {signer_email} not active or already processed for doc {document_id}")
return
# 2. Update status to SIGNED
current_signer.status = SignerStatus.SIGNED
current_signer.signed_at = datetime.fromisoformat(signed_at_str.replace('Z', '+00:00'))
# 3. Find next signer in line
next_signer = session.query(Signer).filter(
Signer.document_id == document_id,
Signer.signing_order == current_signer.signing_order + 1
).first()
if next_signer:
# 4. Activate the next signer
next_signer.status = SignerStatus.ACTIVE
# 5. Dispatch API POST request to Signbee
payload = {
"pdf_url": f"https://your-app-bucket.s3.amazonaws.com/contracts/{document_id}.pdf",
"senderName": "Contract Workflow System",
"senderEmail": "noreply@yourdomain.com",
"recipientName": next_signer.name,
"recipientEmail": next_signer.email,
"metadata": {
"documentId": document_id,
"signingOrder": next_signer.signing_order
}
}
response = requests.post(
"https://signb.ee/api/v1/send",
json=payload,
headers={
"Authorization": f"Bearer {os.getenv('SIGNBEE_API_KEY')}",
"Content-Type": "application/json"
},
timeout=10
)
if response.status_code != 200:
raise Exception(f"Signbee API error: {response.text}")
else:
# No next signer — update document to completed
document = session.query(Document).filter(Document.id == document_id).first()
if document:
document.status = DocumentStatus.COMPLETED
print(f"Document {document_id} fully signed.")
except Exception as exc:
session.rollback()
raise self.retry(exc=exc)
finally:
session.close()What happens on the recipient's side
- Email arrives — Clean email with a "Review & Sign" button
- Document review — Full PDF preview in the browser
- Signature capture — Recipient types their name, sees an animated handwriting signature
- Certificate generated — Final page with timestamps, IPs, and SHA-256 hash
- Both parties receive the signed PDF — Delivered via email
The entire signing experience takes under 60 seconds.
When to use this vs. enterprise solutions
This approach works best when:
- Your contracts are straightforward (two parties, one document)
- You want to ship fast without a complex integration
- Your documents can be generated as markdown or existing PDFs
- You need legal validity but not advanced compliance (QES, notarisation)
Consider enterprise solutions (DocuSign, Adobe Sign) when:
- You need multi-signer routing with conditional logic
- You require Qualified Electronic Signatures for EU regulated industries
- You need field-level placement (specific signature boxes, initials, dates)
- You're in healthcare (HIPAA) or finance with specific audit requirements
FAQs
Is this legally binding?
Yes. Electronic signatures are legally valid under the ESIGN Act (US), eIDAS (EU), and ECA (UK). Signbee adds a SHA-256 certificate to every signed document as a tamper-proof audit trail.
How much does this cost?
Free for the first 5 documents per month. No credit card required. Paid plans for higher volume.
Can I use this without an API key?
Yes. Without an API key, the sender receives a one-time verification code via email. This is slower but works for testing.
Does this work with AI agents?
Yes. Install the Signbee MCP server (npx signbee-mcp) to let Claude, Cursor, or Windsurf send documents for signing directly.
How are dynamic signer ordering edge cases managed if the signing workflow needs modification mid-flight?
Handling modifications to a signing workflow that is already in progress requires robust state machine management and strict access controls. In a sequential signing sequence, once Signer 1 has signed, their state transitions to completed and the document is locked for their entry. If a sender needs to alter the signing order (e.g., swapping Signer 3 and Signer 4, or inserting an additional reviewer) while the document is active, the application must invalidate the existing active signing session for the current pending signer. The backend accomplishes this by flagging the current signature request token as revoked in the database and notifying the active signer that their session has been updated. A new sequence is then re-calculated, updating the ordinal sign order indexes for all remaining signers. The event listener then triggers the dispatch mechanism for the new next signer in the revised sequence. In the event of a total cancellation or restart, all unused links are permanently invalidated to prevent race conditions or unauthorized out-of-order access, ensuring that the integrity of the audit log remains fully secure.
How should my application handle webhook delivery retries, network failures, and duplicate delivery events?
Webhooks are sent over public networks and are subject to transport errors, timeouts, and network partitions. To guarantee reliable synchronization, the Signbee webhook dispatcher implements an exponential backoff retry policy (e.g., retrying at 5, 15, 45 minutes, up to 24 hours) whenever the receiver does not respond with a 2xx status code. Consequently, your application's webhook endpoint must be designed to be strictly idempotent. When a webhook payload is received, you should first check if the target signer's state in your database is already updated to completed. If it is, immediately return a 200 OK response and discard the payload, avoiding redundant processing like dispatching secondary notifications to the next signer. To ensure security and prevent replay or spoofing attacks, your server must verify the webhook signature sent in the request headers (e.g., Signbee-Signature) using your shared webhook secret key. Only process the payload once the cryptographic signature is validated against the request body using HMAC-SHA256.
How are signing link expiration intervals managed, and what is the process for renewal or expiration cleanups?
By default, signing links generated for each signer in a sequence contain an expiration interval (typically 7 days) to minimize security exposure. Managing these expiration intervals requires a combination of automated cron-based cleanup tasks and proactive email reminders. When a document's signature link is nearing its expiration date, the system can automatically send warning emails or reminders to the pending signer (e.g., 24 hours prior to expiration). If a signer fails to sign before the expiry window closes, the document status changes to expired. Once expired, all subsequent signer links in the sequential chain are also invalidated, preventing further actions. To handle this programmatically, your database should track the expiresAt timestamp for each active signature request. If a document expires, a nightly background worker sweeps the database, marks the document status as expired, and triggers a webhook to your application. If the sender decides to renew the contract, the application can issue a renewal request to generate new links and restart the process.
Related resources
Try Signbee — free tier, no credit card, no setup.