home / skills / jeremylongshore / claude-code-plugins-plus-skills / documenso-webhooks-events

This skill guides you to configure and handle Documenso webhooks and events for real-time document signing notifications.

npx playbooks add skill jeremylongshore/claude-code-plugins-plus-skills --skill documenso-webhooks-events

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
10.7 KB
---
name: documenso-webhooks-events
description: |
  Implement Documenso webhook configuration and event handling.
  Use when setting up webhook endpoints, handling document events,
  or implementing real-time notifications for document signing.
  Trigger with phrases like "documenso webhook", "documenso events",
  "document completed webhook", "signing notification".
allowed-tools: Read, Write, Edit, Bash(curl:*), Bash(ngrok:*)
version: 1.0.0
license: MIT
author: Jeremy Longshore <[email protected]>
---

# Documenso Webhooks & Events

## Overview
Configure and handle Documenso webhooks for real-time document signing notifications.

## Prerequisites
- Documenso team account (webhooks require teams)
- HTTPS endpoint for webhook reception
- Understanding of webhook security

## Supported Events

| Event | Trigger | Description |
|-------|---------|-------------|
| `document.created` | Document created | New document added to system |
| `document.sent` | Document sent | Document sent to recipients |
| `document.opened` | Document opened | Recipient opened document |
| `document.signed` | Recipient signed | One recipient completed signing |
| `document.completed` | All signed | All recipients have signed |
| `document.rejected` | Document rejected | Recipient rejected document |
| `document.cancelled` | Document cancelled | Document was cancelled |

## Webhook Setup

### Step 1: Create Webhook in Dashboard

1. Log into Documenso dashboard
2. Click avatar -> "Team settings"
3. Navigate to "Webhooks" tab
4. Click "Create Webhook"
5. Configure:
   - **URL**: Your HTTPS endpoint
   - **Events**: Select events to subscribe
   - **Secret**: Optional but recommended

### Step 2: Implement Webhook Endpoint

```typescript
import express from "express";
import crypto from "crypto";

const app = express();

// IMPORTANT: Use raw body parser for signature verification
app.use("/webhooks/documenso", express.raw({ type: "application/json" }));

interface DocumensoWebhookPayload {
  event:
    | "document.created"
    | "document.sent"
    | "document.opened"
    | "document.signed"
    | "document.completed"
    | "document.rejected"
    | "document.cancelled";
  payload: {
    id: string;
    title: string;
    status: string;
    createdAt: string;
    updatedAt: string;
    documentDataId: string;
    userId: string;
    teamId?: string;
    recipients: Array<{
      id: string;
      email: string;
      name: string;
      role: string;
      signingStatus: string;
      signedAt?: string;
    }>;
  };
  createdAt: string;
  webhookEndpoint: string;
}

app.post("/webhooks/documenso", async (req, res) => {
  // Step 1: Verify webhook secret
  const receivedSecret = req.headers["x-documenso-secret"] as string;
  const expectedSecret = process.env.DOCUMENSO_WEBHOOK_SECRET;

  if (expectedSecret && receivedSecret !== expectedSecret) {
    console.warn("Invalid webhook secret");
    return res.status(401).json({ error: "Invalid signature" });
  }

  // Step 2: Parse payload
  let payload: DocumensoWebhookPayload;
  try {
    payload = JSON.parse(req.body.toString());
  } catch (error) {
    console.error("Invalid JSON payload");
    return res.status(400).json({ error: "Invalid JSON" });
  }

  // Step 3: Log event
  console.log(`Webhook received: ${payload.event}`);
  console.log(`Document: ${payload.payload.id} - ${payload.payload.title}`);

  // Step 4: Handle event
  try {
    await handleWebhookEvent(payload);
    res.status(200).json({ received: true });
  } catch (error) {
    console.error("Webhook handling error:", error);
    res.status(500).json({ error: "Internal error" });
  }
});

async function handleWebhookEvent(
  webhook: DocumensoWebhookPayload
): Promise<void> {
  const { event, payload } = webhook;

  switch (event) {
    case "document.created":
      await onDocumentCreated(payload);
      break;
    case "document.sent":
      await onDocumentSent(payload);
      break;
    case "document.opened":
      await onDocumentOpened(payload);
      break;
    case "document.signed":
      await onDocumentSigned(payload);
      break;
    case "document.completed":
      await onDocumentCompleted(payload);
      break;
    case "document.rejected":
      await onDocumentRejected(payload);
      break;
    case "document.cancelled":
      await onDocumentCancelled(payload);
      break;
    default:
      console.log(`Unhandled event: ${event}`);
  }
}
```

