How-to
Verify webhook signatures
Check that webhook requests come from Emailit by verifying the HMAC-SHA256 signature and timestamp, with code for Node.js, Python, PHP, Ruby, Go and Laravel.
Anyone who knows your webhook URL can send it requests. Every request from Emailit is signed with your webhook’s secret, so verifying the signature proves the request came from Emailit and wasn’t changed on the way. Checking the timestamp also stops old requests from being replayed. Do both before you act on any event.
How the signature works
Each webhook has its own secret, whsec_ followed by 64 hexadecimal characters. You see it once when you create the webhook or reset the secret, and Retrieve a webhook returns it.
For every request, Emailit:
- Takes the current Unix time in seconds, for example
1790846045, and sends it inX-Emailit-Timestamp. - Builds the string
{timestamp}.{raw body}: the timestamp, a period, and the exact request body bytes. - Computes HMAC-SHA256 of that string, using the full secret, including
whsec_, as the key. - Sends the result, hex-encoded in lowercase, in
X-Emailit-Signature.
To verify, your endpoint repeats steps 2 and 3 with the raw body it received and compares its result with the header using a constant-time comparison. Then it checks that the timestamp is recent. We recommend a tolerance of 5 minutes (300 seconds). Each retry is signed again with a fresh timestamp, so a 5-minute window doesn’t reject retries.
Verify requests
Each example reads the raw body, rejects requests with a missing, stale or wrong signature with 401, and only then parses the JSON array.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // full value, including whsec_
const TOLERANCE_SECONDS = 300;
export function verifyEmailitSignature(rawBody, signature, timestamp) {
if (!signature || !timestamp) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!(age <= TOLERANCE_SECONDS)) return false; // also rejects non-numeric timestamps
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(rawBody) // a Buffer with the exact bytes received
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw() keeps the body as a Buffer. Don't run express.json() on this route.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => {
const valid = verifyEmailitSignature(
req.body,
req.get('X-Emailit-Signature'),
req.get('X-Emailit-Timestamp'),
);
if (!valid) return res.status(401).send('Invalid signature');
const events = JSON.parse(req.body.toString('utf8'));
// Queue the events for processing, then acknowledge.
res.sendStatus(204);
});
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["EMAILIT_WEBHOOK_SECRET"].encode() # full value, including whsec_
TOLERANCE_SECONDS = 300
def verify_emailit_signature(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
if not signature or not timestamp:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/emailit")
def emailit_webhook():
raw_body = request.get_data() # raw bytes; don't use request.json first
if not verify_emailit_signature(
raw_body,
request.headers.get("X-Emailit-Signature"),
request.headers.get("X-Emailit-Timestamp"),
):
abort(401)
events = json.loads(raw_body)
# Queue the events for processing, then acknowledge.
return "", 204<?php
const TOLERANCE_SECONDS = 300;
function verifyEmailitSignature(string $rawBody, string $signature, string $timestamp, string $secret): bool
{
if ($signature === '' || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EMAILIT_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_EMAILIT_TIMESTAMP'] ?? '';
$secret = (string) getenv('EMAILIT_WEBHOOK_SECRET'); // full value, including whsec_
if (!verifyEmailitSignature($rawBody, $signature, $timestamp, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$events = json_decode($rawBody, true);
// Queue the events for processing, then acknowledge.
http_response_code(204);require 'sinatra'
require 'openssl'
require 'json'
require 'rack/utils'
SECRET = ENV.fetch('EMAILIT_WEBHOOK_SECRET') # full value, including whsec_
TOLERANCE_SECONDS = 300
def verify_emailit_signature(raw_body, signature, timestamp)
return false if signature.to_s.empty? || timestamp.to_s !~ /\A\d+\z/
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, "#{timestamp}.#{raw_body}")
Rack::Utils.secure_compare(expected, signature)
end
post '/webhooks/emailit' do
raw_body = request.body.read # in Rails, use request.raw_post
valid = verify_emailit_signature(
raw_body,
request.env['HTTP_X_EMAILIT_SIGNATURE'],
request.env['HTTP_X_EMAILIT_TIMESTAMP']
)
halt 401, 'Invalid signature' unless valid
events = JSON.parse(raw_body)
# Queue the events for processing, then acknowledge.
status 204
endpackage main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"time"
)
const toleranceSeconds = 300
func verifyEmailitSignature(rawBody []byte, signature, timestamp, secret string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || signature == "" {
return false
}
age := time.Now().Unix() - ts
if age > toleranceSeconds || age < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
type Event struct {
EventID string `json:"event_id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
func emailitWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
secret := os.Getenv("EMAILIT_WEBHOOK_SECRET") // full value, including whsec_
if !verifyEmailitSignature(rawBody, r.Header.Get("X-Emailit-Signature"), r.Header.Get("X-Emailit-Timestamp"), secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var events []Event
if err := json.Unmarshal(rawBody, &events); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// Queue the events for processing, then acknowledge.
w.WriteHeader(http.StatusNoContent)
}
func main() {
http.HandleFunc("/webhooks/emailit", emailitWebhook)
http.ListenAndServe(":8080", nil)
}<?php
// config/services.php: 'emailit' => ['webhook_secret' => env('EMAILIT_WEBHOOK_SECRET')],
// routes/api.php: Route::post('/webhooks/emailit', EmailitWebhookController::class);
namespace App\Http\Controllers;
use App\Jobs\ProcessEmailitEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class EmailitWebhookController extends Controller
{
private const TOLERANCE_SECONDS = 300;
public function __invoke(Request $request): Response
{
$rawBody = $request->getContent();
$signature = (string) $request->header('X-Emailit-Signature', '');
$timestamp = (string) $request->header('X-Emailit-Timestamp', '');
$secret = (string) config('services.emailit.webhook_secret'); // full value, including whsec_
$fresh = ctype_digit($timestamp)
&& abs(time() - (int) $timestamp) <= self::TOLERANCE_SECONDS;
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
abort_unless($fresh && hash_equals($expected, $signature), 401, 'Invalid signature');
foreach (json_decode($rawBody, true) as $event) {
ProcessEmailitEvent::dispatch($event); // a queued job
}
return response()->noContent();
}
}In Laravel, routes in routes/api.php aren’t checked for CSRF tokens and get the /api prefix, so the URL above is https://acme.com/api/webhooks/emailit. If you register the route in routes/web.php, exclude the path from CSRF verification, or Laravel rejects Emailit’s requests with 419.
Test your verification
Check your function against this known example before you deploy. Call the HMAC part directly, because the timestamp is in the past and fails the 5-minute check.
| Input | Value |
|---|---|
| Secret | whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f |
| Timestamp | 1790846045 |
| Expected signature | 7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3 |
Raw body, exactly as shown, with no trailing newline:
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]Then use Send test on the webhook to confirm your deployed endpoint accepts a real signed request. See Set up a webhook.
Common mistakes
- Parsing before verifying. Frameworks that parse JSON automatically (
express.json(),request.jsonin Flask, Rails params) give you an object, not the bytes Emailit signed. Read the raw body on the webhook route. - Re-serializing the JSON.
JSON.stringify(req.body)orjson.dumps(data)produces different whitespace, key order or escaping, so the HMAC won’t match. Always hash the body exactly as received. - Changing the secret. Use the whole value, including
whsec_, as plain text. Don’t strip the prefix, and don’t hex- or Base64-decode it. - Signing the wrong string. The input is the timestamp, a period, then the body. Signing only the body, or joining with another separator, fails.
- Using
==to compare. Use a constant-time function such ascrypto.timingSafeEqual,hmac.compare_digest,hash_equals,Rack::Utils.secure_compareorhmac.Equal. - A wrong server clock. If your server’s clock drifts, valid requests fall outside the tolerance. Keep it synced with NTP.
- An old or mismatched secret. After Reset, the old secret stops working immediately, including for retries. Each webhook has its own secret, so two endpoints can’t share one.
- Middleware that rewrites the body. Proxies, API gateways or body decompression that change the bytes before your code sees them break the signature. Verify as close to the raw request as possible.
Requests that fail verification get a 401 from your endpoint and are retried like any other failure, so fixing a misconfigured secret within a few hours loses nothing.