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 as x-grand-attempt-count/x-grand-eventid, and tracing headers like sentry-trace/baggage). None of these are part of Grand's contract. Verify x-grand-signature only.

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.

Once verified, dedupe on `idempotencyKey` and respond `2xx`. Here's how retries work.

Grand Public API