When you create a webhook in your Nomos dashboard, we generate a signing secret for that endpoint. Every request we send is signed with that secret, so your application can confirm the payload really came from us and that nothing was changed in transit.
You can find the signing secret on the webhook’s details page in the dashboard.
Every webhook request includes an X-Nomos-Signature header. It looks like this:
t=1768473000,v1=3523dcc0013f08dfa1855772441107330218793f399d7452bd3ff2159c6e0285
There are two pieces:
t — the Unix timestamp (in seconds) when we sent the webhook
v1 — an HMAC-SHA256 of the signed payload, hex-encoded
We compute the signature over a single string formed from the timestamp, a literal . separator, and the raw JSON body of the request, in that order:
HMAC-SHA256(`${timestamp}.${rawBody}`, signingSecret)
The timestamp is part of the signed material, so an attacker can’t shift it without also breaking the signature.
Verify against the raw request body, byte-for-byte. Some frameworks parse
JSON and re-serialize it, which subtly changes whitespace or key order — and
that’s enough to make the signature mismatch even though the data looks
identical.
Verifying a request
We don’t ship an SDK yet, so verification is a few lines of code against any standard HMAC library. Here’s an example in TypeScript using Node’s built-in crypto:
import { Buffer } from "node:buffer";
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyNomosWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
): { ok: true } | { ok: false; reason: string } {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=") as [string, string]),
);
const timestamp = Number(parts.t);
const provided = parts.v1;
if (!timestamp || !provided) {
return { ok: false, reason: "Malformed signature header" };
}
const age = Math.floor(Date.now() / 1000) - timestamp;
if (Math.abs(age) > TOLERANCE_SECONDS) {
return { ok: false, reason: `Timestamp out of tolerance (${age}s)` };
}
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
if (
provided.length !== expected.length ||
!timingSafeEqual(Buffer.from(provided), Buffer.from(expected))
) {
return { ok: false, reason: "Signature mismatch" };
}
return { ok: true };
}
The same approach works in any language that gives you HMAC-SHA256 and a constant-time comparison helper.
Why should I verify webhooks?
Webhooks are vulnerable because attackers can send fake HTTP POST requests to your endpoint, pretending to be a legitimate service. Without verification this can lead to security risks or operational issues, since your application has no way to tell a real event from a forged one.
To mitigate this, each Nomos webhook is signed with a unique key specific to the endpoint. This signature helps verify the source of the webhook, so your application can process only requests it knows came from us.
Another security concern is replay attacks, where intercepted valid payloads, complete with their signatures, are resent to endpoints later. These payloads would pass signature verification on their own. We address this by including the timestamp in the signed material and asking you to reject anything older than a short tolerance window — 5 minutes is a sensible default, and it also gives you a small buffer for clock skew and network delays.