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.
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
Node.js / Bun
Python
Go
Rust
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" };
}
import hashlib
import hmac
import time
FIVE_MINUTES = 5 * 60
def verify_gavai_webhook(
raw_body: bytes,
signature: str | None,
timestamp: str | None,
secret: str,
) -> tuple[bool, str]:
if not signature or not timestamp:
return False, "missing_headers"
if not timestamp.isdigit():
return False, "bad_timestamp"
if abs(int(time.time()) - int(timestamp)) > FIVE_MINUTES:
return False, "stale_timestamp"
body_sha = hashlib.sha256(raw_body).hexdigest()
signed = f"{timestamp}.{body_sha}".encode("ascii")
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
return False, "bad_signature"
return True, "ok"
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func Verify(r *http.Request, secret string) (bool, string, error) {
sig := r.Header.Get("gavai-signature")
tsHeader := r.Header.Get("gavai-timestamp")
if sig == "" || tsHeader == "" {
return false, "missing_headers", nil
}
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return false, "bad_timestamp", nil
}
if abs(time.Now().Unix()-ts) > 300 {
return false, "stale_timestamp", nil
}
body, err := io.ReadAll(r.Body)
if err != nil {
return false, "io_error", err
}
// Re-attach the body so downstream handlers can read it.
r.Body = io.NopCloser(strings.NewReader(string(body)))
sum := sha256.Sum256(body)
signed := tsHeader + "." + hex.EncodeToString(sum[:])
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signed))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
return false, "bad_signature", nil
}
return true, "ok", nil
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
pub fn verify_gavai_webhook(
signature: &str,
timestamp: &str,
body: &[u8],
secret: &[u8],
) -> Result<(), &'static str> {
let ts: i64 = timestamp.parse().map_err(|_| "bad_timestamp")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if (now - ts).abs() > 300 {
return Err("stale_timestamp");
}
let mut hasher = Sha256::new();
hasher.update(body);
let body_sha = hex::encode(hasher.finalize());
let signed = format!("{ts}.{body_sha}");
let mut mac = <HmacSha256 as Mac>::new_from_slice(secret)
.expect("HMAC accepts any key length");
mac.update(signed.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
if signature.len() != expected.len() {
return Err("bad_signature");
}
// Constant-time compare.
let mut diff = 0u8;
for (a, b) in signature.bytes().zip(expected.bytes()) {
diff |= a ^ b;
}
if diff == 0 { Ok(()) } else { Err("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.
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.
Go — io.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