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

# Handle webhooks

> Receive gavAI events on your own endpoint, verify signatures, and dedupe retries.

export const ErrorTable = ({errors}) => <div className="gv-error-table" role="table" aria-label="Errors and fixes">
    <div className="gv-error-table__head" role="row">
      <span className="gv-error-table__col-head" role="columnheader">Error</span>
      <span className="gv-error-table__col-head" role="columnheader">Cause</span>
      <span className="gv-error-table__col-head" role="columnheader">Fix</span>
    </div>
    {errors.map((err, i) => <div className="gv-error-table__row" role="row" key={i}>
        <code className="gv-error-table__code" role="cell">{err.code}</code>
        <div className="gv-error-table__cause" role="cell">{err.cause}</div>
        <div className="gv-error-table__fix" role="cell">{err.fix}</div>
      </div>)}
  </div>;

export const PageMeta = ({audience, readingTime, prereqs, level}) => {
  const items = [audience && ({
    key: "audience",
    label: "AUDIENCE",
    value: audience
  }), readingTime && ({
    key: "time",
    label: "READ",
    value: readingTime
  }), level && ({
    key: "level",
    label: "LEVEL",
    value: level
  }), prereqs && prereqs.length > 0 && ({
    key: "prereqs",
    label: "PREREQS",
    value: prereqs.join(" · ")
  })].filter(Boolean);
  if (items.length === 0) return null;
  return <div className="gv-page-meta" role="group" aria-label="Page metadata">
      {items.map(item => <div className="gv-page-meta__item" key={item.key}>
          <span className="gv-page-meta__label">{item.label}</span>
          <span className="gv-page-meta__value">{item.value}</span>
        </div>)}
    </div>;
};

export const FeatureTable = ({columns, rows, featuredRow, caption, variant}) => <div className={`gv-feature-table ${variant ? `gv-feature-table--${variant}` : ""}`}>
    <table>
      {caption && <caption className="gv-feature-table__caption">{caption}</caption>}
      <thead>
        <tr>
          {columns.map(col => <th key={col.key} scope="col">
              <span className="gv-feature-table__header">{col.label}</span>
            </th>)}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, i) => {
  const rowClass = [i === featuredRow ? "gv-feature-table__row--featured" : "", row.chosen ? "gv-feature-table__row--chosen" : ""].filter(Boolean).join(" ");
  return <tr key={i} className={rowClass}>
              {columns.map(col => {
    const v = row[col.key];
    if (variant === "tradeoff" && Array.isArray(v)) {
      const pill = col.key === "pros" ? "gv-feature-table__pill--pro" : col.key === "cons" ? "gv-feature-table__pill--con" : "";
      return <td key={col.key}>
                      {v.map((item, j) => <span key={j} className={`gv-feature-table__pill ${pill}`}>{item}</span>)}
                    </td>;
    }
    return <td key={col.key}>{v}</td>;
  })}
            </tr>;
})}
      </tbody>
    </table>
  </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 NumberedStep = ({n, title, children}) => <li className="gv-step">
    <span className="gv-step__index">{n}</span>
    <div className="gv-step__body">
      <h3 className="gv-step__title">{title}</h3>
      <div className="gv-step__content">{children}</div>
    </div>
  </li>;

export const NumberedSteps = ({children}) => <ol className="gv-steps">{children}</ol>;

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="GUIDE" />

<PageMeta audience="Developers" readingTime="20 min" level="Intermediate" prereqs={["Public HTTPS endpoint", "Webhook subscription in the console"]} />

By the end of this guide your endpoint accepts webhook POSTs from gavAI, rejects any request not cryptographically signed by your workspace, refuses replays older than five minutes, deduplicates retries on the event id, and answers inside the 10-second timeout. Plan on about 20 minutes if you already have a server with a route you can attach.

## Prerequisites

<ArrowList>
  <ArrowItem>An HTTPS endpoint you control, reachable from the public internet — something like `https://api.acme.com/webhooks/gavai`. Local development works through a tunnel like `ngrok` or `cloudflared`.</ArrowItem>
  <ArrowItem>A webhook subscription added in the gavAI console at `console.gavai.app/settings/webhooks` — endpoint URL plus the event types you want.</ArrowItem>
</ArrowList>

## Steps

