Webhook Signatures
When you configure a webhook secret for your inbound domain, every webhook request is signed so you can verify it came from JetEmail and hasn’t been tampered with.
Each signed webhook request includes these headers:
Header Description X-Webhook-IDUnique job identifier X-Webhook-TimestampUnix timestamp of the delivery attempt X-Webhook-SignatureHMAC-SHA256 hex-encoded signature
How the Signature is Computed
The signature is computed using HMAC-SHA256 with your webhook secret. The signing input combines the webhook ID, timestamp, and request body:
{webhook_id}.{timestamp}.{json_body}
The resulting hex-encoded digest is sent in the X-Webhook-Signature header.
Verifying Signatures
Node.js
Python
PHP
Go
Ruby
const crypto = require ( 'crypto' );
function verifyWebhookSignature ( webhookId , timestamp , body , signature , secret ) {
const signingInput = ` ${ webhookId } . ${ timestamp } . ${ body } ` ;
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( signingInput )
. digest ( 'hex' );
return crypto . timingSafeEqual (
Buffer . from ( signature ),
Buffer . from ( expectedSignature )
);
}
// Express.js example
app . post ( '/inbound' , express . raw ({ type: 'application/json' }), ( req , res ) => {
const webhookId = req . headers [ 'x-webhook-id' ];
const timestamp = req . headers [ 'x-webhook-timestamp' ];
const signature = req . headers [ 'x-webhook-signature' ];
const body = req . body . toString ();
if ( ! verifyWebhookSignature ( webhookId , timestamp , body , signature , process . env . WEBHOOK_SECRET )) {
return res . status ( 401 ). send ( 'Invalid signature' );
}
const email = JSON . parse ( body );
// Process the inbound email...
res . status ( 200 ). send ( 'OK' );
});
import hmac
import hashlib
def verify_webhook_signature ( webhook_id : str , timestamp : str , body : bytes , signature : str , secret : str ) -> bool :
signing_input = f " { webhook_id } . { timestamp } . { body.decode() } "
expected = hmac.new(
secret.encode(),
signing_input.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example
from flask import Flask, request
app = Flask( __name__ )
@app.route ( '/inbound' , methods = [ 'POST' ])
def inbound_webhook ():
webhook_id = request.headers.get( 'X-Webhook-ID' )
timestamp = request.headers.get( 'X-Webhook-Timestamp' )
signature = request.headers.get( 'X-Webhook-Signature' )
body = request.get_data()
if not verify_webhook_signature(webhook_id, timestamp, body, signature, WEBHOOK_SECRET ):
return 'Invalid signature' , 401
email = request.get_json()
# Process the inbound email...
return 'OK' , 200
<? php
function verifyWebhookSignature ( $webhookId , $timestamp , $body , $signature , $secret ) {
$signingInput = "{ $webhookId }.{ $timestamp }.{ $body }" ;
$expected = hash_hmac ( 'sha256' , $signingInput , $secret );
return hash_equals ( $expected , $signature );
}
// Usage
$body = file_get_contents ( 'php://input' );
$webhookId = $_SERVER [ 'HTTP_X_WEBHOOK_ID' ] ?? '' ;
$timestamp = $_SERVER [ 'HTTP_X_WEBHOOK_TIMESTAMP' ] ?? '' ;
$signature = $_SERVER [ 'HTTP_X_WEBHOOK_SIGNATURE' ] ?? '' ;
$secret = getenv ( 'WEBHOOK_SECRET' );
if ( ! verifyWebhookSignature ( $webhookId , $timestamp , $body , $signature , $secret )) {
http_response_code ( 401 );
exit ( 'Invalid signature' );
}
$email = json_decode ( $body , true );
// Process the inbound email...
http_response_code ( 200 );
echo 'OK' ;
package main
import (
" crypto/hmac "
" crypto/sha256 "
" encoding/hex "
" fmt "
" io "
" net/http "
)
func verifyWebhookSignature ( webhookID , timestamp string , body [] byte , signature , secret string ) bool {
signingInput := fmt . Sprintf ( " %s . %s . %s " , webhookID , timestamp , string ( body ))
mac := hmac . New ( sha256 . New , [] byte ( secret ))
mac . Write ([] byte ( signingInput ))
expected := hex . EncodeToString ( mac . Sum ( nil ))
return hmac . Equal ([] byte ( expected ), [] byte ( signature ))
}
func inboundHandler ( w http . ResponseWriter , r * http . Request ) {
webhookID := r . Header . Get ( "X-Webhook-ID" )
timestamp := r . Header . Get ( "X-Webhook-Timestamp" )
signature := r . Header . Get ( "X-Webhook-Signature" )
body , _ := io . ReadAll ( r . Body )
if ! verifyWebhookSignature ( webhookID , timestamp , body , signature , webhookSecret ) {
http . Error ( w , "Invalid signature" , http . StatusUnauthorized )
return
}
// Process the inbound email...
w . WriteHeader ( http . StatusOK )
w . Write ([] byte ( "OK" ))
}
require 'openssl'
def verify_webhook_signature ( webhook_id , timestamp , body , signature , secret )
signing_input = " #{ webhook_id } . #{ timestamp } . #{ body } "
expected = OpenSSL :: HMAC . hexdigest ( 'sha256' , secret, signing_input)
Rack :: Utils . secure_compare (expected, signature)
end
# Sinatra example
post '/inbound' do
body = request. body . read
webhook_id = request. env [ 'HTTP_X_WEBHOOK_ID' ]
timestamp = request. env [ 'HTTP_X_WEBHOOK_TIMESTAMP' ]
signature = request. env [ 'HTTP_X_WEBHOOK_SIGNATURE' ]
unless verify_webhook_signature (webhook_id, timestamp, body, signature, ENV [ 'WEBHOOK_SECRET' ])
halt 401 , 'Invalid signature'
end
email = JSON . parse (body)
# Process the inbound email...
status 200
'OK'
end
Security Best Practices
Always Verify Signatures Never process inbound webhook payloads without verifying the signature first. This prevents attackers from sending fake emails to your endpoint.
Use HTTPS Always use HTTPS for your webhook endpoint to ensure email content is encrypted in transit.
Use Timing-Safe Comparison Use constant-time string comparison functions to prevent timing attacks when verifying signatures.
Validate the Timestamp Check that the X-Webhook-Timestamp is recent (within 5 minutes) to prevent replay attacks.
Replay Prevention
To prevent replay attacks, verify that the timestamp is recent:
function isValidTimestamp ( timestamp , toleranceSeconds = 300 ) {
const now = Math . floor ( Date . now () / 1000 );
const diff = Math . abs ( now - timestamp );
return diff <= toleranceSeconds ;
}
// Usage
const timestamp = parseInt ( req . headers [ 'x-webhook-timestamp' ]);
if ( ! isValidTimestamp ( timestamp )) {
return res . status ( 401 ). send ( 'Request too old' );
}
A tolerance of 5 minutes (300 seconds) is recommended to account for clock drift and retry delays.