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.

Michael Beckett
Michael Beckett

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:

1.You register an HTTPS endpoint URL in the Signbee developer dashboard or programmatically.
2.An event occurs — e.g., a customer reviews a contract, signs a document, or declines to sign.
3.The provider packages the event into a JSON payload and dispatches an HTTP POST request to your URL.
4.Your server validates the signature, puts the task in a queue, and responds with a 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:

EventWhen it firesCommon action
document.sentDocument is dispatched to recipientLog audit trail in CRM
document.viewedRecipient opens the signing linkNotify account manager or sales team
document.signedAll parties complete signingTrigger payment, sync ERP, provision user
document.declinedRecipient explicitly rejects documentRe-route to sales, alert operations
document.expiredDocument deadline passes unsignedMark 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 OK within 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:

Webhook Retry & Backoff Pipeline Flow

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

Node.js & BullMQ Queue Worker Integration
// 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:

Python Celery & Redis Task Architecture
# 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_webhooks database table. When your queue worker processes an event, insert the event_id inside 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 the SET NX command 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 ControlThreat Mitigated
HTTPS Only EnforcementMan-in-the-Middle (MitM) payload sniffing
IP Address WhitelistingMalicious external traffic spoofing
Timestamp Expiry DriftReplay attacks (attacker re-sending old signed payloads)
Dedicated Subnet Worker isolationWorker 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.

Related resources