> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gavai.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify webhook signatures

> HMAC-SHA256 verification with constant-time comparison, in four languages.

export const ExpectedOutput = ({caption = "Expected output", children}) => <div className="gv-output">
    <div className="gv-output__label">
      <svg className="gv-output__icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <polyline points="15 10 20 15 15 20" />
        <path d="M4 4v7a4 4 0 0 0 4 4h12" />
      </svg>
      <span>{caption}</span>
    </div>
    <div className="gv-output__body">{children}</div>
  </div>;

export const Def = ({term, type, defaultValue, required, children}) => <>
    <dt className="gv-def-list__term">
      <span className="gv-def-list__name">{term}</span>
      {type && <span className="gv-def-list__type">{type}</span>}
      {required && <span className="gv-def-list__required">required</span>}
      {defaultValue !== undefined && <span className="gv-def-list__default">
          default <code>{defaultValue}</code>
        </span>}
    </dt>
    <dd className="gv-def-list__description">{children}</dd>
  </>;

export const DefList = ({children}) => <dl className="gv-def-list">{children}</dl>;

export const Explanation = ({children}) => <div className="gv-code-explainer__explanation">{children}</div>;

export const Code = ({children}) => <div className="gv-code-explainer__code">{children}</div>;

export const CodeExplainer = ({eyebrow, title, children}) => <div className="gv-code-explainer">
    {(eyebrow || title) && <header className="gv-code-explainer__header">
        {eyebrow && <Eyebrow label={eyebrow} tone="muted" />}
        {title && <h3 className="gv-code-explainer__title">{title}</h3>}
      </header>}
    <div className="gv-code-explainer__grid">{children}</div>
  </div>;

export const BrandCard = ({eyebrow, title, href, icon, badge, cta, featured = false, horizontal = false, children}) => {
  const classes = ["gv-card", featured && "gv-card--featured", horizontal && "gv-card--horizontal"].filter(Boolean).join(" ");
  const inner = <>
      {icon && <span className="gv-card__icon" aria-hidden="true">
          <Icon icon={icon} />
        </span>}
      <div className="gv-card__main">
        {(eyebrow || badge) && <div className="gv-card__meta">
            {eyebrow && <Eyebrow label={eyebrow} tone="muted" />}
            {badge && <span className="gv-card__badge">{badge}</span>}
          </div>}
        <h3 className="gv-card__title">{title}</h3>
        {children && <div className="gv-card__body">{children}</div>}
        {cta && <span className="gv-card__cta">
            {cta}
            <svg className="gv-card__cta-arrow" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <line x1="7" y1="17" x2="17" y2="7" />
              <polyline points="7 7 17 7 17 17" />
            </svg>
          </span>}
      </div>
    </>;
  return href ? <a className={classes} href={href}>
      {inner}
    </a> : <div className={classes}>{inner}</div>;
};

export const ArrowItem = ({children}) => <li className="gv-arrow-item">
    <span className="gv-arrow-item__arrow" aria-hidden="true">{"↳︎"}</span>
    <span className="gv-arrow-item__content">{children}</span>
  </li>;

export const ArrowList = ({children}) => <ul className="gv-arrow-list">{children}</ul>;

export const Eyebrow = ({label, tone = "default"}) => <div className={`gv-eyebrow gv-eyebrow--${tone}`}>
    <span className="gv-eyebrow__bar" aria-hidden="true" />
    <span className="gv-eyebrow__label">{label}</span>
  </div>;

<Eyebrow label="WEBHOOKS" />

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.

```ts webhooks.ts theme={null}
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.

<DefList>
  <Def term="missing_headers" type="reason">
    `gavai-signature` or `gavai-timestamp` is absent on the request. Usually a proxy or gateway is dropping custom headers — see API Gateway gotcha below.
  </Def>

  <Def term="bad_timestamp" type="reason">
    `gavai-timestamp` is not a base-10 integer. The dispatcher always sends Unix seconds; a non-numeric value means something is rewriting the header.
  </Def>

  <Def term="stale_timestamp" type="reason">
    The timestamp is more than 5 minutes from your server clock. Check NTP sync; this is also the signal of a replay attempt against a captured request.
  </Def>

  <Def term="bad_signature" type="reason">
    HMAC did not match. Almost always (a) wrong secret, (b) hashing a re-serialized body, or (c) signature header truncated in transit.
  </Def>
</DefList>

## What gavAI actually signs

The string fed into HMAC-SHA256 is built in two steps:

```text theme={null}
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.

