Verify requests
Check the X-Notix-Signature header so you know a webhook came from Notix.
Anyone who knows your endpoint URL can send it a POST. The signature proves a call came from Notix and that nobody changed the body on the way. Check it on every call before you act on the event.
How the signature works
Notix signs each call with the webhook's signing secret:
signature = "v1=" + hex(HMAC-SHA256(secret, timestamp + "." + rawBody))
timestampis theX-Notix-Timestampheader, a Unix time in milliseconds.rawBodyis the request body exactly as it arrived.- The result is sent in the
X-Notix-Signatureheader.
To check a call, compute the same value and compare it with the header in constant time. Then check that the timestamp is recent. The SDKs reject a call more than 5 minutes old, which stops an old call being sent again.
Sign the raw body. If your framework parses the JSON first and you turn it back into text, the bytes can change and the check fails.
Verify with an SDK
Each SDK checks the signature and the timestamp, then returns the parsed event. A bad call raises an error, and you answer 400 with a plain "invalid signature". Do not echo the error text back to the caller.
import { Notix } from "notix-js";
const notix = new Notix(process.env.NOTIX_API_KEY);
const webhooks = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!);
// Next.js App Router: app/webhooks/notix/route.ts
export async function POST(request: Request) {
const rawBody = await request.text();
try {
const event = webhooks.constructEvent(rawBody, { headers: request.headers });
if (event.type === "email.bounced") {
console.log("Bounced:", event.data.to);
}
return new Response("ok");
} catch {
return new Response("invalid signature", { status: 400 });
}
}
import os
from flask import Flask, request
from notix import Notix, WebhookVerificationError
app = Flask(__name__)
notix = Notix(os.environ["NOTIX_API_KEY"])
webhooks = notix.webhooks(os.environ["NOTIX_WEBHOOK_SECRET"])
@app.route("/webhooks/notix", methods=["POST"])
def handle_webhook():
try:
event = webhooks.construct_event(request.data, headers=request.headers)
except WebhookVerificationError:
return "invalid signature", 400
if event["type"] == "email.bounced":
print("Bounced:", event["data"]["to"])
return "ok", 200
<?php
use Notix\Exception\SignatureVerificationException;
use Notix\Notix;
$notix = new Notix(getenv('NOTIX_API_KEY'));
$webhooks = $notix->webhooks(getenv('NOTIX_WEBHOOK_SECRET'));
try {
$event = $webhooks->constructEvent(file_get_contents('php://input'), getallheaders());
} catch (SignatureVerificationException $error) {
http_response_code(400);
exit('invalid signature');
}
if ($event['type'] === 'email.bounced') {
error_log('Bounced: ' . implode(', ', $event['data']['to']));
}
http_response_code(200);
Express
Express parses JSON by default. Use the raw body parser on the webhook route so the signature check sees the bytes Notix sent.
import express from "express";
import { Webhooks } from "notix-js";
const webhooks = new Webhooks(process.env.NOTIX_WEBHOOK_SECRET!);
const app = express();
app.post("/webhooks/notix", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = webhooks.constructEvent(req.body, { headers: req.headers });
console.log("Got", event.type);
res.status(200).send("ok");
} catch {
res.status(400).send("invalid signature");
}
});
app.listen(3000);
Check without parsing
verify answers true or false and does not parse the body.
const isValid = webhooks.verify(rawBody, { headers: request.headers });
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
The window is 5 minutes by default. Pass tolerance in milliseconds to change it in Node.js and Python, or toleranceSeconds in PHP. A negative value is an error.
Verify by hand
Without an SDK, compute the HMAC yourself.
import { createHmac, timingSafeEqual } from "node:crypto";
const FIVE_MINUTES_MS = 5 * 60 * 1000;
export function verifyNotixWebhook(
secret: string,
rawBody: string,
signature: string | null,
timestamp: string | null,
): boolean {
if (!signature || !timestamp) return false;
const sentAt = Number(timestamp);
if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > FIVE_MINUTES_MS) return false;
const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const expected = Buffer.from(`v1=${digest}`, "utf8");
const received = Buffer.from(signature, "utf8");
return expected.length === received.length && timingSafeEqual(expected, received);
}
Rotate the secret
Open the webhook, press Actions, then Rotate secret, or call Rotate the signing secret. Notix shows the new secret once. The old one stops working at once, so update your server straight away.
Calls in another provider's format
After a migration with the Migration Center, Notix can also send events in the shape SendGrid or Resend used, for a set time, so an existing handler keeps working while you change it. Those calls carry an X-Notix-Compat header naming the shape. They are still signed with your Notix secret over the body that was sent, in the same way as above. Notix does not copy the old provider's own signature.