Skip to main content
Every webhook gavAI sends you carries a gavai-signature and a gavai-timestamp header. Verify both before you do anything with the payload — that’s what proves the delivery came from your workspace and wasn’t replayed from a captured request. By the end of this page you’ll have a verifier you can drop into a handler and a clear picture of the failure modes that trip people up.

The whole verifier in 30 lines

This is the canonical Node.js / Bun verifier. Read it first — every other section unpacks what’s in here.
webhooks.ts
import crypto from "node:crypto";

const FIVE_MINUTES = 5 * 60;

export function verifyGavaiWebhook(
  rawBody: Buffer,
  signature: string | undefined,
  timestamp: string | undefined,
  secret: string,
): { ok: true } | { ok: false; reason: string } {
  if (!signature || !timestamp) return { ok: false, reason: "missing_headers" };

  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return { ok: false, reason: "bad_timestamp" };

  const skew = Math.abs(Math.floor(Date.now() / 1000) - ts);
  if (skew > FIVE_MINUTES) return { ok: false, reason: "stale_timestamp" };

  const bodySha = crypto.createHash("sha256").update(rawBody).digest("hex");
  const signed = `${timestamp}.${bodySha}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");

  if (signature.length !== expected.length) return { ok: false, reason: "bad_signature" };
  const equal = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  return equal ? { ok: true } : { ok: false, reason: "bad_signature" };
}
Four things that have to be true: both headers are present, the timestamp is within ±5 minutes of your clock, the computed HMAC matches the header, and the comparison is constant-time. Miss any one and you’ve shipped a vulnerability or a flaky handler.

Rejection reasons

The verifier returns one of four reason codes when it rejects a delivery. Log them — they’re how you tell a buggy handler from a real attack.

What gavAI actually signs

The string fed into HMAC-SHA256 is built in two steps:
body_sha   = sha256_hex(raw_body_bytes)
signed_str = "<gavai-timestamp>.<body_sha>"
gavai-signature = hmac_sha256_hex(whsec_..., signed_str)
The body is hashed first, then the hex digest is concatenated with the timestamp and a literal period. The HMAC is taken over that compact string, not over the full body. This keeps the HMAC input bounded at ~80 bytes regardless of payload size.

Sample code in four languages

webhooks.ts
import crypto from "node:crypto";

const FIVE_MINUTES = 5 * 60;

export function verifyGavaiWebhook(
  rawBody: Buffer,
  signature: string | undefined,
  timestamp: string | undefined,
  secret: string,
): { ok: true } | { ok: false; reason: string } {
  if (!signature || !timestamp) return { ok: false, reason: "missing_headers" };

  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return { ok: false, reason: "bad_timestamp" };

  const skew = Math.abs(Math.floor(Date.now() / 1000) - ts);
  if (skew > FIVE_MINUTES) return { ok: false, reason: "stale_timestamp" };

  const bodySha = crypto.createHash("sha256").update(rawBody).digest("hex");
  const signed = `${timestamp}.${bodySha}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");

  if (signature.length !== expected.length) return { ok: false, reason: "bad_signature" };
  const equal = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  return equal ? { ok: true } : { ok: false, reason: "bad_signature" };
}

Wiring it into a handler

The verifier is pure — it just answers yes or no. Reject fast on false, before you parse anything or touch a database.
server.ts
import { verifyGavaiWebhook } from "./webhooks";

const SECRET = process.env.GAVAI_WEBHOOK_SECRET!;

Bun.serve({
  port: 3000,
  async fetch(req) {
    if (req.method !== "POST") return new Response("Not found", { status: 404 });

    const rawBody = Buffer.from(await req.arrayBuffer());
    const result = verifyGavaiWebhook(
      rawBody,
      req.headers.get("gavai-signature") ?? undefined,
      req.headers.get("gavai-timestamp") ?? undefined,
      SECRET,
    );
    if (!result.ok) return new Response(result.reason, { status: 401 });

    const event = JSON.parse(rawBody.toString("utf8"));
    // ... handle event, return 2xx within 10 s
    return new Response("ok", { status: 200 });
  },
});
The Buffer.from(await req.arrayBuffer()) step matters. A framework helper that returns a pre-parsed JSON object has already mangled whitespace and key order, and the HMAC will not match.

Framework gotchas

Express — mount express.raw({ type: "application/json" }) on the webhook route, not express.json(). You receive req.body as a Buffer; pass that buffer directly to the verifier.
FastAPI / Flask — read the raw bytes with await request.body() (FastAPI) or request.get_data() (Flask). Do not call request.json() first — that re-serializes the parsed value and breaks the signature.
Goio.ReadAll(r.Body) consumes the body stream. Re-attach it with io.NopCloser before returning from the verifier so downstream middleware can read it.
AWS API Gateway — lowercase custom headers get dropped unless you list them in the integration config. Add gavai-signature and gavai-timestamp to the allowed headers, or you will see missing_headers rejections on every delivery.

Mistakes that look like everything else

These four account for nearly every “the signature is wrong but my code is right” debugging session.

Where to next