Compute signatures using the raw, unparsed request body. Do not re-stringify the parsed JSON:
Copy
// ✅ Good - Use raw bodyapp.use( express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }, }));const signature = crypto .createHmac('sha256', secret) .update(req.rawBody) // Use raw body .digest('hex');// ❌ Bad - Don't re-stringify parsed bodyconst signature = crypto .createHmac('sha256', secret) .update(JSON.stringify(req.body)) // May not match original .digest('hex');
JSON stringification is not deterministic. The order of object keys may differ, causing signature verification to fail.
Validate the Timestamp
Validate the X-Webhook-Timestamp header to reject old or replayed requests:
Copy
function isTimestampValid(timestamp, maxAgeSeconds = 300) { if (!timestamp) { return false; } const now = Date.now(); const age = now - parseInt(timestamp); // Reject if older than 5 minutes or more than 1 minute in the future return age < maxAgeSeconds * 1000 && age > -60000;}app.post('/webhooks/autosend', (req, res) => { const timestamp = req.headers['x-webhook-timestamp']; if (!isTimestampValid(timestamp)) { return res.status(401).json({ error: 'Invalid or expired timestamp' }); } if (!verifyWebhookSignature(req, webhookSecret)) { return res.status(401).json({ error: 'Invalid signature' }); } // Process webhook});
The timestamp is in milliseconds (not seconds). AutoSend sends timestamps as Date.now().toString().
Use HTTPS for Production
Always use HTTPS for your webhook endpoints in production:
Copy
// ❌ Bad - HTTP in productionconst webhookUrl = 'http://api.example.com/webhooks/autosend';// ✅ Good - HTTPSconst webhookUrl = 'https://api.example.com/webhooks/autosend';
HTTPS ensures:
Requests are encrypted in transit
Man-in-the-middle attacks are prevented
Webhook data remains confidential
Your webhook secret is protected
AutoSend does not enforce HTTPS for webhook URLs, but it is strongly recommended for production use.
Respond Quickly (Under 10 Seconds)
Webhook requests have a 10-second timeout. Always respond within this time:
Copy
// ✅ Good - Queue processing and respond immediatelyapp.post('/webhooks/autosend', async (req, res) => { // Verify signature if (!verifyWebhookSignature(req, webhookSecret)) { return res.status(401).json({ error: 'Invalid signature' }); } // Queue for background processing await queue.add('process-webhook', req.body); // Respond immediately res.status(200).json({ received: true });});// ❌ Bad - Long processing blocks responseapp.post('/webhooks/autosend', async (req, res) => { // This might take too long await processWebhook(req.body); await updateDatabase(req.body); await sendNotification(req.body); res.status(200).json({ received: true });});
If your endpoint doesn’t respond within 10 seconds, AutoSend will consider the delivery failed and retry up to 3 times.
Handle Webhook Retries Idempotently
AutoSend retries failed deliveries up to 3 times. Make your webhook handler idempotent:
Copy
// ✅ Good - Idempotent processingapp.post('/webhooks/autosend', async (req, res) => { const deliveryId = req.headers['x-webhook-delivery-id']; // Check if already processed const exists = await db.webhookLogs.findOne({ deliveryId }); if (exists) { return res.status(200).json({ received: true, status: 'duplicate' }); } // Process webhook await processWebhook(req.body); // Store delivery ID await db.webhookLogs.create({ deliveryId, processedAt: new Date() }); res.status(200).json({ received: true });});
Use the X-Webhook-Delivery-Id header to track which deliveries you’ve already processed.
Rotate Secrets Periodically
Regularly rotate your webhook secrets for enhanced security:
1
Create a new webhook with the same events and URL
2
Update your application to support both old and new secrets temporarily
// Trim whitespace from secretconst webhookSecret = process.env.WEBHOOK_SECRET.trim();
Timestamp Validation Fails
Symptoms: Requests fail with “Invalid timestamp” errorCommon Causes:
Wrong time unit - Timestamp is in milliseconds, not seconds
Copy
// ❌ Bad - Treating as secondsconst age = Math.floor(Date.now() / 1000) - parseInt(timestamp);// ✅ Good - Millisecondsconst age = Date.now() - parseInt(timestamp);
Clock skew - Server time is off
Copy
// Allow 1 minute of clock skewconst age = Date.now() - parseInt(timestamp);return age < 300000 && age > -60000; // -1 min to +5 min
Timezone issues
Copy
// Timestamps are always UTCconst now = Date.now(); // Always use Date.now(), not local time
Testing Signature Verification Locally
Test your signature verification without waiting for real webhooks:
Copy
const crypto = require('crypto');function testSignatureVerification() { const webhookSecret = 'test-secret-12345'; // Create a test payload (matching AutoSend's format) const payload = { type: 'email.opened', createdAt: new Date().toISOString(), data: { emailId: 'test-123', campaignId: 'campaign-456', timestamp: new Date().toISOString(), }, }; const payloadString = JSON.stringify(payload); // Generate signature const signature = crypto .createHmac('sha256', webhookSecret) .update(payloadString) .digest('hex'); console.log('Test payload:', payloadString); console.log('Test signature:', signature); // Verify it works const expectedSignature = crypto .createHmac('sha256', webhookSecret) .update(payloadString) .digest('hex'); console.log('Verification passes:', signature === expectedSignature); // Test with your actual verification function const isValid = crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); console.log('timingSafeEqual passes:', isValid);}testSignatureVerification();
Webhooks Not Being Received
Symptoms: No webhook requests arriving at your endpointTroubleshooting Steps:
Check webhook is active
Navigate to Webhooks in AutoSend
Verify webhook status is “Active”
Check if failure count is high (auto-disabled after 5 failures)
Verify URL is accessible
Copy
# Test your endpoint is publicly accessiblecurl -X POST https://your-domain.com/webhooks/autosend \ -H "Content-Type: application/json" \ -d '{"test": true}'
Check delivery logs
Click on your webhook in AutoSend
View the “Delivery Logs” tab
Look for error messages or status codes
Test with resend
Create a test event
Use the “Resend” feature to manually trigger delivery