mirror of
https://github.com/documenso/documenso.git
synced 2026-07-09 20:45:00 +10:00
297 lines
8.8 KiB
Plaintext
297 lines
8.8 KiB
Plaintext
---
|
|
title: Webhook Verification
|
|
description: Verify webhook signatures for security.
|
|
---
|
|
|
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|
import { Callout } from 'fumadocs-ui/components/callout';
|
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
|
|
|
## Overview
|
|
|
|
Verifying webhook requests ensures that incoming payloads originate from Documenso and have not been tampered with. Without verification, attackers could forge requests to your endpoint and trigger unintended actions.
|
|
|
|
## How Documenso Signs Webhooks
|
|
|
|
When you configure a webhook with a secret, Documenso includes that secret in every webhook request via the `X-Documenso-Secret` header. Your server should compare this header value against your stored secret to authenticate the request.
|
|
|
|
```bash
|
|
POST /your-webhook-endpoint HTTP/1.1
|
|
Host: your-server.com
|
|
Content-Type: application/json
|
|
X-Documenso-Secret: your_webhook_secret_here
|
|
|
|
{"event": "DOCUMENT_COMPLETED", "payload": {...}}
|
|
```
|
|
|
|
## Signature Header Format
|
|
|
|
| Header | Description |
|
|
| -------------------- | ------------------------------------------------------- |
|
|
| `X-Documenso-Secret` | The secret key you configured when creating the webhook |
|
|
|
|
The header contains your webhook secret as a plain string. If you did not configure a secret, the header will be an empty string.
|
|
|
|
## Verification Steps
|
|
|
|
{/* prettier-ignore */}
|
|
<Steps>
|
|
<Step>
|
|
Extract the `X-Documenso-Secret` header from the incoming request
|
|
</Step>
|
|
<Step>
|
|
Compare it against your stored webhook secret using a constant-time comparison
|
|
</Step>
|
|
<Step>
|
|
Reject the request if the values do not match
|
|
</Step>
|
|
<Step>
|
|
Process the webhook payload if verification succeeds
|
|
</Step>
|
|
</Steps>
|
|
|
|
<Callout type="warn">
|
|
Always use constant-time string comparison to prevent timing attacks. Standard equality operators
|
|
(`===` or `==`) can leak information about the secret through response time variations.
|
|
</Callout>
|
|
|
|
## Code Examples
|
|
|
|
<Tabs items={['Node.js (Express)', 'Python (Flask)']}>
|
|
<Tab value="Node.js (Express)">
|
|
```javascript
|
|
const express = require('express');
|
|
const crypto = require('crypto');
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
const WEBHOOK_SECRET = process.env.DOCUMENSO_WEBHOOK_SECRET;
|
|
|
|
function verifyWebhookSignature(receivedSecret, expectedSecret) {
|
|
if (!expectedSecret) {
|
|
// No secret configured, skip verification
|
|
// Not recommended for production
|
|
return true;
|
|
}
|
|
|
|
if (!receivedSecret) {
|
|
return false;
|
|
}
|
|
|
|
// Use constant-time comparison to prevent timing attacks
|
|
try {
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(receivedSecret),
|
|
Buffer.from(expectedSecret),
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
app.post('/webhooks/documenso', (req, res) => {
|
|
const receivedSecret = req.headers['x-documenso-secret'];
|
|
|
|
if (!verifyWebhookSignature(receivedSecret, WEBHOOK_SECRET)) {
|
|
console.error('Webhook verification failed');
|
|
return res.status(401).json({ error: 'Invalid signature' });
|
|
}
|
|
|
|
// Signature verified, process the webhook
|
|
const { event, payload } = req.body;
|
|
console.log(`Verified webhook: ${event}`);
|
|
|
|
// Process the event...
|
|
|
|
res.status(200).json({ received: true });
|
|
});
|
|
|
|
app.listen(3000, () => {
|
|
console.log('Webhook server running on port 3000');
|
|
});
|
|
|
|
````
|
|
</Tab>
|
|
<Tab value="Python (Flask)">
|
|
```python
|
|
import hmac
|
|
import os
|
|
from flask import Flask, request, jsonify
|
|
|
|
app = Flask(__name__)
|
|
|
|
WEBHOOK_SECRET = os.environ.get('DOCUMENSO_WEBHOOK_SECRET')
|
|
|
|
def verify_webhook_signature(received_secret, expected_secret):
|
|
"""Verify the webhook signature using constant-time comparison."""
|
|
if not expected_secret:
|
|
# No secret configured, skip verification
|
|
# Not recommended for production
|
|
return True
|
|
|
|
if not received_secret:
|
|
return False
|
|
|
|
# Use constant-time comparison to prevent timing attacks
|
|
return hmac.compare_digest(received_secret, expected_secret)
|
|
|
|
@app.route('/webhooks/documenso', methods=['POST'])
|
|
def handle_webhook():
|
|
received_secret = request.headers.get('X-Documenso-Secret', '')
|
|
|
|
if not verify_webhook_signature(received_secret, WEBHOOK_SECRET):
|
|
print('Webhook verification failed')
|
|
return jsonify({'error': 'Invalid signature'}), 401
|
|
|
|
# Signature verified, process the webhook
|
|
data = request.get_json()
|
|
event = data.get('event')
|
|
payload = data.get('payload')
|
|
|
|
print(f'Verified webhook: {event}')
|
|
|
|
# Process the event...
|
|
|
|
return jsonify({'received': True}), 200
|
|
|
|
if __name__ == '__main__':
|
|
app.run(port=3000)
|
|
````
|
|
|
|
</Tab>
|
|
</Tabs>
|
|
|
|
## Handling Verification Failures
|
|
|
|
When verification fails, follow these practices:
|
|
|
|
| Action | Description |
|
|
| ------------------- | ------------------------------------------------------------------ |
|
|
| Return 401 status | Respond with `401 Unauthorized` to indicate authentication failure |
|
|
| Log the attempt | Record failed attempts for security monitoring |
|
|
| Do not process | Never process the payload if verification fails |
|
|
| Do not leak details | Avoid exposing information about why verification failed |
|
|
|
|
```javascript
|
|
app.post('/webhooks/documenso', (req, res) => {
|
|
const receivedSecret = req.headers['x-documenso-secret'];
|
|
|
|
if (!verifyWebhookSignature(receivedSecret, WEBHOOK_SECRET)) {
|
|
// Log for monitoring but don't expose details
|
|
console.error('Webhook verification failed', {
|
|
timestamp: new Date().toISOString(),
|
|
ip: req.ip,
|
|
});
|
|
|
|
// Generic error response
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
// Continue processing...
|
|
});
|
|
```
|
|
|
|
### Common Verification Issues
|
|
|
|
| Issue | Cause | Solution |
|
|
| --------------- | --------------------------------------- | ------------------------------------------------------------------ |
|
|
| Secret mismatch | Webhook secret changed or misconfigured | Verify the secret in your environment matches the one in Documenso |
|
|
| Empty header | Webhook created without a secret | Add a secret to the webhook configuration in Documenso |
|
|
| Encoding issues | String encoding mismatch | Ensure both secrets use the same encoding (UTF-8) |
|
|
|
|
## Security Best Practices
|
|
|
|
<Accordions type="multiple">
|
|
<Accordion title="Use strong secrets">
|
|
Generate a cryptographically secure random string for your webhook secret:
|
|
|
|
```bash
|
|
# Generate a 32-byte random secret
|
|
openssl rand -hex 32
|
|
```
|
|
|
|
</Accordion>
|
|
<Accordion title="Store secrets securely">
|
|
Never hardcode secrets in your source code. Use environment variables or a secrets manager:
|
|
|
|
```javascript
|
|
// Good: Environment variable
|
|
const WEBHOOK_SECRET = process.env.DOCUMENSO_WEBHOOK_SECRET;
|
|
|
|
// Bad: Hardcoded
|
|
const WEBHOOK_SECRET = 'my-secret-key'; // Never do this
|
|
```
|
|
|
|
</Accordion>
|
|
<Accordion title="Rotate secrets periodically">
|
|
Update your webhook secret periodically:
|
|
|
|
1. Generate a new secret
|
|
2. Update your server to accept both old and new secrets temporarily
|
|
3. Update the webhook configuration in Documenso
|
|
4. Remove the old secret from your server
|
|
|
|
</Accordion>
|
|
<Accordion title="Validate payload structure">
|
|
After verifying the signature, validate the payload structure before processing:
|
|
|
|
```javascript
|
|
const { event, payload, createdAt } = req.body;
|
|
|
|
if (!event || !payload) {
|
|
return res.status(400).json({ error: 'Invalid payload structure' });
|
|
}
|
|
|
|
// Validate event is a known type
|
|
const validEvents = [
|
|
'DOCUMENT_CREATED',
|
|
'DOCUMENT_SENT',
|
|
'DOCUMENT_OPENED',
|
|
'DOCUMENT_SIGNED',
|
|
'DOCUMENT_RECIPIENT_COMPLETED',
|
|
'DOCUMENT_COMPLETED',
|
|
'DOCUMENT_REJECTED',
|
|
'DOCUMENT_CANCELLED',
|
|
'DOCUMENT_REMINDER_SENT',
|
|
'TEMPLATE_CREATED',
|
|
'TEMPLATE_UPDATED',
|
|
'TEMPLATE_DELETED',
|
|
'TEMPLATE_USED',
|
|
];
|
|
|
|
if (!validEvents.includes(event)) {
|
|
console.warn(`Unknown event type: ${event}`);
|
|
}
|
|
```
|
|
|
|
</Accordion>
|
|
<Accordion title="Use HTTPS">
|
|
Always use HTTPS endpoints in production to encrypt data in transit, including the secret header.
|
|
</Accordion>
|
|
<Accordion title="Implement rate limiting">
|
|
Protect your endpoint from abuse with rate limiting:
|
|
|
|
```javascript
|
|
const rateLimit = require('express-rate-limit');
|
|
|
|
const webhookLimiter = rateLimit({
|
|
windowMs: 60 * 1000, // 1 minute
|
|
max: 100, // 100 requests per minute
|
|
message: { error: 'Too many requests' },
|
|
});
|
|
|
|
app.post('/webhooks/documenso', webhookLimiter, (req, res) => {
|
|
// Handle webhook...
|
|
});
|
|
```
|
|
|
|
</Accordion>
|
|
</Accordions>
|
|
|
|
## See Also
|
|
|
|
- [Webhook Setup](/docs/developers/webhooks/setup) - Configure webhook endpoints and secrets
|
|
- [Webhook Events](/docs/developers/webhooks/events) - Event types and payload structure
|