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:
- You configure a secret when creating a webhook
- Documenso includes this secret in the
X-Documenso-Secret header
- Your endpoint verifies the secret matches your stored value
- 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
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
Use environment variables for secrets
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. Rotate secrets periodically
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);
Validate payload structure
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' });
}
});
Implement IP allowlisting (optional)
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:
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
Webhooks failing with 401 Unauthorized
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'];
Secret verification works locally but fails in production
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