Webhook signing
Webhook signing adds a cryptographic signature to each webhook request, allowing your server to verify that requests genuinely came from EmailConnect. This prevents attackers from sending forged webhook payloads to your endpoint.
EmailConnect follows the Standard Webhooks convention, an open specification adopted by services like Svix, Resend, and others. You can use any Standard Webhooks-compatible library to verify signatures.
Migrating from legacy headers? Before 1 June 2026, EmailConnect sends both Standard Webhooks headers and legacy
X-Webhook-Signature/X-Webhook-Timestampheaders. The two formats differ: legacy signatures use the secret as a raw string and produce a hex-encodedsha256=...value, while standard signatures use a base64-decodedwhsec_secret and produce a base64-encodedv1,...value. After 1 June 2026, only the standard headers are sent. Update your verification code to use the standard headers described below.
How it works
When webhook signing is enabled:
- EmailConnect generates a base64-encoded secret key for your webhook (prefixed with
whsec_) - For each request, EmailConnect constructs a signed content string from the message ID, timestamp, and body
- An HMAC-SHA256 signature is computed and included as an HTTP header
- Your server verifies the signature using the shared secret
Signature headers
EmailConnect sends the following headers with signed webhooks:
| Header | Description |
|---|---|
webhook-id |
Unique message identifier (e.g., msg_2KWP...) |
webhook-timestamp |
Unix timestamp (seconds) when the request was signed |
webhook-signature |
Signature in format v1,<base64-signature> (may contain multiple space-separated signatures during key rotation) |
The signing scheme
The signed content is the concatenation of the message ID, timestamp, and JSON body, separated by dots:
{webhook-id}.{webhook-timestamp}.{body}
This content is signed with HMAC-SHA256 using the base64-decoded secret (strip the whsec_ prefix before decoding). The resulting signature is base64-encoded and prefixed with v1,.
Enabling webhook signing
For new webhooks
- Create a new webhook in your dashboard
- Toggle "Webhook signing" in the Advanced options section
- Click "Create webhook"
- Copy the generated secret (starts with
whsec_) immediately — it won't be shown again
For existing webhooks
- Edit an existing webhook
- Toggle "Webhook signing" to enable it
- Click "Save"
- Copy the generated secret immediately
Regenerating a secret
If you need a new secret (e.g., after a potential compromise):
- Edit your webhook
- Click "Regenerate" next to the signing status
- Copy the new secret
- Update your server with the new secret
Verifying signatures
Step-by-step process
- Extract the
webhook-id,webhook-timestamp, andwebhook-signatureheaders - Reject the request if the timestamp is too far from the current time (recommended: 5 minutes)
- Construct the signed content:
{webhook-id}.{webhook-timestamp}.{raw-body} - Base64-decode your secret (after removing the
whsec_prefix) - Compute HMAC-SHA256 of the signed content using the decoded secret
- Base64-encode the result and compare against the signature(s) in the header (after the
v1,prefix)
Using a Standard Webhooks library
The easiest approach is to use an off-the-shelf Standard Webhooks library:
npm install standardwebhooks # Node.js
pip install standardwebhooks # Python
Node.js / Express
const crypto = require('crypto');
function verifyWebhook(req, secret) {
const msgId = req.headers['webhook-id'];
const timestamp = req.headers['webhook-timestamp'];
const signatures = req.headers['webhook-signature'];
if (!msgId || !timestamp || !signatures) {
return false;
}
// Reject old timestamps (5-minute tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false;
}
// Construct signed content
const rawBody = req.rawBody || JSON.stringify(req.body);
const signedContent = `${msgId}.${timestamp}.${rawBody}`;
// Decode secret (strip whsec_ prefix, then base64-decode)
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
// Compute expected signature
const expected = crypto
.createHmac('sha256', secretBytes)
.update(signedContent, 'utf8')
.digest('base64');
// Check against all provided signatures (handles key rotation)
return signatures
.split(' ')
.some(sig => {
const [version, value] = sig.split(',');
return version === 'v1' && crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(value)
);
});
}
// Express middleware to preserve raw body
app.use('/webhook', express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
app.post('/webhook/emails', (req, res) => {
if (!verifyWebhook(req, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const emailData = req.body;
console.log('Verified email from:', emailData.message.sender.email);
res.status(200).json({ success: true });
});
Python / Flask
import hmac
import hashlib
import base64
import time
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
def verify_webhook(request, secret):
msg_id = request.headers.get('webhook-id')
timestamp = request.headers.get('webhook-timestamp')
signatures = request.headers.get('webhook-signature')
if not all([msg_id, timestamp, signatures]):
return False
# Reject old timestamps (5-minute tolerance)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return False
# Construct signed content
raw_body = request.get_data(as_text=True)
signed_content = f'{msg_id}.{timestamp}.{raw_body}'
# Decode secret (strip whsec_ prefix, then base64-decode)
secret_str = secret.removeprefix('whsec_')
secret_bytes = base64.b64decode(secret_str)
# Compute expected signature
expected = base64.b64encode(
hmac.new(secret_bytes, signed_content.encode('utf-8'), hashlib.sha256).digest()
).decode()
# Check against all provided signatures (handles key rotation)
for sig in signatures.split(' '):
version, _, value = sig.partition(',')
if version == 'v1' and hmac.compare_digest(expected, value):
return True
return False
@app.route('/webhook/emails', methods=['POST'])
def handle_webhook():
if not verify_webhook(request, WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
email_data = request.json
print(f"Verified email from: {email_data['message']['sender']['email']}")
return jsonify({'success': True})
Security best practices
Store secrets securely
- Use environment variables, never hardcode secrets
- Use a secrets manager in production (AWS Secrets Manager, HashiCorp Vault, etc.)
- Rotate secrets periodically or after any potential exposure
Verify timestamps
- Reject requests where
webhook-timestampis more than 5 minutes from the current time - This prevents replay attacks
Use timing-safe comparison
- Always use constant-time string comparison (
crypto.timingSafeEqual,hmac.compare_digest, etc.) - Regular string comparison can leak information through timing differences
Log verification failures
- Log failed signature verifications for security monitoring
- Include relevant details (timestamp, source IP) but never log the secret
Use HTTPS
- Always use HTTPS for webhook endpoints
- HTTPS encrypts the entire request including headers and signature
Troubleshooting
"Invalid signature" errors
Check your secret
- Ensure you're using the correct
whsec_-prefixed secret for this webhook - Secrets are shown only once — regenerate if lost
Check raw body handling
- The signature is computed on the raw JSON request body
- Parsing JSON before verification can change whitespace/formatting
- Use middleware that preserves the raw body
Check signed content construction
- The signed content must be
{webhook-id}.{webhook-timestamp}.{body}— all three parts, dot-separated - Use the raw body string, not a re-serialised object
Testing signature verification
You can test your verification logic with a simple script:
const crypto = require('crypto');
const secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; // example
const msgId = 'msg_test123';
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = '{"test": "payload"}';
const signedContent = `${msgId}.${timestamp}.${body}`;
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const signature = 'v1,' + crypto
.createHmac('sha256', secretBytes)
.update(signedContent, 'utf8')
.digest('base64');
console.log('webhook-id:', msgId);
console.log('webhook-timestamp:', timestamp);
console.log('webhook-signature:', signature);
console.log('Body:', body);
Need help?
Having issues with webhook signing?
- Contact support at support@emailconnect.eu
- Include your webhook ID and any error messages
- See the Standard Webhooks specification for protocol details