### Step 3: Event Handlers

```typescript
async function onDocumentCreated(
  doc: DocumensoWebhookPayload["payload"]
): Promise<void> {
  console.log(`Document created: ${doc.title}`);

  // Track in your system
  await db.documents.create({
    externalId: doc.id,
    title: doc.title,
    status: "created",
    createdAt: new Date(doc.createdAt),
  });
}

async function onDocumentSent(
  doc: DocumensoWebhookPayload["payload"]
): Promise<void> {
  console.log(`Document sent: ${doc.title}`);

  // Update status
  await db.documents.update({
    where: { externalId: doc.id },
    data: { status: "sent", sentAt: new Date() },
  });

  // Notify internal users
  await notifications.send({
    channel: "slack",
    message: `Document "${doc.title}" sent to ${doc.recipients.length} recipients`,
  });
}

async function onDocumentOpened(
  doc: DocumensoWebhookPayload["payload"]
): Promise<void> {
  // Find who opened it
  const opener = doc.recipients.find(
    (r) => r.signingStatus === "NOT_SIGNED" // They opened but haven't signed
  );

  console.log(`Document opened by: ${opener?.email}`);

  // Track for analytics
  await analytics.track("document_opened", {
    documentId: doc.id,
    recipientEmail: opener?.email,
  });
}

async function onDocumentSigned(
  doc: DocumensoWebhookPayload["payload"]
): Promise<void> {
  // Find who signed
  const signer = doc.recipients.find((r) => r.signingStatus === "SIGNED");

  console.log(`Document signed by: ${signer?.email}`);

  // Update tracking
  await db.signatures.create({
    documentId: doc.id,
    recipientEmail: signer?.email,
    signedAt: signer?.signedAt ? new Date(signer.signedAt) : new Date(),
  });

  // Check if all have signed
  const allSigned = doc.recipients.every(
    (r) => r.role === "CC" || r.signingStatus === "SIGNED"
  );

  if (allSigned) {
    console.log("All recipients have signed!");
  }
}

async function onDocumentCompleted(
  doc: DocumensoWebhookPayload["payload"]
): Promise<void> {
  console.log(`Document completed: ${doc.title}`);

  // Update status
  await db.documents.update({
    where: { externalId: doc.id },
    data: { status: "completed", completedAt: new Date() },
  });

  // Download signed document
  const client = getDocumensoClient();
  const signedDoc = await client.documents.downloadV0({
    documentId: doc.id,
  });

  // Store signed copy
  await storage.upload(`signed/${doc.id}.pdf`, signedDoc);

  // Trigger downstream processes
  await workflows.trigger("document_completed", {
    documentId: doc.id,
    title: doc.title,
  });
}

async function onDocumentRejected(
  doc: DocumensoWebhookPayload["payload"]
): Promise<void> {
  const rejecter = doc.recipients.find((r) => r.signingStatus === "REJECTED");

  console.log(`Document rejected by: ${rejecter?.email}`);

  // Update status
  await db.documents.update({
    where: { externalId: doc.id },
    data: { status: "rejected", rejectedBy: rejecter?.email },
  });

  // Alert team
  await notifications.send({
    channel: "slack",
    priority: "high",
    message: `Document "${doc.title}" rejected by ${rejecter?.email}`,
  });
}

async function onDocumentCancelled(
  doc: DocumensoWebhookPayload["payload"]
): Promise<void> {
  console.log(`Document cancelled: ${doc.title}`);

  // Update status
  await db.documents.update({
    where: { externalId: doc.id },
    data: { status: "cancelled", cancelledAt: new Date() },
  });
}
```

### Step 4: Idempotency

```typescript
// Prevent duplicate processing
const processedWebhooks = new Set<string>();

async function handleWebhookIdempotent(
  webhook: DocumensoWebhookPayload
): Promise<boolean> {
  // Create unique key from event + document + timestamp
  const key = `${webhook.event}:${webhook.payload.id}:${webhook.createdAt}`;

  // Check if already processed
  if (processedWebhooks.has(key)) {
    console.log(`Duplicate webhook ignored: ${key}`);
    return false;
  }

  // Mark as processed
  processedWebhooks.add(key);

  // Cleanup old entries (keep last 10000)
  if (processedWebhooks.size > 10000) {
    const oldest = processedWebhooks.values().next().value;
    processedWebhooks.delete(oldest);
  }

  // Process webhook
  await handleWebhookEvent(webhook);
  return true;
}

// For production, use Redis or database
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

async function handleWebhookIdempotentRedis(
  webhook: DocumensoWebhookPayload
): Promise<boolean> {
  const key = `webhook:${webhook.event}:${webhook.payload.id}:${webhook.createdAt}`;

  // Try to set key with 7 day expiry
  const result = await redis.set(key, "1", "EX", 604800, "NX");

  if (!result) {
    console.log(`Duplicate webhook: ${key}`);
    return false;
  }

  await handleWebhookEvent(webhook);
  return true;
}
```