<NumberedSteps>
  <NumberedStep title="Register the webhook and save the signing secret">
    When you add a webhook in `console.gavai.app/settings/webhooks`, gavAI generates a signing secret of the form `whsec_<64 hex chars>`. The secret is shown **exactly once** — copy it to your secret manager immediately.

    ```text theme={null}
    whsec_a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456
    ```

    If you lose the secret, delete the webhook and create a new one. There is no retrieval endpoint.

    Store the secret as an environment variable in your service:

    ```bash theme={null}
    export GAVAI_WEBHOOK_SECRET="whsec_a1b2c3d4e5f6..."
    ```
  </NumberedStep>

  <NumberedStep title="Read the three delivery headers">
    Every webhook delivery includes three headers. All must be present — reject deliveries missing any of them before parsing the body.

    <FeatureTable
      columns={["Header", "Format", "Purpose"]}
      rows={[
["X-Gavai-Webhook-Signature", "v1=<hex>", "HMAC-SHA256 signature over the timestamp and body hash. The v1= prefix is forward-compatible with future signature schemes."],
["X-Gavai-Webhook-Timestamp", "Unix seconds (integer string)", "Server time when the delivery was sent. Used with your server clock to detect replays."],
["X-Gavai-Webhook-Id", "UUIDv4", "Unique identifier for this delivery attempt. Changes on each retry — do not use it for deduplication."],
]}
    />
  </NumberedStep>

  <NumberedStep title="Verify the signature">
    gavAI builds the signature as:

    ```text theme={null}
    signed_str  = "<timestamp>.<sha256_hex(raw_body_bytes)>"
    signature   = hmac_sha256(signing_secret, signed_str)
    header      = "v1=" + hex(signature)
    ```

    Your verifier reconstructs `signed_str` from the raw request body and the timestamp header, computes the same HMAC, and compares with a constant-time function.

    <Warning>
      Hash the **raw request bytes**, not a JSON-parsed-and-re-serialized version. Re-serializing changes whitespace and key ordering, producing a different hash and a failed verification. In Express, mount `express.raw({ type: "application/json" })` on the webhook route instead of `express.json()`.
    </Warning>

    <CodeGroup>
      ```js Node.js theme={null}
      import crypto from "node:crypto";

      export function verifyGavaiWebhook(req, secret) {
        const sigHeader = req.headers["x-gavai-webhook-signature"]; // "v1=<hex>"
        const ts = req.headers["x-gavai-webhook-timestamp"];        // "<unix_secs>"
        if (!sigHeader || !ts) return false;
        if (!sigHeader.startsWith("v1=")) return false;

        const skewSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
        if (Number.isNaN(skewSeconds) || skewSeconds > 300) return false;

        const bodySha = crypto
          .createHash("sha256")
          .update(req.rawBody)            // raw bytes, NOT JSON-parsed text
          .digest("hex");
        const signed = `${ts}.${bodySha}`;
        const expected = crypto
          .createHmac("sha256", secret)
          .update(signed)
          .digest("hex");

        const got = sigHeader.slice(3);
        // Length check first — timingSafeEqual throws on mismatched lengths.
        if (got.length !== expected.length) return false;
        return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
      }
      ```

      ```python Python 3.10+ theme={null}
      import hashlib
      import hmac
      import time

      def verify_gavai_webhook(headers: dict, raw_body: bytes, secret: str) -> bool:
          sig_header = headers.get("X-Gavai-Webhook-Signature", "")
          ts = headers.get("X-Gavai-Webhook-Timestamp", "")
          if not sig_header.startswith("v1="):
              return False
          if not ts.isdigit():
              return False
          if abs(int(time.time()) - int(ts)) > 300:
              return False

          body_sha = hashlib.sha256(raw_body).hexdigest()
          signed = f"{ts}.{body_sha}".encode("ascii")
          expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
          return hmac.compare_digest(sig_header[3:], expected)
      ```
    </CodeGroup>

    <Warning>
      An unsigned or invalid request is an attacker forging events to trigger side effects in your system. Reject any failed verification with `401 Unauthorized` before processing the payload.
    </Warning>
  </NumberedStep>

  <NumberedStep title="Enforce the timestamp window">
    The verifiers above already enforce this, but it is worth calling out: reject any delivery where `X-Gavai-Webhook-Timestamp` is more than **±5 minutes** off your server clock.

    ```js theme={null}
    const skewSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
    if (skewSeconds > 300) return false; // 300 seconds = 5 minutes
    ```

    Without this check, a captured delivery could be replayed against your endpoint days later. The HMAC still verifies — the timestamp window is your replay protection.

    <Tip>
      Keep the server clock synced via NTP. A drifted clock causes legitimate deliveries to fail the window check.
    </Tip>
  </NumberedStep>

  <NumberedStep title="Dedupe retries on the body's id">
    gavAI retries failed deliveries up to **5 times** with increasing delays: roughly **1 s, 5 s, 30 s, 5 min, 1 hr**. Each retry carries a fresh `X-Gavai-Webhook-Id` header — do not use that for deduplication.

    Use the `id` field inside the JSON body, which stays constant across retries:

    ```js theme={null}
    app.post("/webhooks/gavai", express.raw({ type: "application/json" }), async (req, res) => {
      if (!verifyGavaiWebhook(req, process.env.GAVAI_WEBHOOK_SECRET)) {
        return res.status(401).send("Invalid signature");
      }

      const event = JSON.parse(req.body.toString());
      const eventId = event.id; // stable across retries

      if (await db.events.exists(eventId)) {
        return res.status(200).send("Already processed");
      }

      await db.events.insert(eventId);
      // ... handle the event ...

      res.status(200).send("OK");
    });
    ```

    Store processed event ids in a database or cache with a TTL that covers the maximum retry window (last retry \~1 hour out, plus margin — a 24-hour TTL is safe).
  </NumberedStep>

  <NumberedStep title="Return inside 10 seconds">
    gavAI expects a `2xx` response within **10 seconds**. Beyond that, the delivery is marked failed and a retry is scheduled. Move slow work — emails, third-party API calls, analytics — to a background queue and acknowledge fast:

    ```js theme={null}
    app.post("/webhooks/gavai", express.raw({ type: "application/json" }), async (req, res) => {
      if (!verifyGavaiWebhook(req, process.env.GAVAI_WEBHOOK_SECRET)) {
        return res.status(401).send("Invalid signature");
      }

      const event = JSON.parse(req.body.toString());

      // Enqueue for background processing — respond immediately.
      await queue.push({ event });

      res.status(200).send("Queued");
    });
    ```

    After 5 consecutive deliveries each exhaust all 5 retries, gavAI opens a **circuit breaker** for 1 hour and pauses all deliveries to your endpoint. Once you have fixed the underlying issue, click **Send test event** in `console.gavai.app/settings/webhooks` to re-probe after the cool-down.
  </NumberedStep>
