Why Verify Webhooks?
Security is critical. Anyone can send a POST request to your webhook endpoint. Without verification, malicious actors could:
Send fake events to corrupt your data
Trigger unwanted actions in your application
Cause your system to process fraudulent information
Launch denial-of-service attacks
Always verify webhook signatures to ensure requests are genuinely from AutoSend.
How AutoSend Signs Webhooks
Every webhook request from AutoSend includes an X-Webhook-Signature header containing an HMAC-SHA256 signature.
Signature Generation
AutoSend generates the signature using this process:
Take the raw request body as a string (the JSON payload)
Compute HMAC-SHA256 using your webhook secret as the key
Convert to hexadecimal format
Add as header : X-Webhook-Signature: <signature>
// Pseudocode for how AutoSend generates signatures
const signature = crypto
. createHmac ( "sha256" , webhookSecret )
. update ( JSON . stringify ( requestBody ))
. digest ( "hex" );
Every webhook request includes these headers:
HMAC-SHA256 signature of the request bodyExample: "a1b2c3d4e5f6..."
The event type Example: "email.opened"
Unique delivery identifier Example: "delivery-123..."
Unix timestamp when webhook was sent Example: "1699790400"
Always application/json Example: "application/json"
AutoSend user agent Example: "AutoSend-Webhooks/1.0"
Steps to verify Signatures
Extract the signature from the X-Webhook-Signature header
Get the raw request body as a string (before parsing)
Compute the expected signature using your webhook secret
Compare signatures using a constant-time comparison function
const express = require ( "express" );
const crypto = require ( "crypto" );
const app = express ();
// Important: Store raw body for signature verification
app . use (
express . json ({
verify : ( req , res , buf ) => {
req . rawBody = buf . toString ( "utf8" );
},
})
);
function verifyWebhookSignature ( req , webhookSecret ) {
const receivedSignature = req . headers [ "x-webhook-signature" ];
if ( ! receivedSignature ) {
return false ;
}
// Compute expected signature using raw body
const expectedSignature = crypto
. createHmac ( "sha256" , webhookSecret )
. update ( req . rawBody )
. digest ( "hex" );
// Use constant-time comparison to prevent timing attacks
return crypto . timingSafeEqual (
Buffer . from ( receivedSignature ),
Buffer . from ( expectedSignature )
);
}
app . post ( "/webhooks/autosend" , ( req , res ) => {
const webhookSecret = process . env . WEBHOOK_SECRET ;
// Verify signature
if ( ! verifyWebhookSignature ( req , webhookSecret )) {
console . error ( "Invalid webhook signature" );
return res . status ( 401 ). json ({ error: "Invalid signature" });
}
// Process webhook
const { event , data } = req . body ;
console . log ( `Verified webhook: ${ event } ` );
res . status ( 200 ). json ({ received: true });
});
See all 49 lines
Retrieving Your Webhook Secret
If you’ve lost your webhook secret, you can retrieve it:
Navigate to Webhooks from the AutoSend sidebar
Copy the secret and store it securely
Complete Example
Here’s a complete, production-ready webhook endpoint with signature verification:
const express = require ( "express" );
const crypto = require ( "crypto" );
const app = express ();
// Store raw body for signature verification
app . use (
express . json ({
verify : ( req , res , buf ) => {
req . rawBody = buf . toString ( "utf8" );
},
})
);
// Webhook secret from environment
const WEBHOOK_SECRET = process . env . WEBHOOK_SECRET ;
if ( ! WEBHOOK_SECRET ) {
throw new Error ( "WEBHOOK_SECRET environment variable is required" );
}
// Verify webhook signature
function verifyWebhookSignature ( req ) {
const receivedSignature = req . headers [ "x-webhook-signature" ];
if ( ! receivedSignature ) {
return false ;
}
const expectedSignature = crypto
. createHmac ( "sha256" , WEBHOOK_SECRET )
. update ( req . rawBody )
. digest ( "hex" );
return crypto . timingSafeEqual (
Buffer . from ( receivedSignature ),
Buffer . from ( expectedSignature )
);
}
// Validate timestamp (optional but recommended)
function isTimestampValid ( timestamp , maxAgeSeconds = 300 ) {
const now = Math . floor ( Date . now () / 1000 );
const age = now - parseInt ( timestamp );
return age < maxAgeSeconds && age > - 60 ;
}
// Webhook endpoint
app . post ( "/webhooks/autosend" , async ( req , res ) => {
const deliveryId = req . headers [ "x-webhook-delivery-id" ];
const timestamp = req . headers [ "x-webhook-timestamp" ];
// Validate timestamp
if ( ! isTimestampValid ( timestamp )) {
console . error ( "Invalid timestamp" , { deliveryId , timestamp });
return res . status ( 401 ). json ({ error: "Invalid timestamp" });
}
// Verify signature
if ( ! verifyWebhookSignature ( req )) {
console . error ( "Invalid signature" , { deliveryId });
return res . status ( 401 ). json ({ error: "Invalid signature" });
}
// Process webhook
const { event , data } = req . body ;
console . log ( "Webhook received and verified" , {
deliveryId ,
event ,
timestamp: new Date ( parseInt ( timestamp ) * 1000 ). toISOString (),
});
try {
// Queue for background processing
await processWebhookAsync ( event , data );
res . status ( 200 ). json ({ received: true });
} catch ( error ) {
console . error ( "Error processing webhook" , { deliveryId , error });
res . status ( 500 ). json ({ error: "Internal error" });
}
});
app . listen ( 3000 , () => {
console . log ( "Webhook server listening on port 3000" );
});
See all 86 lines
Security Best Practices
Always Use Constant-Time Comparison
Never use === or == to compare signatures. Use constant-time comparison functions to prevent timing attacks:// ❌ Bad - Vulnerable to timing attacks
if ( receivedSignature === expectedSignature ) {
// Process webhook
}
// ✅ Good - Constant-time comparison
if (
crypto . timingSafeEqual (
Buffer . from ( receivedSignature ),
Buffer . from ( expectedSignature )
)
) {
// Process webhook
}
Never hardcode webhook secrets in your code: // ❌ Bad
const WEBHOOK_SECRET = "a1b2c3d4e5f6g7h8..." ;
// ✅ Good
const WEBHOOK_SECRET = process . env . WEBHOOK_SECRET ;
if ( ! WEBHOOK_SECRET ) {
throw new Error ( "WEBHOOK_SECRET environment variable is required" );
}
Store secrets in:
Environment variables
Secure secret management services (AWS Secrets Manager, HashiCorp Vault, etc.)
Encrypted configuration files
Never:
Commit secrets to version control
Include secrets in client-side code
Share secrets in logs or error messages
Compute signatures using the raw, unparsed request body : // ✅ Good - Use raw body
app . 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 body
const signature = crypto
. createHmac ( "sha256" , secret )
. update ( JSON . stringify ( req . body )) // May not match original
. digest ( "hex" );
Optionally, validate the X-Webhook-Timestamp header to reject old requests: function isWebhookTimestampValid ( timestamp , maxAgeSeconds = 300 ) {
const now = Math . floor ( Date . now () / 1000 );
const age = now - parseInt ( timestamp );
// Reject if older than 5 minutes
return age < maxAgeSeconds && age > - 60 ; // Allow 1 minute clock skew
}
app . post ( "/webhooks/autosend" , ( req , res ) => {
const timestamp = req . headers [ "x-webhook-timestamp" ];
if ( ! isWebhookTimestampValid ( timestamp )) {
return res . status ( 401 ). json ({ error: "Timestamp too old" });
}
if ( ! verifyWebhookSignature ( req , webhookSecret )) {
return res . status ( 401 ). json ({ error: "Invalid signature" });
}
// Process webhook
});
Always use HTTPS for your webhook endpoints in production:// ❌ Bad - HTTP in production
const webhookUrl = "http://api.example.com/webhooks/autosend" ;
// ✅ Good - HTTPS
const 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
Rotate Secrets Periodically
Regularly rotate your webhook secrets:
Create a new webhook with a new secret
Update your application to accept both old and new secrets temporarily
Switch traffic to the new webhook
Remove the old webhook
// Support multiple secrets during rotation
const WEBHOOK_SECRETS = [
process . env . WEBHOOK_SECRET ,
process . env . WEBHOOK_SECRET_OLD , // Remove after rotation complete
]. filter ( Boolean );
function verifyWithMultipleSecrets ( req ) {
return WEBHOOK_SECRETS . some (( secret ) => verifyWebhookSignature ( req , secret ));
}
Troubleshooting
Signature Verification Failing
Symptoms : All webhook requests return 401 UnauthorizedCommon Causes:
Using wrong secret
// Check which secret you're using
console . log ( "Using secret starting with:" , webhookSecret . substring ( 0 , 10 ));
Body parsing issues
// Ensure you're using raw body
console . log ( "Raw body:" , req . rawBody );
console . log ( "Parsed body:" , JSON . stringify ( req . body ));
String encoding issues
// Ensure consistent encoding
const expectedSignature = crypto
. createHmac ( "sha256" , webhookSecret )
. update ( req . rawBody , "utf8" ) // Explicit encoding
. digest ( "hex" );
Comparing wrong values
// Debug signature comparison
console . log ( "Received signature:" , receivedSignature );
console . log ( "Expected signature:" , expectedSignature );
console . log ( "Match:" , receivedSignature === expectedSignature );
Testing Signature Verification
Test your signature verification locally: const crypto = require ( "crypto" );
function testSignatureVerification () {
const webhookSecret = "test-secret-12345" ;
const payload = JSON . stringify ({
event: "email.opened" ,
timestamp: "2025-11-12T10:30:00.000Z" ,
data: { emailId: "test-123" },
});
// Generate signature
const signature = crypto
. createHmac ( "sha256" , webhookSecret )
. update ( payload )
. digest ( "hex" );
console . log ( "Test payload:" , payload );
console . log ( "Test signature:" , signature );
// Verify it works
const expectedSignature = crypto
. createHmac ( "sha256" , webhookSecret )
. update ( payload )
. digest ( "hex" );
console . log ( "Verification:" , signature === expectedSignature );
}
testSignatureVerification ();
See all 29 lines