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

# Webhooks

> How gavAI delivers events to your endpoint — signed, retried, and at-least-once.

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

<PageMeta audience="Developers" readingTime="6 min" level="Concepts" />

When something happens inside your gavAI workspace — a page is published, a form is submitted, a payment clears — your server hears about it because gavAI calls you, not the other way around. No polling, no long-lived connections. The delivery is signed so you can prove it came from gavAI, and retried so a brief outage on your side doesn't lose the event. This page is the mental model for that system; the rest of this section is the detail.

## Why this exists

Polling for state changes works until it doesn't. You either poll too often and waste both sides' budget, or you poll too rarely and act on stale data. Webhooks invert the relationship: gavAI knows the moment something changes, so gavAI is the right party to initiate the notification. Your endpoint stays quiet until it has work to do.

The trade-off is that you now run a public HTTPS endpoint that an external service writes to, which means you need to (a) confirm each call really came from gavAI and (b) handle the fact that the network is not reliable and the same event may arrive more than once. Verification and idempotency, the next two pages in this section, cover those two responsibilities.

## The delivery flow

```mermaid theme={null}
sequenceDiagram
    participant W as Workspace event<br/>(acme)
    participant D as gavAI dispatcher
    participant E as Your endpoint
    W->>D: page.published fires
    D->>D: Sign body with whsec_...<br/>compute gavai-signature + gavai-timestamp
    D->>E: POST /webhooks/gavai<br/>(delivery whd_01H...)
    E->>E: Verify HMAC, check timestamp,<br/>dedupe by event id
    E-->>D: 200 OK (within 10 s)
    Note over D,E: If non-2xx or timeout,<br/>dispatcher retries with backoff
```

The three identifiers worth keeping straight:

<DefList>
  <Def term="wh_01H..." type="Subscription ID">
    Durable registration of an endpoint URL plus an event filter. Long-lived — survives across deliveries.
  </Def>

  <Def term="whd_01H..." type="Delivery ID">
    One attempt to push one event to that endpoint. Ephemeral; a single logical event can have multiple delivery attempts.
  </Def>

  <Def term="whsec_..." type="Signing secret">
    Generated once per subscription and tied to it for that subscription's lifetime. Shown exactly once at creation.
  </Def>
</DefList>

## What every delivery carries

<FeatureTable
  columns={[
{ key: "header", label: "Header" },
{ key: "purpose", label: "Purpose" },
]}
  rows={[
{
  header: "`gavai-signature`",
  purpose: "Hex HMAC-SHA256 over `<timestamp>.<sha256_hex(body)>` — see [Verify webhook signatures](/webhooks/verification)",
},
{
  header: "`gavai-timestamp`",
  purpose: "Unix seconds at dispatch — verify it is within ±5 minutes of your server clock",
},
{
  header: "`gavai-delivery-id`",
  purpose: "Delivery attempt id `whd_01H...` — changes on each retry",
},
{
  header: "`gavai-event`",
  purpose: "Event family, for example `pages.published` or `forms.submitted`",
},
{
  header: "`Content-Type`",
  purpose: "`application/json`",
},
]}
/>

The JSON body always includes an `id` field. That id is stable across retries of the same logical event and is the value you should dedupe on. The delivery id in the header changes on every retry — useful for log correlation, useless for dedupe.

## The event envelope

Every delivery body is wrapped in the same envelope shape. The keys are stable across event families, so a single deserializer can handle every event type your handler receives.

<DefList>
  <Def term="id" type="string">
    Stable event identifier. The same across retries of the same logical event. Use this as the dedupe key.
  </Def>

  <Def term="type" type="string">
    Event family, dotted. Examples: `pages.published`, `forms.submitted`, `payments.succeeded`. Mirrors the value sent in the `gavai-event` header.
  </Def>

  <Def term="created_at" type="string (ISO 8601)">
    When the event was emitted by the workspace, in UTC. Use this — not the arrival time at your endpoint — to order events on your side.
  </Def>

  <Def term="workspace" type="string">
    Slug of the workspace the event originated in. Confirm it matches the workspace your subscription belongs to before processing.
  </Def>

  <Def term="version" type="string">
    Envelope schema version. The platform ships new event types without breaking the envelope shape; if the envelope itself ever changes, the version field is the way your handler will know.
  </Def>

  <Def term="data" type="object">
    Event-family-specific payload. The shape inside `data` varies by event type and is documented per family in the event catalogue.
  </Def>
</DefList>

The envelope is also what the signature covers. The HMAC input is computed over the exact bytes of the entire envelope body, not over `data` alone — verify the signature before you reach for any field inside.

## Delivery semantics, named

<ArrowList>
  <ArrowItem>**At-least-once, not exactly-once.** A `2xx` response marks delivery successful. Anything else triggers retry. You will sometimes receive the same event twice — see [Idempotency](/webhooks/idempotency) for the receiver-side recipe.</ArrowItem>
  <ArrowItem>**10-second response budget.** Respond before the deadline and queue heavy work asynchronously. A handler that blocks on a database write or an outbound API call will hit the timeout under load.</ArrowItem>
  <ArrowItem>**Exponential backoff, capped.** Six attempts span roughly 1 hour 6 minutes from initial dispatch. See [Retry behavior](/webhooks/retry-behavior) for the schedule.</ArrowItem>
  <ArrowItem>**Circuit breaker on persistent failure.** After 5 consecutive deliveries exhaust all their retries, gavAI pauses delivery to that endpoint for 1 hour. Events that fire while the breaker is open are dropped, not queued.</ArrowItem>
  <ArrowItem>**No ordering guarantee.** A retry of an older event may land after a newer event. Use the payload timestamp inside the event body to sequence on your side, not arrival order.</ArrowItem>
</ArrowList>

## How a subscription is born

Register a webhook in `/console/settings/webhooks` (or via the API). gavAI returns a signing secret of the form `whsec_<64 hex chars>` exactly once. Store it in your secret manager before closing the dialog — there is no rotation endpoint, and there is no way to retrieve it later. If you lose the secret, delete the subscription and create a new one.

Each subscription belongs to one workspace and one URL. Use a separate subscription per environment — production traffic landing on a staging handler is a bad day.

## Where to next

<Columns cols={2}>
  <BrandCard eyebrow="WEBHOOKS" title="Verify webhook signatures" href="/webhooks/verification">
    HMAC-SHA256 verification with constant-time comparison, in four languages.
  </BrandCard>

  <BrandCard eyebrow="WEBHOOKS" title="Retry behavior and circuit breaker" href="/webhooks/retry-behavior">
    The exact retry schedule, what counts as failure, and how to recover.
  </BrandCard>

  <BrandCard eyebrow="WEBHOOKS" title="Idempotency for receivers" href="/webhooks/idempotency">
    Why duplicate delivery happens and how to make your handler safe for it.
  </BrandCard>

  <BrandCard eyebrow="GUIDES" title="Handle webhooks end-to-end" href="/guides/handle-webhooks">
    A complete walkthrough — register, verify, dedupe, and test your handler.
  </BrandCard>
</Columns>
