Webhook Security
Securing your webhook endpoints is crucial to ensure incoming requests are legitimate and come from DeePsy. This guide covers HMAC signature verification and security best practices.
Always implement signature verification for production webhooks. Without it, anyone who knows your webhook URL can send fake events to your application.
HMAC Signature Verification
DeePsy signs webhook payloads using HMAC-SHA256 to ensure authenticity. When signature verification is enabled, each webhook request includes a signature header you can use to verify the payload hasn't been tampered with.
How It Works
- Secret Generation: When you enable signatures, DeePsy generates a unique secret for your webhook (prefixed with
whsec_
) - Signature Creation: For each webhook, DeePsy creates an HMAC-SHA256 hash of the JSON payload using your secret
- Header Inclusion: The signature is sent in the
X-Webhook-Signature
header - Verification: Your application recreates the hash and compares it with the received signature
Enabling Signatures
Enable signature verification when creating a webhook in the dashboard or set signature_enabled: true
when using the API.
The webhook secret is shown only once during creation. Store it securely immediately - you cannot retrieve it later. If you lose it, you'll need to regenerate a new secret.
Signature Header Format
Signed webhook requests include the X-Webhook-Signature
header:
X-Webhook-Signature: sha256=<hmac_signature>
Implementation Examples
Here's how to verify webhook signatures in different languages:
- JavaScript
- TypeScript
- PHP
- Python
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
if (!signature || !signature.startsWith('sha256=')) {
return false;
}
const expectedSignature = signature.substring(7); // Remove 'sha256=' prefix
const computedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(computedSignature, 'hex')
);
}
// Usage in Express (important: use raw body parser)
const express = require('express');
const app = express();
app.post('/webhooks/deepsy',
express.raw({type: 'application/json'}),
(req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = req.body; // Raw buffer, not parsed JSON
const secret = process.env.WEBHOOK_SECRET; // whsec_...
if (!verifyWebhookSignature(payload, signature, secret)) {
return res.status(401).send('Unauthorized');
}
// Now it's safe to parse the JSON
const event = JSON.parse(payload);
console.log('Verified event:', event.event);
res.status(200).send('OK');
}
);
import * as crypto from 'crypto';
import * as express from 'express';
interface WebhookEvent {
event: string;
webhook_id: string;
timestamp: string;
company_id: string;
data: any;
}
function verifyWebhookSignature(
payload: Buffer | string,
signature: string,
secret: string
): boolean {
if (!signature || !signature.startsWith('sha256=')) {
return false;
}
const expectedSignature = signature.substring(7);
const computedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(computedSignature, 'hex')
);
}
const app = express();
app.post('/webhooks/deepsy',
express.raw({type: 'application/json'}),
(req: express.Request, res: express.Response) => {
const signature = req.headers['x-webhook-signature'] as string;
const payload = req.body as Buffer;
const secret = process.env.WEBHOOK_SECRET!; // whsec_...
if (!verifyWebhookSignature(payload, signature, secret)) {
return res.status(401).send('Unauthorized');
}
const event: WebhookEvent = JSON.parse(payload.toString());
console.log('Verified event:', event.event);
res.status(200).send('OK');
}
);
<?php
function verifyWebhookSignature($payload, $signature, $secret) {
if (!$signature || strpos($signature, 'sha256=') !== 0) {
return false;
}
$expectedSignature = substr($signature, 7); // Remove 'sha256=' prefix
$computedSignature = hash_hmac('sha256', $payload, $secret);
// Use hash_equals for timing-safe comparison
return hash_equals($expectedSignature, $computedSignature);
}
// Get raw POST data (important: don't use $_POST)
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET'); // whsec_...
if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
exit('Unauthorized');
}
// Now it's safe to parse the JSON
$event = json_decode($payload, true);
error_log('Verified event: ' . $event['event']);
http_response_code(200);
echo json_encode(['status' => 'success']);
?>
import hmac
import hashlib
import json
import os
from flask import Flask, request, jsonify
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
if not signature or not signature.startswith('sha256='):
return False
expected_signature = signature[7:] # Remove 'sha256=' prefix
computed_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Use compare_digest for timing-safe comparison
return hmac.compare_digest(expected_signature, computed_signature)
app = Flask(__name__)
@app.route('/webhooks/deepsy', methods=['POST'])
def handle_webhook():
# Get raw request data (important: not request.json)
payload = request.get_data()
signature = request.headers.get('X-Webhook-Signature', '')
secret = os.getenv('WEBHOOK_SECRET') # whsec_...
if not verify_webhook_signature(payload, signature, secret):
return jsonify({'error': 'Unauthorized'}), 401
# Now it's safe to parse the JSON
event = json.loads(payload)
print(f"Verified event: {event['event']}")
return jsonify({'status': 'success'}), 200
if __name__ == '__main__':
app.run(port=1234)
Security Best Practices
Store Secrets Securely
Never hardcode webhook secrets in your application:
// ✅ Good - Use environment variables
const secret = process.env.WEBHOOK_SECRET;
// ❌ Bad - Hardcoded secret
const secret = 'whsec_abc123...';
Rotate Secrets Regularly
Regenerate webhook secrets periodically through the dashboard or API:
# Regenerate secret via API
curl -X POST "https://deepsy.fr/api/v1/webhooks/{webhook_id}/regenerate-secret" \
-H "Authorization: Bearer deepsy-dev-your-api-key-here"
Use Different Secrets for Different Environments
Use separate webhook secrets for development, staging, and production environments.
Endpoint Security
Use HTTPS Only
All webhook URLs must use HTTPS to ensure data transmission security:
// ✅ Good
const webhookUrl = 'https://your-domain.com/webhooks/deepsy';
// ❌ Bad - HTTP not allowed
const webhookUrl = 'http://your-domain.com/webhooks/deepsy';
Validate Content-Type
Ensure incoming requests have the correct content type:
app.post('/webhooks/deepsy', (req, res) => {
if (req.headers['content-type'] !== 'application/json') {
return res.status(400).send('Invalid content type');
}
// Process webhook...
});
Implement Rate Limiting
Protect your endpoint 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 windowMs
message: 'Too many webhook requests'
});
app.post('/webhooks/deepsy', webhookLimiter, (req, res) => {
// Process webhook...
});
Request Validation
Beyond signature verification, implement additional validation:
function validateWebhookPayload(event) {
// Validate required fields
if (!event.event || !event.webhook_id || !event.company_id) {
throw new Error('Missing required fields');
}
// Validate event type
const validEvents = [
'email.sent', 'email.delivered', 'email.bounced', 'email.dropped', 'email.opened', 'email.clicked',
'campaign.created', 'campaign.updated', 'campaign.archived',
'candidate.created', 'candidate.questionnaire_completed', 'candidate.test_started',
'candidate.test_completed', 'candidate.evaluation_completed',
'user.created', 'user.invited'
];
if (!validEvents.includes(event.event)) {
throw new Error('Invalid event type');
}
// Validate timestamp (reject old events)
const eventTime = new Date(event.timestamp);
const now = new Date();
const maxAge = 5 * 60 * 1000; // 5 minutes
if (now - eventTime > maxAge) {
throw new Error('Event too old');
}
return true;
}
Error Handling
Handle errors gracefully while maintaining security:
app.post('/webhooks/deepsy', (req, res) => {
try {
// Verify signature
if (!verifyWebhookSignature(req.body, req.headers['x-webhook-signature'], secret)) {
return res.status(401).send('Unauthorized');
}
// Validate payload
const event = JSON.parse(req.body);
validateWebhookPayload(event);
// Process event
processWebhookEvent(event);
res.status(200).send('OK');
} catch (error) {
// Log error for debugging but don't expose details
console.error('Webhook processing error:', error);
// Return generic error to avoid information leakage
res.status(400).send('Bad Request');
}
});
Idempotency
Handle duplicate events by implementing idempotency:
const processedEvents = new Set();
function processWebhookEvent(event) {
const eventId = `${event.webhook_id}-${event.timestamp}-${event.event}`;
if (processedEvents.has(eventId)) {
console.log('Duplicate event ignored:', eventId);
return;
}
processedEvents.add(eventId);
// Process the event
// ...
// Clean up old event IDs periodically
if (processedEvents.size > 10000) {
processedEvents.clear();
}
}
Troubleshooting
For signature verification issues and other security-related problems, see our Troubleshooting Guide.
Next Steps
Now that you understand webhook security:
- Learn about Available Events and their payloads
- Explore Testing Strategies for development
- Review Troubleshooting for common issues
Need Help?
For additional security questions:
- Contact our development team at dev@deepsy.fr
- Reach out via deepsy.fr/contact
- Check our Troubleshooting Guide for common problems