May 2, 2026 · Developer Guide
E-Signature API Webhooks: Get Notified the Instant a Document Is Signed
Don't poll. Don't check. Get a webhook the moment a document is signed, viewed, or declined — and trigger your next workflow automatically.
Founder, Signbee
TL;DR
Webhooks replace polling. Instead of checking document status every 30 seconds, you receive an asynchronous POST request the instant something happens. Register a secure URL, verify the cryptographic signature, and immediately trigger downstream workflows. Works seamlessly with any app that sends documents for signing.
How e-signature webhooks work
Traditional API architectures require client applications to constantly query an external server to detect state changes. This anti-pattern, known as polling, introduces significant latency, wastes database resources, and consumes precious API rate-limiting budgets (which are tightly managed across top providers, as we covered in our Rate Limits Guide).
Webhooks reverse this paradigm by adopting an event-driven architecture. Instead of your server asking the e-signature API for updates, the API notifies your server by sending an HTTP POST request containing details of the event. The lifecycle follows a simple, repeatable flow:
200 OK status.Common webhook events
Depending on the complex workflows of your application, you may want to subscribe to different event namespaces. For maximum throughput and to prevent ingestion bottlenecks, only listen to events that trigger specific downstream actions. Below are the standard event types you will encounter:
| Event | When it fires | Common action |
|---|---|---|
| document.sent | Document is dispatched to recipient | Log audit trail in CRM |
| document.viewed | Recipient opens the signing link | Notify account manager or sales team |
| document.signed | All parties complete signing | Trigger payment, sync ERP, provision user |
| document.declined | Recipient explicitly rejects document | Re-route to sales, alert operations |
| document.expired | Document deadline passes unsigned | Mark transaction failed, request renewal |
The Concurrency Challenge: Why Direct Processing Fails
A common mistake when starting out with webhooks is executing heavy processing logic directly inside the webhook HTTP request-response cycle. For example, when a document.signed event is received, your endpoint might attempt to fetch the completed PDF, parse signature certificates, run heavy database updates, make calls to Salesforce/HubSpot, and generate transactional emails.
This approach introduces two significant points of failure:
- HTTP Timeouts: E-signature providers place strict constraints on webhook response times. If your server does not respond with a
200 OKwithin 3 to 5 seconds, the provider cuts the connection. Vercel Serverless Functions have a 10-second default execution ceiling, while Nginx or cloud load balancers may kill connections rapidly. If a timeout occurs, the provider assumes delivery failed and automatically schedules a retry, generating duplicate task runs and worsening system resource contention. - Concurrency Spikes & Database Locks: During bulk signing campaigns or system synchronization cycles, your webhook receiver can be flooded with hundreds of concurrent POST requests. Running synchronous queries directly on the receipt threads can lock rows in PostgreSQL or MySQL, exhausting database pool connections and causing complete outage.
Queue-Based Event Processing Architecture
To build a robust integration, you must decouple webhook ingestion from event processing. The ingestion server should perform minimal validation and hand the event off to an asynchronous message broker, immediately replying with a successful status to the sender. This ensures your public-facing gateway remains responsive and highly available regardless of downstream processing speed.
A resilient pipeline operates in three distinct phases: Ingestion, Worker Execution, and Backoff Routing. The following diagram illustrates the lifecycle of a webhook event passing through a decoupled worker queue system:
[ Incoming Webhook Event ]
│
▼
┌───────────────────────┐
│ Signature Valid? │
└───────────┬───────────┘
├───────────────── No ──────────────► [ 401 Unauthorized / Drop ]
│
Yes
│
▼
┌───────────────────────┐
│ Ingest & Ack (200 OK) │
└───────────┬───────────┘
│
▼
┌───────────────────────┐
│ Push to Message Queue │◄────────────────────────────────┐
└───────────┬───────────┘ │
│ │
▼ │
┌───────────────────────┐ │
│ BullMQ / Celery │ │
│ Worker Pulls │ │
└───────────┬───────────┘ │
│ │
Worker Processes │
│ │
Did it succeed? │
/ \ │
Yes No │
/ \ │
▼ ▼ │
[ Success / Ack ] [ Max Retries Reached? ] │
/ \ │
Yes No │
/ \ │
▼ ▼ │
[ Dead Letter Queue ] [ Calculate Backoff ] │
[ (DLQ) for Alerts ] [ (Exponential + Jitter) ]│
│ │
└─────────────────┘
In this architecture:
- Ingestion Queue: Inbound jobs wait for execution in a fast memory-store (e.g., Redis).
- Worker Pool: Concurrency-capped background worker processes pull jobs one-by-one. If downstream services are down, worker pipelines are throttled without dropping payloads.
- Retry Queue with Exponential Backoff: Failed events are scheduled with increasing delays delay = base * (2 ^ attempt) to prevent hammering downstream resources. Jitter adds random noise, avoiding synchronized retry spikes.
- Dead Letter Queue (DLQ): If all retries fail, the job goes to the DLQ. Engineers inspect the payload, fix bugs, and rerun the job manually.
Verifying Webhook Signatures: HMAC SHA-256
Because webhook handlers are public URLs, they are vulnerable to spoofing attacks. Anyone can construct a fake JSON object simulating a successful signature and POST it to your endpoint. To mitigate this risk, you must verify the signature of every payload using HMAC SHA-256.
Signbee uses standard webhook formats conforming to the Svix Standard Webhooks specification. This ensures that every payload is cryptographically signed using a shared secret key only known to you and Signbee. The headers sent with each request are:
X-Signbee-Signature: The cryptographic signature computed from the secret, event ID, and timestamp.X-Signbee-Timestamp: The UNIX timestamp of the transmission (used to prevent replay attacks by rejecting payloads older than 5 minutes).X-Signbee-Event-Id: A unique identifier for the event.
Implementing a Node.js BullMQ Worker with HMAC Verification
For Node.js environments, BullMQ provides a robust Redis-backed queue system. The code below demonstrates a complete Express ingestion controller that verifies signatures using standard cryptographic primitives and delegates database synchronization to a background worker with automated retry configuration:
// worker.ts
import { Queue, Worker, Job } from 'bullmq';
import { Webhook } from 'standard-webhooks';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const WEBHOOK_SECRET = process.env.SIGNBEE_WEBHOOK_SECRET || 'whsec_your_secret';
// Initialize Redis-backed queue with exponential retry backoff
export const webhookQueue = new Queue('webhook-processing', {
connection: { host: '127.0.0.1', port: 6379 },
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000, // Retries in 2s, 4s, 8s, 16s, 32s
},
removeOnComplete: true,
},
});
// 1. Ingestion Endpoint logic (Express / Next.js API Route)
export async function handleIncomingWebhook(reqBody: string, headers: Record<string, string>) {
const signature = headers['x-signbee-signature'];
const timestamp = headers['x-signbee-timestamp'];
const eventId = headers['x-signbee-event-id'];
if (!signature || !timestamp || !eventId) {
throw new Error('Missing webhook verification headers');
}
// Validate the signature using the svix standard webhook specification
const wh = new Webhook(WEBHOOK_SECRET);
try {
wh.verify(reqBody, {
'webhook-id': eventId,
'webhook-signature': signature,
'webhook-timestamp': timestamp,
});
} catch (err) {
throw new Error('Signature verification failed: unauthorized payload');
}
// Enqueue job into BullMQ immediately for async processing
const payload = JSON.parse(reqBody);
await webhookQueue.add(
`event-${eventId}`,
{ eventId, payload },
{ jobId: eventId } // Redis job deduplication helps prevent race conditions
);
}
// 2. Concurrency-limited background worker
const worker = new Worker('webhook-processing', async (job: Job) => {
const { eventId, payload } = job.data;
const { event, document_id, signer_email, signed_at } = payload;
console.log(`[Worker] Processing event: ${event} | ID: ${eventId}`);
// Ensure strict idempotency at database level
const existingEvent = await prisma.processedWebhook.findUnique({
where: { id: eventId }
});
if (existingEvent) {
console.log(`[Worker] Duplicate event detected (ID: ${eventId}). Skipping processing.`);
return;
}
// Execute updates inside an atomic database transaction
await prisma.$transaction(async (tx) => {
// Record event as processed
await tx.processedWebhook.create({
data: { id: eventId, eventType: event }
});
if (event === 'document.signed') {
// Sync document signing state
await tx.contract.update({
where: { documentId: document_id },
data: {
status: 'SIGNED',
signedAt: signed_at ? new Date(signed_at) : new Date(),
signerEmail: signer_email
}
});
// Perform downstream workflows (e.g. provisioning access)
await triggerProvisioningWorkflow(signer_email, document_id);
}
});
}, {
connection: { host: '127.0.0.1', port: 6379 },
limiter: {
max: 15, // Process maximum of 15 jobs per second to prevent DB pool exhaustion
duration: 1000,
}
});
async function triggerProvisioningWorkflow(email: string, docId: string) {
// downstream integration code...
}
worker.on('failed', (job, err) => {
console.error(`Job ${job?.id} failed ultimately: ${err.message}`);
});Implementing a Python Celery Task with Redis Broker
Python web projects (FastAPI, Django, Flask) frequently handle high-throughput workloads using Celery paired with Redis. Celery allows for elegant task isolation, allowing web routers to return 202 Accepted headers immediately. Below is the Python task definition that enforces database transactional updates, handles signature verification, and leverages Celery's built-in retry backoff options with random jitter noise:
# tasks.py
import os
import logging
from celery import Celery
from standardwebhooks.webhooks import Webhook
from db_connection import get_db_pool # Mock connection utility
logger = logging.getLogger(__name__)
# Configure Celery to use Redis as a broker and status backend
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
app = Celery("signbee_tasks", broker=REDIS_URL, backend=REDIS_URL)
app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="UTC",
enable_utc=True,
task_acks_late=True, # Acknowledge only after processing completes
task_reject_on_worker_lost=True,
)
SIGNBEE_WEBHOOK_SECRET = os.getenv("SIGNBEE_WEBHOOK_SECRET", "whsec_your_secret")
@app.task(
bind=True,
max_retries=5,
default_retry_delay=2, # Initial retry wait of 2s
autoretry_for=(Exception,), # Automatically retry on database errors/timeouts
retry_backoff=True, # Exponential delays: 2s, 4s, 8s, 16s...
retry_backoff_max=300, # Cap retry spacing to 5 minutes
retry_jitter=True # Avoid thundering herd with randomized offset
)
def process_webhook_task(self, event_id, payload):
logger.info(f"Processing webhook task {event_id} (Attempt {self.request.retries + 1})")
event_type = payload.get("event")
document_id = payload.get("document_id")
signer_email = payload.get("signer_email")
signed_at = payload.get("signed_at")
db_pool = get_db_pool()
with db_pool.getconn() as conn:
with conn.cursor() as cur:
# 1. Idempotency Check: search if event_id exists
cur.execute("SELECT id FROM processed_webhooks WHERE id = %s;", (event_id,))
if cur.fetchone():
logger.info(f"Event {event_id} already processed. Aborting task safely.")
return True
# 2. Transactional Update: update db state & log transaction
if event_type == "document.signed":
cur.execute(
"""
UPDATE client_contracts
SET status = 'SIGNED', signed_at = %s, signer = %s
WHERE document_id = %s;
""",
(signed_at, signer_email, document_id)
)
# Mark as processed in the same database transaction
cur.execute(
"INSERT INTO processed_webhooks (id, event_type) VALUES (%s, %s);",
(event_id, event_type)
)
conn.commit()
logger.info(f"Contract database sync successful for document {document_id}")
# Schedule downstream workflow task asynchronously
trigger_post_signing_actions.delay(document_id, signer_email)
return True
@app.task
def trigger_post_signing_actions(document_id, email):
# Triggers emails, ERP updates, sales notification CRM pipelines
logger.info(f"Executing downstream automation for contract {document_id} signed by {email}")
Achieving Idempotency & Avoiding Duplicate Processing
In distributed networks, communication between systems is inherently unreliable. To ensure critical documents are never missed, e-signature engines deploy webhooks using **at-least-once delivery**. Under this scheme, Signbee will keep resending the notification if the network drops before receiving your response, or if your ingestion server replies slower than the threshold limit.
Consequently, your worker handlers will periodically ingest the same event payload multiple times. Failing to account for duplicates can cause critical runtime errors, such as billing customers twice, dispatching redundant fulfillment emails, or violating database state rules.
To implement strict **at-most-once processing** (effectively resulting in Exactly-Once behavior at the application layer), follow these standard patterns:
- Database Unique Constraints: Maintain a
processed_webhooksdatabase table. When your queue worker processes an event, insert theevent_idinside the same transaction block as the contract state modification. If the database engine encounters a duplicate ID, a unique constraint constraint violation will throw, causing the transaction to abort safely without corrupting data. - Redis Distributed Locks: Set a temporary lock key in Redis (e.g.,
lock:event_id) using theSET NXcommand with a time-to-live (TTL) of 10 minutes. If the key is already set, immediately return success, aborting redundant execution threads before they hit database connection pools.
Webhook Security & Networking Best Practices
Securing public-facing HTTP callback endpoints requires a multi-layered defense strategy. Beyond validating HMAC signatures, you should apply the following perimeter security controls in production environments:
| Security Control | Threat Mitigated |
|---|---|
| HTTPS Only Enforcement | Man-in-the-Middle (MitM) payload sniffing |
| IP Address Whitelisting | Malicious external traffic spoofing |
| Timestamp Expiry Drift | Replay attacks (attacker re-sending old signed payloads) |
| Dedicated Subnet Worker isolation | Worker exposure & lateral network movement |
Frequently Asked Questions
Why is signature verification critical, and how is HMAC SHA-256 implemented?
E-signature webhooks are public HTTP endpoints that anyone can send POST requests to. If signature verification is omitted, attackers can spoof webhook payloads (e.g., sending a fake document.signed event to trigger shipping or release funds without actual signatures). Standard HMAC SHA-256 verification (such as the Svix Standard Webhooks specification used by Signbee) prevents this. The signature is computed using a shared webhook secret, the timestamp, and the raw payload body. When a webhook arrives, the receiver computes the same HMAC SHA-256 signature and compares it with the X-Signbee-Signature header. If they match, the payload is authentic and untampered.
How does queue-based event processing solve webhook concurrency and reliability challenges?
Webhook endpoints must respond quickly (ideally with a 200 OK status in under 5 seconds) to avoid timeouts. Processing intensive tasks (such as parsing large PDF documents, database updates, syncing with Salesforce/HubSpot, or sending automated emails) directly within the webhook request loop leads to timeouts, connection drops, and missed events. Queue-based event processing decouples ingestion from execution. The webhook endpoint only performs quick signature verification and publishes the raw payload to a message queue or broker (like Redis, RabbitMQ, or Amazon SQS). A separate pool of background workers (e.g., BullMQ in Node.js or Celery in Python) consumes these tasks asynchronously. This ensures that even during traffic spikes, the webhook handler remains fast and responsive, and any downstream database locks or API rate limits do not block the incoming event flow.
What retry strategies and backoff algorithms should be implemented for webhooks to handle downstream failures?
Webhook systems must be resilient to transient network partitions, database deadlocks, and third-party API rate limiting. Standard retry strategies employ exponential backoff with jitter to prevent a thundering herd problem, where failed tasks are retried simultaneously and overwhelm downstream services. For instance, rather than retrying every 5 seconds, backoff delays increase exponentially (e.g., 1s, 2s, 4s, 8s, up to 15m) combined with a small randomized jitter offset. BullMQ and Celery natively support this via built-in retry settings. If a task exceeds its maximum retry threshold (typically 5 to 10 attempts), it must be moved to a Dead Letter Queue (DLQ). The DLQ acts as a holding pen for failed events, allowing developers to alert, inspect, debug, and manually replay the payload once the underlying system recovers, ensuring no signed document event is permanently lost.
Real-time signing webhooks — 5 free docs/month.
Last updated: May 29, 2026 · Michael Beckett is the founder of Signbee and B2bee Ltd.