Request Signature
Every webhook request from Emailit includes an X-Emailit-Signature header containing an HMAC-SHA256 signature and an X-Emailit-Timestamp header with the Unix timestamp of when the request was signed. You should verify this signature to ensure the request is authentic and has not been tampered with.
How it works
- When you create a webhook, Emailit generates a signing secret for that endpoint
- For each delivery, Emailit concatenates the timestamp and raw request body as
{timestamp}.{rawBody} - An HMAC-SHA256 hash is computed over that string using your signing secret
- The signature is sent in the
X-Emailit-Signatureheader and the timestamp inX-Emailit-Timestamp - Your server recomputes the signature and compares it to the header value
Verifying the signature
To verify a webhook signature:
- Extract the
X-Emailit-SignatureandX-Emailit-Timestampheaders from the request - Concatenate the timestamp and the raw request body as
{timestamp}.{rawBody} - Compute an HMAC-SHA256 hash of that string using your webhook signing secret
- Compare the computed hash with the signature using a timing-safe comparison
- Optionally, reject requests where the timestamp is older than a few minutes to guard against replay attacks
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signature, timestamp, secret) {
const signedPayload = `${timestamp}.${rawBody}`;
const computed = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(computed, 'hex'),
Buffer.from(signature, 'hex')
);
}
// In your webhook handler:
const rawBody = req.body; // raw request body as string
const signature = req.headers['x-emailit-signature'];
const timestamp = req.headers['x-emailit-timestamp'];
const secret = process.env.WEBHOOK_SIGNING_SECRET;
// Guard against replay attacks (5 minute tolerance)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
return res.status(401).send('Request too old');
}
if (!verifyWebhookSignature(rawBody, signature, timestamp, secret)) {
return res.status(401).send('Invalid signature');
}
import hmac
import hashlib
import time
def verify_webhook_signature(raw_body, signature, timestamp, secret):
signed_payload = f"{timestamp}.{raw_body}"
computed = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed, signature)
# Guard against replay attacks (5 minute tolerance)
age = int(time.time()) - int(timestamp)
if age > 300:
raise ValueError("Request too old")
function verifyWebhookSignature(
string $rawBody,
string $signature,
string $timestamp,
string $secret
): bool {
$signedPayload = "{$timestamp}.{$rawBody}";
$computed = hash_hmac('sha256', $signedPayload, $secret);
return hash_equals($computed, $signature);
}
// Guard against replay attacks (5 minute tolerance)
$age = time() - intval($timestamp);
if ($age > 300) {
http_response_code(401);
exit('Request too old');
}
require 'openssl'
def verify_webhook_signature(raw_body, signature, timestamp, secret)
signed_payload = "#{timestamp}.#{raw_body}"
computed = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
Rack::Utils.secure_compare(computed, signature)
end
# Guard against replay attacks (5 minute tolerance)
age = Time.now.to_i - timestamp.to_i
raise 'Request too old' if age > 300
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"strconv"
)
func verifyWebhookSignature(rawBody, signature, timestamp, secret string) bool {
signedPayload := fmt.Sprintf("%s.%s", timestamp, rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
computed := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(computed), []byte(signature))
}
// Guard against replay attacks (5 minute tolerance)
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Now().Unix()-ts > 300 {
// reject request
}
Verify signature
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signature, timestamp, secret) {
const signedPayload = `${timestamp}.${rawBody}`;
const computed = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(computed, 'hex'),
Buffer.from(signature, 'hex')
);
}
// In your webhook handler:
const rawBody = req.body; // raw request body as string
const signature = req.headers['x-emailit-signature'];
const timestamp = req.headers['x-emailit-timestamp'];
const secret = process.env.WEBHOOK_SIGNING_SECRET;
// Guard against replay attacks (5 minute tolerance)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
return res.status(401).send('Request too old');
}
if (!verifyWebhookSignature(rawBody, signature, timestamp, secret)) {
return res.status(401).send('Invalid signature');
}
Important notes
- The signature is computed over
{timestamp}.{rawBody}— always use the raw request body string, not a re-serialized version (e.g.JSON.stringify(req.body)). Whitespace or key ordering differences will break the signature. - Always use a timing-safe comparison function (e.g.
crypto.timingSafeEqual,hmac.compare_digest,hash_equals) to prevent timing attacks. - Consider rejecting requests where the
X-Emailit-Timestampis older than a few minutes to guard against replay attacks. - Keep your signing secret secure — store it in environment variables, not in source code.
- You can find your webhook signing secret in the Emailit dashboard under the webhook settings, or via the Webhooks API.