<CodeExplainer>
  <Code>
    ```text theme={null}
    secret:        whsec_test_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
    timestamp:     1762819200
    raw body:      {"event":"pages.published","id":"evt_01H8X3K2YQZ4V7N9P5R6S7T8U9"}

    body_sha:      sha256_hex(raw body)
    signed_str:    1762819200.<body_sha>
    signature:     hmac_sha256_hex(secret, signed_str)
    ```
  </Code>

  <Explanation>
    `raw body` is the exact bytes off the wire — no parse, no re-serialize, no whitespace tweak. The authoritative test vector lives in the dispatcher source at `crates/webhook-dispatcher/src/signing.rs` as `sign_round_trip_known_vector`. If your implementation disagrees with that test, trust the test.
  </Explanation>
</CodeExplainer>

## Sample code in four languages

<Tabs>
  <Tab title="Node.js / Bun">
    ```ts webhooks.ts theme={null}
    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" };
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python webhooks.py theme={null}
    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"
    ```
  </Tab>

  <Tab title="Go">
    ```go webhook.go theme={null}
    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 }
    ```
  </Tab>

  <Tab title="Rust">
    ```rust webhook.rs theme={null}
    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") }
    }
    ```
  </Tab>
</Tabs>

## 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.

```ts server.ts theme={null}
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.

<ExpectedOutput caption="Headers on a real delivery">
  ```http theme={null}
  POST /webhooks/gavai HTTP/1.1
  Host: hooks.acme.com
  Content-Type: application/json
  gavai-signature: 9c2b6f7a3e8d1c4b5a6f7e8d9c0b1a2f3e4d5c6b7a8f9e0d1c2b3a4f5e6d7c8b
  gavai-timestamp: 1762819200
  gavai-delivery-id: whd_01H8X5R6S7T8U9V0W1X2Y3Z4A5
  gavai-event: pages.published
  ```
</ExpectedOutput>

## Framework gotchas

<Note>
  **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.
</Note>

<Note>
  **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.
</Note>

<Note>
  **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.
</Note>

<Note>
  **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.
</Note>

## Mistakes that look like everything else

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

<ArrowList>
  <ArrowItem>**Hashing a re-serialized body.** The SHA-256 input must be the exact bytes off the wire. If your framework parsed the JSON for you, find a way to get the raw buffer back — `request.get_data(cache=True)` in Flask, the `rawBody` middleware option in Express, the `request.body` stream in FastAPI before any parser touches it.</ArrowItem>
  <ArrowItem>**Skipping the timestamp check.** Without the ±5 minute window, a single captured webhook body can be replayed against your endpoint indefinitely. The signature alone proves origin, not freshness.</ArrowItem>
  <ArrowItem>**Comparing with `==`.** Regular string equality short-circuits on the first mismatched byte and leaks timing information that can be used to recover a valid signature byte by byte. Use `crypto.timingSafeEqual` (Node), `hmac.compare_digest` (Python), `hmac.Equal` (Go), or the equivalent in your language.</ArrowItem>
  <ArrowItem>**Deduplicating on the delivery id.** The `gavai-delivery-id` header changes on every retry. The stable dedupe key is the `id` field inside the JSON body. See [Idempotency](/webhooks/idempotency).</ArrowItem>
</ArrowList>

## Where to next

<Columns cols={2}>
  <BrandCard eyebrow="WEBHOOKS" title="Idempotency for receivers" href="/webhooks/idempotency">
    Why duplicates happen and the dedupe pattern that handles them safely.
  </BrandCard>

  <BrandCard eyebrow="WEBHOOKS" title="Retry behavior" href="/webhooks/retry-behavior">
    What gavAI does when your endpoint fails or times out.
  </BrandCard>
</Columns>