## Local Development

```bash
# Start ngrok tunnel
ngrok http 3000

# Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
# Configure this URL in Documenso webhook settings
# Your endpoint: https://abc123.ngrok.io/webhooks/documenso
```

## Testing Webhooks

```bash
# Test webhook endpoint locally
curl -X POST http://localhost:3000/webhooks/documenso \
  -H "Content-Type: application/json" \
  -H "X-Documenso-Secret: your-secret" \
  -d '{
    "event": "document.completed",
    "payload": {
      "id": "doc_test123",
      "title": "Test Document",
      "status": "COMPLETED",
      "createdAt": "2024-01-01T00:00:00Z",
      "updatedAt": "2024-01-01T01:00:00Z",
      "recipients": [
        {
          "id": "rec_123",
          "email": "[email protected]",
          "name": "Test Signer",
          "role": "SIGNER",
          "signingStatus": "SIGNED"
        }
      ]
    },
    "createdAt": "2024-01-01T01:00:00Z",
    "webhookEndpoint": "https://yourapp.com/webhooks/documenso"
  }'
```

## Output
- Webhook endpoint configured
- All events handled
- Idempotency implemented
- Local testing ready

## Error Handling
| Issue | Cause | Solution |
|-------|-------|----------|
| 401 Unauthorized | Wrong secret | Check webhook secret |
| Webhook not received | URL not HTTPS | Use HTTPS endpoint |
| Duplicate processing | No idempotency | Add deduplication |
| Timeout | Slow handler | Use async queue |

## Resources
- [Documenso Webhooks](https://docs.documenso.com/developers/webhooks)
- [ngrok Documentation](https://ngrok.com/docs)
- [Webhook Best Practices](https://webhooks.fyi/)

## Next Steps
For performance optimization, see `documenso-performance-tuning`.

Overview

This skill implements Documenso webhook configuration and event handling for real-time document signing workflows. It provides a secure webhook endpoint pattern, per-event handlers, idempotency strategies, and local testing guidance. Use it to receive and process document lifecycle events reliably and trigger downstream automation.

How this skill works

The skill shows how to register a webhook in the Documenso team settings and implement an HTTPS endpoint that verifies a webhook secret and parses raw JSON payloads. It maps supported events (created, sent, opened, signed, completed, rejected, cancelled) to dedicated handlers that update databases, notify teams, download signed assets, and trigger workflows. It also includes idempotency patterns using in-memory sets or Redis and local testing instructions with ngrok and curl.

When to use it

  • Setting up real-time notifications for document signing and status changes
  • Integrating Documenso events into CRM, ERP, or internal dashboards
  • Automating downstream work after a document is completed (download, store, kickoff workflows)
  • Preventing duplicate processing of webhook events in high-throughput systems
  • Developing locally with secure public tunnels (ngrok) for webhook testing

Best practices

  • Require HTTPS and verify webhook secret header to prevent spoofing
  • Use raw body parsing to ensure signature verification is reliable
  • Implement idempotency (Redis or database-backed keys) to avoid duplicate processing
  • Process heavy or slow work asynchronously via queues to avoid webhook timeouts
  • Log events and failures with enough context (event, document id, timestamps) and alert on repeated errors

Example use cases

  • Update internal document records when a document is created or sent
  • Send Slack or email alerts when a document is rejected or cancelled
  • Download and store signed PDFs when documents complete, then trigger billing or fulfillment workflows
  • Track recipient behavior (opened, signed) for analytics and follow-ups
  • Use Redis-backed deduplication for reliable production webhook processing

FAQ

How do I verify Documenso webhooks?

Compare the X-Documenso-Secret header with your stored secret. Use raw request body parsing before JSON parsing so verification matches the original payload.

What if my webhook handler is slow and times out?

Acknowledge the webhook quickly (200) and enqueue heavy work for background workers. Use retries and idempotent processing for the queued jobs.

How do I prevent processing the same event twice?

Create a unique key (event:documentId:createdAt) and store it with an expiry in Redis or a database using a set-if-not-exists operation to ensure single processing.