</NumberedSteps>

## Troubleshooting

<ErrorTable
  errors={[
{
  code: "All deliveries fail 401",
  cause: "Your handler is reading the parsed body instead of the raw bytes.",
  fix: "Mount express.raw({ type: 'application/json' }) on the webhook route. In Hono, use await c.req.text() before parsing.",
},
{
  code: "Sporadic verification failures",
  cause: "The server clock has drifted.",
  fix: "Run timedatectl (Linux) or sync NTP, then retest.",
},
{
  code: "Header missing from request",
  cause: "A proxy is stripping custom headers — AWS API Gateway is a common culprit.",
  fix: "List the X-Gavai-Webhook-* headers explicitly in your gateway's integration mapping.",
},
{
  code: "Same event processed twice",
  cause: "You are deduplicating on X-Gavai-Webhook-Id (changes per retry) instead of the body id (stable).",
  fix: "Switch your dedupe key to event.id from the JSON body.",
},
{
  code: "Endpoint paused after deploy",
  cause: "Circuit breaker opened during 5 consecutive failed deliveries.",
  fix: "Click Send test event in the console to re-probe after the 1-hour cool-down.",
},
]}
/>

## What you built

You registered a webhook and stored the signing secret, read and validated the three delivery headers, implemented HMAC-SHA256 verification with constant-time comparison, added replay protection via the timestamp window, deduplicated retries on the body `id`, and structured the handler to respond inside the 10-second timeout. Your endpoint is now safe to receive and process gavAI events.

## Next steps

<Columns cols={2}>
  <BrandCard title="Webhooks overview" href="/webhooks/overview" cta="Read more">
    Event types, payload schemas, and how to manage subscriptions in the console.
  </BrandCard>

  <BrandCard title="Signature verification reference" href="/webhooks/verification" cta="Read reference">
    The full signing algorithm, test vectors, and examples in Go and Rust.
  </BrandCard>

  <BrandCard title="Retry behavior" href="/webhooks/retry-behavior" cta="Read more">
    Retry schedule, circuit breaker rules, and how to recover a paused endpoint.
  </BrandCard>
</Columns>
