Overview
Node.js receivers must calculate the signature from the original raw request body.
If middleware parses the JSON first and discards the raw bytes, the signature calculation may use different bytes and fail. Configure the webhook route to receive a Buffer.
The signing secret belongs on the backend only.
Setup
1. Store PULSEGRID_WEBHOOK_SECRET in your server environment.
2. Configure express.raw for this route.
3. Validate the timestamp is no older than five minutes.
4. Calculate HMAC-SHA256 over timestamp.rawBody.
5. Use timingSafeEqual only after confirming both buffers have equal lengths.
6. Parse JSON only after verification.
Code example
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/pulsegrid",
express.raw({ type: "application/json" }),
async (req, res) => {
const timestamp = req.header("X-PulseGrid-Timestamp") || "";
const supplied = req.header("X-PulseGrid-Signature") || "";
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > 300) {
return res.status(401).json({ error: "Expired request" });
}
const digest = crypto
.createHmac("sha256", process.env.PULSEGRID_WEBHOOK_SECRET)
.update(Buffer.concat([Buffer.from(timestamp + "."), req.body]))
.digest("hex");
const expected = Buffer.from("v1=" + digest);
const actual = Buffer.from(supplied);
if (expected.length !== actual.length ||
!crypto.timingSafeEqual(expected, actual)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString("utf8"));
await handlePulseGridEvent(event);
return res.status(200).json({ received: true });
}
);
Notes
Do not put express.json() in front of this webhook route unless you also preserve the original raw body.
Return 401 for invalid signatures. Never process an event whose signature could not be verified.