Notix
Learn

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:

HeaderWhat it holds
X-Notix-Signaturev1= followed by the hex HMAC-SHA256 of the timestamp, a dot, and the raw body
X-Notix-TimestampUnix time in milliseconds when the delivery was signed
X-Notix-EventThe event type, for example email.bounced, so you can route before parsing
X-Notix-CallThe delivery id, the same value as id in the body; use it to deduplicate
X-Notix-Retrytrue 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 check

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.

app/api/webhooks/notix/route.ts, Next.js App Router
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 });
}

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:

PostgreSQL, one insert per delivery
-- 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.

FAQ

Questions, answered.

How do I verify a webhook signature for email events?
Read the raw request body as bytes, take X-Notix-Timestamp and X-Notix-Signature from the headers, compute HMAC-SHA256 with your signing secret over the string timestamp, a dot, then the raw body, hex-encode it and prefix it with v1=. Compare that to the header with a constant-time function, and reject the request if the timestamp is more than five minutes from your clock. The notix-js, Python and PHP packages do all of this in one call; the Go check is fifteen lines of standard library.
Why does my signature check fail even though the secret is right?
Almost always because the body was parsed and re-serialised before the check. JSON middleware re-orders nothing but it does drop whitespace and can re-escape characters, so the bytes you hash are not the bytes Notix signed. Use request.text() in a Next.js route handler, express.raw() in Express, request.body in Django, request.data in Flask and getContent() in Laravel, and run the check before any JSON parsing. The second cause is a stale clock on your server tripping the five-minute tolerance.
What stops someone replaying a real delivery later?
Two things, and you should use both. The timestamp is inside the signed string, so it cannot be changed without invalidating the signature, and the verifiers refuse anything more than five minutes old. Within that window, dedupe on X-Notix-Call: it is unique per delivery and repeats on every retry of the same call, so an insert into a table keyed on it tells you whether you have seen the delivery before.
How do I rotate the signing secret?
Edit the webhook in the dashboard and choose to rotate its secret, or pass rotateSecret when updating it through the API. Notix generates a new whsec_ value and replaces the old one immediately; there is no overlap period with two active secrets. Update the secret in your endpoint first, then rotate, and expect the few deliveries in flight during the switch to fail verification and be retried under the new secret.
What happens if my endpoint is slow or down?
Notix waits ten seconds for a response. Anything other than a 2xx, including a timeout or a redirect, counts as a failure, and the delivery is retried up to six attempts in total with the wait doubling from about five seconds, so roughly two and a half minutes end to end. After thirty consecutive failed calls the webhook is disabled automatically and has to be re-enabled from the dashboard, which is why the handler should answer 200 as soon as the signature checks out and do the real work afterwards.
Does the same check work for SMS and verification events?
Yes. Every delivery from Notix, whatever the event type, carries the same five headers and is signed the same way. One verifier in front of one endpoint handles email, SMS, verification, contact, domain, journey and campaign events alike; route on X-Notix-Event or on type in the body after the signature passes.

Every event, signed the same way.

Create a webhook, copy its secret once, and the check above covers email, SMS and verification events alike.