Webhook Signatures
How to verify that webhook deliveries are authentic
Every webhook delivery from Nahook includes a cryptographic signature so you can verify it's authentic and hasn't been tampered with. Nahook follows the Standard Webhooks specification.
Signature Headers
Each delivery includes three headers:
| Header | Description | Example |
|---|---|---|
webhook-id | Unique message identifier | msg_abc123 |
webhook-timestamp | Unix timestamp (seconds) | 1711843200 |
webhook-signature | HMAC-SHA256 signature | v1,K7gNU3sdo+OL... |
How Signing Works
The signature is computed as:
HMAC-SHA256(
key: base64_decode(secret_without_prefix),
message: "{webhook-id}.{webhook-timestamp}.{body}"
)Where:
- key is your endpoint's signing secret (
whsec_...) with thewhsec_prefix stripped and then base64-decoded - message is the
webhook-id,webhook-timestamp, and raw request body joined with. - The result is base64-encoded and prefixed with
v1,
Verification in TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(
payload: string,
headers: Record<string, string>,
secret: string,
): boolean {
const msgId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signature = headers["webhook-signature"];
if (!msgId || !timestamp || !signature) {
return false;
}
// Reject timestamps older than 5 minutes (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
return false;
}
// Strip "whsec_" prefix and decode the secret
const secretBytes = Buffer.from(
secret.replace(/^whsec_/, ""),
"base64",
);
// Compute expected signature
const signedContent = `${msgId}.${timestamp}.${payload}`;
const expected = createHmac("sha256", secretBytes)
.update(signedContent)
.digest("base64");
// Compare with provided signature (strip "v1," prefix)
const provided = signature.split(",")[1];
if (!provided) return false;
return timingSafeEqual(
Buffer.from(expected),
Buffer.from(provided),
);
}Usage in an Express/Fastify handler:
app.post("/webhooks", (req, res) => {
const isValid = verifyWebhook(
JSON.stringify(req.body), // raw body string
req.headers as Record<string, string>,
"whsec_YOUR_ENDPOINT_SECRET",
);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
// Process the webhook
console.log("Received event:", req.body);
res.status(200).json({ received: true });
});Always use the raw request body string for verification, not a parsed-then-re-serialized object. JSON re-serialization can change whitespace or key ordering, which breaks the signature.
Replay Protection
The webhook-timestamp header prevents replay attacks. Always verify that the timestamp is within an acceptable window (e.g., 5 minutes) before processing the event:
const now = Math.floor(Date.now() / 1000);
const timestamp = parseInt(headers["webhook-timestamp"], 10);
if (Math.abs(now - timestamp) > 300) {
// Reject — too old or too far in the future
}Idempotency
The webhook-id header uniquely identifies each delivery. Store processed IDs to prevent duplicate processing during retries:
const messageId = headers["webhook-id"];
if (await alreadyProcessed(messageId)) {
return res.status(200).json({ received: true }); // Acknowledge but skip
}
await processEvent(req.body);
await markProcessed(messageId);Signing Secret
Each endpoint has a unique signing secret in the format whsec_{base64_encoded_key}. Find it on the endpoint detail page in the dashboard. The secret is generated at endpoint creation and cannot be changed.