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
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:Sample code in four languages
- Node.js / Bun
- Python
- Go
- Rust
webhooks.ts
Wiring it into a handler
The verifier is pure — it just answers yes or no. Reject fast onfalse, before you parse anything or touch a database.
server.ts
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.