July 21, 2026 · Tutorial
FastAPI E-Signature Integration: Async Contracts & Webhooks in Python (2026)
Build production-ready, asynchronous e-signature workflows in Python using FastAPI, httpx, Pydantic, PostgreSQL audit logging, and secure HMAC-SHA256 webhook verification.
Founder, Signbee
TL;DR & ARCHITECTURE SUMMARY
This guide walks you through building an enterprise-grade, non-blocking Python e-signature backend with FastAPI. You will dispatch digital contracts via Signbee's async REST API using httpx.AsyncClient, validate requests with Pydantic v2, record immutable state changes in PostgreSQL via SQLAlchemy 2.0 Async, and implement cryptographically verified HMAC-SHA256 webhook listeners using hmac and hashlib.compare_digest().
Why Asynchronous E-Signatures Matter in Python Architecture
In modern API design, e-signature dispatching is inherently an I/O-bound operation. When an application prepares a non-disclosure agreement (NDA), master services agreement (MSA), or employment contract, it generates document markdown or metadata, formats recipient profiles, and makes outbound HTTP requests to an e-signature provider.
In traditional synchronous Python applications (such as Flask or WSGI-based Django applications relying on the requests library), each outbound HTTP network hop blocks the active OS worker thread. If the e-signature API takes 400 milliseconds to process the request, generate the cryptographic signing token, and issue notification emails, that worker thread remains idle and unavailable to handle incoming web traffic.
By leveraging FastAPI and httpx.AsyncClient, your application operates on an asynchronous event loop (based on Python's asyncio). While waiting for network I/O from Signbee's API, the event loop yields control, enabling a single FastAPI application instance to handle thousands of concurrent client requests, API webhook callbacks, and database queries simultaneously.
If you are looking for a basic synchronous Python implementation using the standard requests library or Flask, refer to our foundational guide on Python E-Signature API Integration.
FastAPI E-Signature Architecture Overview
The production-ready integration pattern detailed in this tutorial separates concerns across modular layers: request schemas, client service wrappers, database models, route handlers, and webhook signature verification logic.
End-to-End Async Data Flow
- 1.Client Request: Frontend or client app calls
POST /api/contracts/sendwith JSON payload. - 2.Pydantic Validation: FastAPI automatically validates inputs, recipient emails, and contract body.
- 3.Async API Call:
SignbeeAsyncClientuseshttpxto dispatch contract to Signbee API. - 4.Database Persistence: Initial contract record and audit log are created asynchronously in PostgreSQL via
asyncpg. - 5.Webhook Event Callback: When recipient signs or views document, Signbee posts HMAC-SHA256 payload to
POST /api/webhooks/signbee. - 6.HMAC Verification: FastAPI computes digest using raw bytes and
hashlib.compare_digest()before updating contract status in PostgreSQL.
Project Setup & Dependencies
Create a clean virtual environment and install the modern Python web stack dependencies:
python3 -m venv venv
source venv/bin/activate
pip install "fastapi>=0.110.0" \
"uvicorn[standard]>=0.28.0" \
"httpx>=0.27.0" \
"pydantic>=2.6.0" \
"pydantic-settings>=2.2.0" \
"sqlalchemy[asyncio]>=2.0.28" \
"asyncpg>=0.29.0"Step 1: Application Settings & Configuration
Store sensitive API tokens and database connection credentials in environment variables. We use Pydantic's BaseSettings to guarantee type safety and automatic configuration parsing.
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
SIGNBEE_API_KEY: str
SIGNBEE_WEBHOOK_SECRET: str
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/contracts_db"
SIGNBEE_BASE_URL: str = "https://signb.ee/api/v1"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@lru_cache()
def get_settings() -> Settings:
return Settings()Step 2: Async PostgreSQL Database & Audit Trail Models
Auditability is a legal cornerstone of electronic signature compliance under regulations like ESIGN, eIDAS, and UETA. Every state transition—from initial creation to signature completion—must be immutably captured.
Here we define our database connection lifecycle using SQLAlchemy 2.0 Async Engine and asyncpg, along with relational ORM models for contracts and audit log entries:
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_size=20,
max_overflow=10,
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raiseNext, we establish the database schema mapping in app/models.py:
import uuid
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Text, DateTime, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Contract(Base):
__tablename__ = "contracts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
signbee_doc_id: Mapped[Optional[str]] = mapped_column(String(100), unique=True, index=True, nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
recipient_name: Mapped[str] = mapped_column(String(255), nullable=False)
recipient_email: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
status: Mapped[str] = mapped_column(String(50), default="pending", index=True)
signing_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
markdown_content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
audit_logs: Mapped[List["ContractAuditLog"]] = relationship("ContractAuditLog", back_populates="contract", cascade="all, delete-orphan")
class ContractAuditLog(Base):
__tablename__ = "contract_audit_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
contract_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("contracts.id"), index=True)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
event_metadata: Mapped[dict] = mapped_column(JSON, default=dict)
timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
contract: Mapped["Contract"] = relationship("Contract", back_populates="audit_logs")Step 3: Pydantic Validation Schemas
Pydantic v2 ensures strict validation of incoming requests, API payloads, and webhook events before business logic is executed.
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, Dict, Any
from datetime import datetime
class ContractSendRequest(BaseModel):
title: str = Field(..., min_length=3, max_length=255, description="Document title")
recipient_name: str = Field(..., min_length=2, max_length=100)
recipient_email: EmailStr
markdown_content: str = Field(..., min_length=10, description="Markdown document text")
class ContractSendResponse(BaseModel):
internal_contract_id: str
signbee_doc_id: str
status: str
signing_url: str
created_at: datetime
class WebhookEventData(BaseModel):
document_id: str
event: str # e.g., document.sent, document.viewed, document.signed, document.declined
recipient_email: EmailStr
signed_pdf_url: Optional[str] = None
certificate_id: Optional[str] = None
timestamp: str
metadata: Dict[str, Any] = Field(default_factory=dict)Step 4: Asynchronous Signbee API Client Wrapper
To interface cleanly with Signbee's REST API, we build an asynchronous client class utilizing httpx.AsyncClient. This client handles connection pooling, request timeouts, and exception transformations.
import logging
import httpx
from typing import Dict, Any
from app.config import get_settings
logger = logging.getLogger(__name__)
class SignbeeAPIException(Exception):
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f"Signbee API Error [{status_code}]: {message}")
class SignbeeAsyncClient:
def __init__(self):
self.settings = get_settings()
self.headers = {
"Authorization": f"Bearer {self.settings.SIGNBEE_API_KEY}",
"Content-Type": "application/json",
"User-Agent": "FastAPI-Signbee-Integration/2026.1",
}
async def send_contract(self, title: str, recipient_name: str, recipient_email: str, markdown: str) -> Dict[str, Any]:
"""Dispatch a markdown document to Signbee's async send endpoint."""
payload = {
"title": title,
"recipient_name": recipient_name,
"recipient_email": recipient_email,
"markdown": markdown,
}
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
f"{self.settings.SIGNBEE_BASE_URL}/send",
json=payload,
headers=self.headers,
)
if response.status_code != 200:
logger.error(f"Signbee API error: {response.status_code} - {response.text}")
raise SignbeeAPIException(response.status_code, response.text)
return response.json()
except httpx.RequestError as exc:
logger.error(f"Network error contacting Signbee API: {exc}")
raise SignbeeAPIException(503, "E-signature gateway unreachable")Step 5: Contract Route Handler (`POST /api/contracts/send`)
Now we create the route handler for dispatching contracts. When called, it inserts a pending record in PostgreSQL, invokes the asynchronous Signbee client, saves the resulting signbee_doc_id and signing_url, and records an initial audit log entry.
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import ContractSendRequest, ContractSendResponse
from app.models import Contract, ContractAuditLog
from app.services.signbee import SignbeeAsyncClient, SignbeeAPIException
router = APIRouter(prefix="/api/contracts", tags=["Contracts"])
signbee_client = SignbeeAsyncClient()
@router.post("/send", response_model=ContractSendResponse, status_code=status.HTTP_201_CREATED)
async def send_contract(
payload: ContractSendRequest,
db: AsyncSession = Depends(get_db),
):
# 1. Create local contract record
contract = Contract(
title=payload.title,
recipient_name=payload.recipient_name,
recipient_email=payload.recipient_email,
markdown_content=payload.markdown_content,
status="pending_dispatch",
)
db.add(contract)
await db.flush() # Generate contract.id UUID
try:
# 2. Dispatch to Signbee async API
api_result = await signbee_client.send_contract(
title=payload.title,
recipient_name=payload.recipient_name,
recipient_email=payload.recipient_email,
markdown=payload.markdown_content,
)
# 3. Update contract status and URLs
contract.signbee_doc_id = api_result["document_id"]
contract.signing_url = api_result["signing_url"]
contract.status = "sent"
# 4. Append audit log
audit = ContractAuditLog(
contract_id=contract.id,
event_type="contract.dispatched",
description=f"Contract successfully dispatched to {payload.recipient_email}",
event_metadata={"signbee_doc_id": api_result["document_id"]},
)
db.add(audit)
return ContractSendResponse(
internal_contract_id=str(contract.id),
signbee_doc_id=api_result["document_id"],
status=contract.status,
signing_url=api_result["signing_url"],
created_at=contract.created_at,
)
except SignbeeAPIException as exc:
contract.status = "failed"
audit = ContractAuditLog(
contract_id=contract.id,
event_type="contract.dispatch_failed",
description=f"Dispatch failed: {exc.message}",
event_metadata={"status_code": exc.status_code},
)
db.add(audit)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to dispatch document to e-signature service: {exc.message}",
)Step 6: Secure Webhook Handler with HMAC-SHA256 Signature Verification
When a recipient opens, signs, or declines a document, Signbee emits real-time webhook events to your application. To learn more about webhook event lifecycles and retry mechanisms, check out our guide on E-Signature API Webhook Events.
Critical Security Rule: Never trust incoming webhook payloads without verifying their cryptographic signature. Attackers could spoof HTTP requests to mark contracts as signed without legitimate signatures.
In FastAPI, signature verification requires reading the raw bytes of the request body (await request.body()) prior to parsing JSON. We compute an HMAC digest using Python's standard hmac and hashlib.sha256 modules, and perform constant-time comparison via hashlib.compare_digest() to prevent timing attacks.
import hmac
import hashlib
import json
import logging
from fastapi import APIRouter, Request, Header, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.config import get_settings
from app.models import Contract, ContractAuditLog
from app.schemas import WebhookEventData
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/webhooks", tags=["Webhooks"])
settings = get_settings()
def verify_hmac_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""
Verify incoming HMAC-SHA256 signature using constant-time comparison.
"""
if not signature_header or not secret:
return False
# Strip optional prefix (e.g. 'sha256=')
clean_signature = signature_header.replace("sha256=", "").strip()
expected_hmac = hmac.new(
key=secret.encode("utf-8"),
msg=raw_body,
digestmod=hashlib.sha256
).hexdigest()
return hashlib.compare_digest(expected_hmac.lower(), clean_signature.lower())
@router.post("/signbee", status_code=status.HTTP_200_OK)
async def handle_signbee_webhook(
request: Request,
x_signbee_signature: str = Header(None, alias="X-Signbee-Signature"),
db: AsyncSession = Depends(get_db),
):
# 1. Obtain raw unparsed body bytes
body_bytes = await request.body()
# 2. Verify HMAC-SHA256 signature
if not x_signbee_signature or not verify_hmac_signature(body_bytes, x_signbee_signature, settings.SIGNBEE_WEBHOOK_SECRET):
logger.warning("Rejecting invalid or unverified webhook signature")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid HMAC signature",
)
# 3. Parse JSON payload into Pydantic model
try:
raw_json = json.loads(body_bytes.decode("utf-8"))
event_data = WebhookEventData(**raw_json)
except Exception as exc:
logger.error(f"Failed to parse webhook JSON payload: {exc}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Malformed JSON body",
)
# 4. Lookup target contract in PostgreSQL
stmt = select(Contract).where(Contract.signbee_doc_id == event_data.document_id)
result = await db.execute(stmt)
contract = result.scalar_one_or_none()
if not contract:
logger.error(f"Webhook received for unknown document_id: {event_data.document_id}")
return {"status": "ignored", "reason": "Contract not found"}
# 5. Process state transition
event_type = event_data.event
if event_type == "document.signed":
contract.status = "completed"
elif event_type == "document.viewed":
contract.status = "viewed"
elif event_type == "document.declined":
contract.status = "declined"
# 6. Append immutable audit log
audit_entry = ContractAuditLog(
contract_id=contract.id,
event_type=event_type,
description=f"Webhook received event: {event_type} for recipient {event_data.recipient_email}",
event_metadata=event_data.metadata,
)
db.add(audit_entry)
logger.info(f"Successfully processed webhook event '{event_type}' for document {event_data.document_id}")
return {"status": "success", "processed_event": event_type}Once a contract is fully signed, you may need to cryptographically verify the digital signatures embedded directly inside the returned PDF document. To implement server-side verification using Python tools like pyHanko, read our tutorial on Programmatic PDF Signature Verification.
Step 7: Assembling the FastAPI Application
Finally, assemble the main FastAPI application file in app/main.py using an asynccontextmanager lifespan for database table creation and cleanup:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import engine, Base
from app.routers import contracts, webhooks
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Initialize database tables (for dev/demo; use Alembic for prod migrations)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown: Dispose DB connection pool
await engine.dispose()
app = FastAPI(
title="Async E-Signature Service",
version="2026.1",
description="FastAPI service for dispatching e-signature contracts and processing HMAC webhooks",
lifespan=lifespan,
)
app.include_router(contracts.router)
app.include_router(webhooks.router)
@app.get("/health", tags=["Health"])
async def health_check():
return {"status": "healthy", "service": "esignature-fastapi"}Testing the FastAPI E-Signature Pipeline
Start your FastAPI application with Uvicorn:
uvicorn app.main:app --reload --port 8000
You can test dispatching a contract using curl:
curl -X POST "http://localhost:8000/api/contracts/send" \
-H "Content-Type: application/json" \
-d '{
"title": "Master Services Agreement",
"recipient_name": "Jane Doe",
"recipient_email": "jane@example.com",
"markdown_content": "# Master Services Agreement\n\nThis agreement is entered into between Acme Corp and Jane Doe."
}'Frequently Asked Questions
How does async HTTP with httpx improve e-signature throughput in FastAPI applications?
In traditional synchronous Python frameworks like Flask or Django using the standard requests library, sending an e-signature request blocks the active OS thread while waiting for network round-trips to complete. Under high request volumes, this thread-blocking behavior quickly exhausts your web server worker pool, introducing severe latency bottlenecks and decreasing overall throughput. In contrast, FastAPI combined with httpx.AsyncClient leverages Python's asyncio event loop. When your application initiates an outgoing request to the Signbee E-Signature API, the event loop yields control back to other incoming client requests while waiting for the HTTP response headers and body. This non-blocking I/O model enables a single FastAPI worker to process thousands of concurrent contract dispatches per second with minimal memory footprint, making it the ideal choice for high-volume SaaS platforms, enterprise document automation pipelines, and AI agent execution environments.
How do I securely verify HMAC-SHA256 signatures on Signbee webhooks in FastAPI?
Securing webhook endpoints against forgery and man-in-the-middle attacks requires cryptographic signature verification. When Signbee sends a webhook event (such as contract.signed or contract.declined), it computes an HMAC-SHA256 signature using your shared webhook secret and sends it in the X-Signbee-Signature HTTP header. In FastAPI, your webhook route handler must access the raw unparsed request body using await request.body() before any JSON parsing takes place, as whitespace or key reordering can alter the raw bytes. You then compute an expected signature using Python's built-in hmac library and hashlib.sha256 digest function. Finally, compare the computed signature with the header value using hashlib.compare_digest(). Using compare_digest() is crucial because it performs constant-time string comparison, preventing timing-attack vulnerabilities where an attacker could deduce secret signature bytes by measuring nanosecond differences in response times.
Why should I store contract audit logs in PostgreSQL alongside e-signature webhooks?
Relying solely on external webhook notifications leaves your application vulnerable to missed state changes if network disruptions, server restarts, or unhandled exceptions occur during event delivery. By capturing and persisting immutable audit log records in PostgreSQL using an asynchronous ORM like SQLAlchemy or SQLModel, your application establishes a single source of truth for contract lifecycle state changes. Each incoming webhook payload—such as contract creation, recipient viewing, timestamped signatures, and legal certificate generation—is committed to a structured relational database with JSONB metadata and strict foreign key integrity. This historical event log allows your system to reconcile missed events during background recovery jobs, support audit trails for legal compliance (such as ESIGN, eIDAS, and HIPAA standards), and provide instantaneous real-time reporting to internal dashboards without querying external API endpoints repeatedly.
Ready to automate async Python e-signatures in FastAPI? Get started with 5 free documents/month.
Last updated: July 21, 2026 · Michael Beckett is the founder of Signbee and B2bee Ltd.