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.

Code editor flowing to a signed contract — automating contract signing

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:

  1. Accept contract content (text, markdown, or a PDF URL)
  2. Send it to a recipient for legally binding e-signature
  3. Receive confirmation when the document is signed
  4. 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
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"
  }'
JavaScript (Node.js / fetch)
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" }
Python (requests)
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:

Send an existing PDF
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:

Real-world integration example
// 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

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

prisma/schema.prisma
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__.

models.py
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:

webhookHandler.ts
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:

app.py
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

  1. Email arrives — Clean email with a "Review & Sign" button
  2. Document review — Full PDF preview in the browser
  3. Signature capture — Recipient types their name, sees an animated handwriting signature
  4. Certificate generated — Final page with timestamps, IPs, and SHA-256 hash
  5. 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.