Verifying signatures
Every webhook is signed with your subscription's secret so you can prove it came from Grand and was not tampered with in transit. Always verify the signature before acting on a webhook.
The contract
| Header | x-grand-signature |
| Algorithm | HMAC-SHA256 |
| Encoding | base64 |
| Signed content | the raw request body bytes, exactly as received |
| Secret | your subscription's signing secret |
The signature is base64( HMAC_SHA256( rawBody, signingSecret ) ). Compute the same HMAC over the
raw body you receive and compare it — in constant time — against the x-grand-signature header.
Verification flow
Rules that matter
Verify over the raw body, before any JSON parsing or re-serialization. Parsing then
re-stringifying can reorder keys or change whitespace and will break the HMAC. Capture the raw
bytes: express.raw() in Node, request.get_data() in Flask, io.ReadAll(r.Body) in Go.
- Use the secret as a literal string — do not base64-decode it, even though it looks like it.
-
Compare in constant time (
crypto.timingSafeEqual,hmac.compare_digest) to avoid timing attacks — never==. -
Ignore all transport headers. Delivery is handled by a delivery layer that adds its own
metadata (
x-grand-*delivery headers such asx-grand-attempt-count/x-grand-eventid, and tracing headers likesentry-trace/baggage). None of these are part of Grand's contract. Verifyx-grand-signatureonly.
Examples
Node.js (Express)
const crypto = require("crypto");
// Mount with the raw body preserved:
// app.use("/webhooks/grand", express.raw({ type: "application/json" }))
function verify(rawBody, header, signingSecret) {
const expected = crypto
.createHmac("sha256", signingSecret)
.update(rawBody) // raw bytes — NOT JSON.stringify(parsed)
.digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(header || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/grand", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("x-grand-signature"), process.env.GRAND_SIGNING_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// dedupe on event.idempotencyKey, then handle
res.sendStatus(200);
});
Python
import base64, hashlib, hmac
def verify(raw_body: bytes, header: str, signing_secret: str) -> bool:
expected = base64.b64encode(
hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, header or "")
Verify a captured request with openssl
Handy for debugging. Save the exact raw body to a file with no trailing newline:
# note: printf, not echo — echo appends a newline and breaks the HMAC
printf '%s' '<raw body>' > body.bin
openssl dgst -sha256 -hmac "<signing-secret>" -binary body.bin | base64
A stray trailing newline is the most common reason a manual check fails — the HMAC is byte-exact. Real receivers hand the raw request bytes straight to the HMAC and never hit this.