A webhook you have not verified is a public endpoint that anyone can call.
Every delivery from Notix carries a signature: HMAC-SHA256, keyed with the webhook's signing secret, over the timestamp and the raw request body. Checking it takes a dozen lines in any language and rules out forged bounces, replayed events and a mistyped URL on someone else's dashboard. This page shows exactly what is signed, what the headers hold, and the check in TypeScript, Python, Go and PHP.
Why verify at all.
A webhook endpoint accepts POST requests from the internet. Without a signature check, anything that can reach the URL can tell your application that an email bounced, that a customer complained, or that a one-time code was verified. The consequences range from a user marked invalid by mistake to a sign-in flow that trusts a forged verification.verified event. The signature proves two things at once: the request was built by someone who holds your signing secret, and the body arrived byte for byte as it was sent.
What is signed, and what arrives.
When a delivery goes out, Notix serialises the payload once, takes the current time in milliseconds, and computes
X-Notix-Signature: v1=hex( HMAC-SHA256( secret, timestamp + "." + rawBody ) )
The secret is the whsec_ value shown once when the webhook is created. The dot between the timestamp and the body is literal. The result is lower-case hex behind a v1= version prefix, so a future scheme can ship without breaking verifiers that check the prefix first. Five headers ride with every request:
| Header | What it holds |
|---|---|
X-Notix-Signature | v1= followed by the hex HMAC-SHA256 of the timestamp, a dot, and the raw body |
X-Notix-Timestamp | Unix time in milliseconds when the delivery was signed |
X-Notix-Event | The event type, for example email.bounced, so you can route before parsing |
X-Notix-Call | The delivery id, the same value as id in the body; use it to deduplicate |
X-Notix-Retry | true on every attempt after the first |
The body is a JSON envelope: id, type, version, createdAt, teamId, attempt and the event-specific data. The shape of data for every event is in the webhooks reference.
Why it has to be the raw body.
The HMAC covers the exact bytes Notix sent. A framework that parses the JSON for you and hands back an object has already thrown those bytes away; re-serialising the object produces different whitespace and sometimes different escaping, and the check fails even with the right secret. So read the body before any JSON middleware touches it: await request.text() in a Next.js route handler, express.raw() on the one route in Express, request.body in Django, request.data in Flask, $request->getContent() in Laravel. Verify, then parse.
Compare the two signatures with a constant-time function as well: timingSafeEqual, hmac.compare_digest, hmac.Equal, hash_equals. A plain string comparison returns early at the first differing byte, and the difference in timing is measurable from outside.
The same verification in four languages.
The TypeScript, Python and PHP packages ship a verifier that reads the headers, checks the tolerance, computes the HMAC and parses the event in one call. Go and any other language do it by hand; the raw variants show what the packages do inside.
import { Notix, WebhookVerificationError } from "notix-js";
const notix = new Notix(process.env.NOTIX_API_KEY!);
const webhooks = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!);
export async function POST(request: Request) {
// request.text(), never request.json(): the signature covers the exact bytes.
const rawBody = await request.text();
let event;
try {
event = webhooks.constructEvent(rawBody, { headers: request.headers });
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response(error.code, { status: 401 });
}
throw error;
}
// Narrowing on type narrows data with it.
if (event.type === "email.bounced") {
await markInvalid(event.data.to, event.id);
}
return new Response("ok", { status: 200 });
}
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const TOLERANCE_MS = 5 * 60 * 1000;
function verify(secret: string, rawBody: Buffer, headers: express.Request["headers"]) {
const signature = String(headers["x-notix-signature"] ?? "");
const timestamp = String(headers["x-notix-timestamp"] ?? "");
if (!signature.startsWith("v1=") || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) return false;
const expected =
"v1=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
// Raw bytes for this route only; express.json() elsewhere would re-serialise them.
app.post("/webhooks/notix", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(process.env.NOTIX_WEBHOOK_SECRET!, req.body, req.headers)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).send("ok");
queue.add(event); // process after answering
});
from flask import Flask, request
from notix import Notix
from notix.webhooks import WebhookVerificationError
app = Flask(__name__)
notix = Notix(os.environ["NOTIX_API_KEY"])
webhooks = notix.webhooks(os.environ["NOTIX_WEBHOOK_SECRET"])
@app.post("/webhooks/notix")
def notix_webhook():
try:
# request.data is the raw bytes; request.get_json() would not be.
event = webhooks.construct_event(request.data, headers=request.headers)
except WebhookVerificationError as error:
return error.code, 401
if event["type"] == "email.bounced":
mark_invalid(event["data"]["to"], event["id"])
return "ok", 200
# The same check without the package, for Django or anything else:
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, signature: str, timestamp: str) -> bool:
if not signature.startswith("v1=") or not timestamp.isdigit():
return False
if abs(time.time() * 1000 - int(timestamp)) > 5 * 60 * 1000:
return False
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest("v1=" + digest.hexdigest(), signature)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranceMs = 5 * 60 * 1000
func verify(secret string, rawBody []byte, signature, timestamp string) bool {
if !strings.HasPrefix(signature, "v1=") {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().UnixMilli()-ts)) > toleranceMs {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func handler(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
if !verify(os.Getenv("NOTIX_WEBHOOK_SECRET"), rawBody,
r.Header.Get("X-Notix-Signature"), r.Header.Get("X-Notix-Timestamp")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK) // answer first, then parse rawBody and do the work
}
use Illuminate\Http\Request;
use Notix\Exception\SignatureVerificationException;
use Notix\Webhooks;
Route::post('/webhooks/notix', function (Request $request) {
$webhooks = new Webhooks(config('services.notix.webhook_secret'));
try {
// getContent() is the raw body; $request->all() is not.
$event = $webhooks->constructEvent($request->getContent(), $request->headers->all());
} catch (SignatureVerificationException $e) {
return response($e->getMessage(), 401);
}
if ($event['type'] === 'email.bounced') {
MarkInvalid::dispatch($event['data']['to'], $event['id']);
}
return response('ok', 200);
})->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
// The same check without the package:
function verifyNotix(string $secret, string $rawBody, string $signature, string $timestamp): bool
{
if (!str_starts_with($signature, 'v1=') || !ctype_digit($timestamp)) {
return false;
}
if (abs((int) round(microtime(true) * 1000) - (int) $timestamp) > 5 * 60 * 1000) {
return false;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
Replay protection: the timestamp and the call id.
Because the timestamp sits inside the signed string, an attacker who captures a valid delivery cannot change it, and the verifiers refuse any delivery whose timestamp is more than five minutes from the server's clock in either direction. That closes the window on replays to five minutes and makes a wrong system clock the most common reason a correct check fails.
Inside the window, two identical requests can still be legitimate: Notix retries a delivery your endpoint did not acknowledge, and the retry carries the same X-Notix-Call and the same body with attempt incremented. Make the handler idempotent by recording the call id before doing any work:
-- One row per delivery id; a retry of the same call is a no-op.
CREATE TABLE notix_webhook_calls (
call_id text PRIMARY KEY, -- X-Notix-Call, also "id" in the body
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO notix_webhook_calls (call_id, event_type)
VALUES ($1, $2)
ON CONFLICT (call_id) DO NOTHING;
-- If no row was inserted, you have already handled this delivery: return 200 and stop.
A retry then costs one rejected insert, and a replay inside the five minutes costs the same.
Rotating the secret.
Each webhook has one signing secret. Rotating it, from the webhook's edit page or with rotateSecret on the update call, generates a new whsec_ value and drops the old one at once; Notix does not sign with two secrets during a changeover. The safe order is to deploy the new secret to your endpoint first, then rotate, and let the handful of deliveries in flight fail verification and come back on the retry schedule signed with the new value.
Answer fast, work later.
Notix waits ten seconds for a response and treats anything but a 2xx, including a redirect, as a failure. A failed delivery is retried up to six attempts with the wait doubling from about five seconds, and thirty consecutive failures disable the webhook until someone re-enables it in the dashboard. So the handler should verify the signature, record the call id, return 200, and hand the event to a queue or a background job. Anything that talks to your database or a third party belongs after the response, not before it.
The bounce and complaint handlers this protects are described on the bounce handling page; the security posture around it is on the security page.
Questions, answered.
How do I verify a webhook signature for email events?
Why does my signature check fail even though the secret is right?
What stops someone replaying a real delivery later?
How do I rotate the signing secret?
What happens if my endpoint is slow or down?
Does the same check work for SMS and verification events?
Keep reading.
Email bounce handling
Hard bounces against soft bounces, what happens to the address automatically, and the bounce webhook payload your code receives.
ProductEmail API
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesTransactional email in Node.js, production-ready
Idempotency keys, batch sends, a webhook handler, templates, the error envelope and retries on the typed SDK.
CompanySecurity
How Notix protects sending credentials, customer data and mail in transit.
NotixDocs
The quickstart: verify a domain, copy an API key, send the first email.
Every event, signed the same way.
Create a webhook, copy its secret once, and the check above covers email, SMS and verification events alike.