Skip to main content
Webhook security ensures that HTTP requests to your endpoint actually originate from Documenso and haven’t been tampered with.

How Documenso secures webhooks

Documenso includes a secret verification mechanism:
  1. You configure a secret when creating a webhook
  2. Documenso includes this secret in the X-Documenso-Secret header
  3. Your endpoint verifies the secret matches your stored value
  4. Requests with invalid or missing secrets are rejected
Always verify the webhook secret before processing events. Unverified webhooks could be spoofed by attackers.

Setting up webhook secrets

When creating a webhook, include a strong secret:
const webhook = await createWebhook({
  webhookUrl: 'https://your-app.com/webhooks/documenso',
  secret: 'your-strong-random-secret-here',
  eventTriggers: ['DOCUMENT_COMPLETED'],
  enabled: true
});
Generate cryptographically secure secrets:
# Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# Python
python -c "import secrets; print(secrets.token_hex(32))"

# OpenSSL
openssl rand -hex 32

Webhook request headers

Every webhook request from Documenso includes:
POST /webhooks/documenso HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-Documenso-Secret: your-secret-key
User-Agent: Documenso-Webhooks

{...webhook payload...}
The X-Documenso-Secret header contains the secret you configured.

Verifying webhook requests

Implement secret verification in your webhook endpoint:
const express = require('express');
const crypto = require('crypto');
const app = express();

// Store your webhook secret securely
const WEBHOOK_SECRET = process.env.DOCUMENSO_WEBHOOK_SECRET;

if (!WEBHOOK_SECRET) {
  throw new Error('DOCUMENSO_WEBHOOK_SECRET environment variable is required');
}

app.post('/webhooks/documenso', express.json(), (req, res) => {
  // Extract the secret from headers
  const receivedSecret = req.headers['x-documenso-secret'];
  
  // Verify the secret
  if (!receivedSecret || receivedSecret !== WEBHOOK_SECRET) {
    console.error('Invalid webhook secret');
    return res.status(401).json({ error: 'Unauthorized' });
  }
  
  // Secret is valid, process the webhook
  const { event, payload } = req.body;
  console.log(`Verified webhook: ${event} for document ${payload.id}`);
  
  // Process the event...
  
  res.status(200).json({ received: true });
});

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

Constant-time comparison

When comparing secrets, use constant-time comparison to prevent timing attacks:
const crypto = require('crypto');

function verifySecret(receivedSecret, expectedSecret) {
  // Convert strings to buffers for constant-time comparison
  const receivedBuffer = Buffer.from(receivedSecret || '', 'utf8');
  const expectedBuffer = Buffer.from(expectedSecret, 'utf8');
  
  // Ensure both buffers are the same length
  if (receivedBuffer.length !== expectedBuffer.length) {
    return false;
  }
  
  // Use crypto.timingSafeEqual for constant-time comparison
  return crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}

// Usage
const isValid = verifySecret(
  req.headers['x-documenso-secret'],
  process.env.DOCUMENSO_WEBHOOK_SECRET
);
Never use simple string comparison (=== or ==) for secrets. Use constant-time comparison functions to prevent timing attacks.

Security best practices

Never hardcode webhook secrets in your source code:
// ❌ Don't do this
const SECRET = 'my-webhook-secret';

// ✅ Do this
const SECRET = process.env.DOCUMENSO_WEBHOOK_SECRET;
Store secrets in:
  • Environment variables
  • Secret management services (AWS Secrets Manager, HashiCorp Vault)
  • Encrypted configuration files
Always use HTTPS for webhook URLs in production:
// ❌ Insecure
webhookUrl: 'http://your-app.com/webhooks'

// ✅ Secure
webhookUrl: 'https://your-app.com/webhooks'
HTTP webhooks expose the secret in transit and can be intercepted.
Change webhook secrets regularly to limit exposure:
// Update webhook with new secret
await updateWebhook({
  id: webhookId,
  secret: generateNewSecret(),
});
Implement a rotation schedule (e.g., every 90 days).
Track webhook verification failures:
if (!verifySecret(receivedSecret, expectedSecret)) {
  console.warn({
    event: 'webhook_verification_failed',
    timestamp: new Date().toISOString(),
    ip: req.ip,
    path: req.path
  });
  return res.status(401).json({ error: 'Unauthorized' });
}
Monitor for suspicious patterns that might indicate attacks.
Protect webhook endpoints from abuse:
const rateLimit = require('express-rate-limit');

const webhookLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per window
  message: 'Too many webhook requests'
});

app.post('/webhooks/documenso', webhookLimiter, handleWebhook);
Verify the webhook payload format:
const { z } = require('zod');

const WebhookSchema = z.object({
  event: z.string(),
  payload: z.object({
    id: z.number(),
    title: z.string(),
    status: z.string(),
    // ... other fields
  }),
  createdAt: z.string(),
  webhookEndpoint: z.string()
});

app.post('/webhooks/documenso', (req, res) => {
  // Verify secret first
  if (!verifySecret(req.headers['x-documenso-secret'])) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  
  // Validate payload structure
  try {
    const webhook = WebhookSchema.parse(req.body);
    // Process valid webhook...
  } catch (error) {
    console.error('Invalid webhook payload:', error);
    return res.status(400).json({ error: 'Invalid payload' });
  }
});
For additional security, restrict webhooks to Documenso IPs:
const ALLOWED_IPS = [
  // Add Documenso webhook IP ranges
  // Contact support for current IP list
];

app.post('/webhooks/documenso', (req, res, next) => {
  const clientIp = req.ip || req.connection.remoteAddress;
  
  if (!ALLOWED_IPS.includes(clientIp)) {
    console.warn(`Rejected webhook from unauthorized IP: ${clientIp}`);
    return res.status(403).json({ error: 'Forbidden' });
  }
  
  next();
});
IP allowlisting requires keeping the IP list updated and may not work with cloud hosting that uses dynamic IPs.

Webhook security checklist

Before deploying webhooks to production:
  • Use HTTPS endpoint URL
  • Store secret in environment variables
  • Implement constant-time secret comparison
  • Validate webhook payload structure
  • Log verification failures
  • Implement rate limiting
  • Set up monitoring and alerts
  • Test with invalid secrets
  • Document secret rotation procedure
  • Review error handling

Testing security

Test your webhook security implementation:
curl -X POST https://your-app.com/webhooks/documenso \
  -H "Content-Type: application/json" \
  -H "X-Documenso-Secret: your-secret-key" \
  -d '{
    "event": "DOCUMENT_COMPLETED",
    "payload": {
      "id": 123,
      "title": "test.pdf",
      "status": "COMPLETED"
    },
    "createdAt": "2024-03-15T10:30:00.000Z",
    "webhookEndpoint": "https://your-app.com/webhooks/documenso"
  }'

# Should return: {"received":true}

Troubleshooting

Possible causes:
  • Secret mismatch between Documenso and your endpoint
  • Secret not properly loaded from environment variables
  • Header name case sensitivity (use lowercase header access)
Solutions:
// Check secret is loaded
console.log('Secret configured:', !!process.env.DOCUMENSO_WEBHOOK_SECRET);

// Log received secret (remove in production)
console.log('Received secret:', req.headers['x-documenso-secret']);

// Verify header access is case-insensitive
const secret = req.headers['x-documenso-secret'] || 
               req.headers['X-Documenso-Secret'];
Possible causes:
  • Environment variable not set in production
  • Different secret configured in production vs. development
  • Reverse proxy modifying headers
Solutions:
  • Verify environment variables in production deployment
  • Check reverse proxy (nginx, Apache) header forwarding
  • Use webhook logs to inspect received headers
Issue: Simple string comparison can leak secret length through timing.Solution: Always use constant-time comparison:
// ❌ Vulnerable to timing attacks
if (receivedSecret === expectedSecret) { ... }

// ✅ Constant-time comparison
if (crypto.timingSafeEqual(
  Buffer.from(receivedSecret),
  Buffer.from(expectedSecret)
)) { ... }

Additional resources

Webhook setup

Configure webhook endpoints

Event reference

View all available events