documenso-webhooks-events

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Documenso Webhooks & Events

Documenso Webhooks与事件

Overview

概述

Configure and handle Documenso webhooks for real-time document signing notifications.
配置并处理Documenso webhook,以实现文档签署的实时通知。

Prerequisites

前置条件

  • Documenso team account (webhooks require teams)
  • HTTPS endpoint for webhook reception
  • Understanding of webhook security
  • Documenso团队账号(webhook功能仅对团队账号开放)
  • 用于接收webhook的HTTPS端点
  • 了解webhook安全相关知识

Supported Events

支持的事件

EventTriggerDescription
document.created
Document createdNew document added to system
document.sent
Document sentDocument sent to recipients
document.opened
Document openedRecipient opened document
document.signed
Recipient signedOne recipient completed signing
document.completed
All signedAll recipients have signed
document.rejected
Document rejectedRecipient rejected document
document.cancelled
Document cancelledDocument was cancelled
事件触发条件描述
document.created
文档创建系统中新增了文档
document.sent
文档发送文档已发送给收件人
document.opened
文档打开收件人打开了文档
document.signed
收件人签署一位收件人完成签署
document.completed
全部签署所有收件人都已签署
document.rejected
文档被拒绝收件人拒绝了文档
document.cancelled
文档被取消文档已被取消

Webhook Setup

Webhook设置

Step 1: Create Webhook in Dashboard

步骤1:在控制台中创建Webhook

  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
  1. 登录Documenso控制台
  2. 点击头像 ->「团队设置」
  3. 导航至「Webhooks」标签页
  4. 点击「创建Webhook」
  5. 配置以下内容:
    • URL:你的HTTPS端点
    • 事件:选择要订阅的事件
    • 密钥:可选但建议设置

Step 2: Implement Webhook Endpoint

步骤2:实现Webhook端点

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}`);
  }
}
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

步骤3:事件处理器

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() },
  });
}
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

步骤4:幂等性处理

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;
}
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
undefined
bash
undefined

Start ngrok tunnel

Start ngrok tunnel

ngrok http 3000
ngrok http 3000

Copy the HTTPS URL (e.g., https://abc123.ngrok.io)

Copy the HTTPS URL (e.g., https://abc123.ngrok.io)

Configure this URL in Documenso webhook settings

Configure this URL in Documenso webhook settings

undefined
undefined

Testing Webhooks

Webhook测试

bash
undefined
bash
undefined

Test webhook endpoint locally

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": "signer@example.com", "name": "Test Signer", "role": "SIGNER", "signingStatus": "SIGNED" } ] }, "createdAt": "2024-01-01T01:00:00Z", "webhookEndpoint": "https://yourapp.com/webhooks/documenso" }'
undefined
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": "signer@example.com", "name": "Test Signer", "role": "SIGNER", "signingStatus": "SIGNED" } ] }, "createdAt": "2024-01-01T01:00:00Z", "webhookEndpoint": "https://yourapp.com/webhooks/documenso" }'
undefined

Output

输出结果

  • Webhook endpoint configured
  • All events handled
  • Idempotency implemented
  • Local testing ready
  • Webhook端点已配置完成
  • 所有事件均已处理
  • 实现了幂等性
  • 可进行本地测试

Error Handling

错误处理

IssueCauseSolution
401 UnauthorizedWrong secretCheck webhook secret
Webhook not receivedURL not HTTPSUse HTTPS endpoint
Duplicate processingNo idempotencyAdd deduplication
TimeoutSlow handlerUse async queue
问题原因解决方案
401 未授权密钥错误检查webhook密钥
未收到WebhookURL非HTTPS使用HTTPS端点
重复处理未实现幂等性添加去重逻辑
超时处理器响应过慢使用异步队列

Resources

参考资源

Next Steps

下一步

For performance optimization, see
documenso-performance-tuning
.
如需性能优化,请查看「documenso-performance-tuning」。