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

# Idempotency for webhook receivers

> Why duplicate delivery happens and how to make your handler safe for it.

export const PullQuote = ({children}) => <p className="gv-pull-quote">
    <span className="gv-pull-quote__mark" aria-hidden="true">“</span>
    <span className="gv-pull-quote__body">{children}</span>
  </p>;

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

gavAI guarantees at-least-once delivery, which means *more than once* is normal. Your handler will sometimes see the same logical event twice. This page explains why that happens and gives you the dedupe pattern that handles it without surprises. There's also a section at the end on the `Idempotency-Key` header you should attach when a webhook handler turns around and writes back to gavAI's API.

## Why duplicate delivery is unavoidable

The webhook dispatcher decides whether a delivery succeeded by reading the HTTP response code. That decision is sound when it gets a clean `200`, but the wire is messy. Consider what happens when your handler does its work successfully, returns `200`, and then:

<ArrowList>
  <ArrowItem>The response packet is lost in transit. The dispatcher never sees the `200`, treats the delivery as timed out, and retries.</ArrowItem>
  <ArrowItem>The TCP connection is reset between the response and the dispatcher reading it. Same outcome — retry.</ArrowItem>
  <ArrowItem>Your load balancer accepts the connection, your worker hands back `200`, then a process restart drops the connection before the byte makes it back. Retry.</ArrowItem>
  <ArrowItem>A regional failover happens mid-delivery. The new region's dispatcher hasn't yet learned that the first attempt succeeded, and re-attempts.</ArrowItem>
</ArrowList>

There is no protocol that can avoid this short of a two-phase commit across the network boundary, and that's a price nobody pays in practice. The pragmatic alternative is **exactly-once processing on the receiver side, layered on at-least-once delivery on the wire**. gavAI guarantees the wire half; your handler covers the processing half.

## The dedupe key

Every event payload includes a stable `id` field that is the same across every retry of the same logical event. The delivery-attempt id in the `gavai-delivery-id` header is different — it changes on each retry, which is useful for log correlation but useless for dedupe.

<DefList>
  <Def term="id" type="event id (JSON body)">
    Stable across every retry of the same logical event. Example: `evt_01H8X3K2YQZ4V7N9P5R6S7T8U9`. **Use this for deduplication.**
  </Def>

  <Def term="gavai-delivery-id" type="HTTP header">
    Changes on every delivery attempt. Example: `whd_01H8X5R6S7T8U9V0W1X2Y3Z4A5`. Useful for log correlation; useless for dedupe.
  </Def>
</DefList>

<PullQuote>Dedupe on the body's `id`, never on the header's delivery id.</PullQuote>

## The receiver-side recipe

The shape of an idempotent handler:

```ts handler.ts theme={null}
import { verifyGavaiWebhook } from "./webhooks";

const SECRET = process.env.GAVAI_WEBHOOK_SECRET!;

Bun.serve({
  port: 3000,
  async fetch(req) {
    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")) as {
      id: string;
      type: string;
      data: Record<string, unknown>;
    };

    // 1. Check the dedupe store BEFORE doing any work.
    if (await store.has(event.id)) {
      return new Response("duplicate", { status: 200 });
    }

    // 2. Atomically claim the id. SET NX on Redis, INSERT ... ON CONFLICT DO NOTHING on Postgres.
    const claimed = await store.claim(event.id, { ttlSeconds: 24 * 60 * 60 });
    if (!claimed) {
      return new Response("duplicate", { status: 200 });
    }

    // 3. Now do the work, knowing only one handler instance is doing it.
    await processEvent(event);

    return new Response("ok", { status: 200 });
  },
});
```

Three rules:

<ArrowList>
  <ArrowItem>**Return `2xx` for duplicates.** A `4xx` on a duplicate burns the rest of the retry budget on an event you've already processed. `200 duplicate` tells the dispatcher you've handled it.</ArrowItem>
  <ArrowItem>**Claim the id atomically before doing the work.** A naive `has()` then `set()` is racy — two parallel deliveries of the same retry can both pass the `has()` check and both run the work. Use Redis `SET NX`, Postgres `INSERT ... ON CONFLICT DO NOTHING`, or a unique constraint on the event id column.</ArrowItem>
  <ArrowItem>**TTL your dedupe entries to 24 hours.** That covers the full retry window with margin. Less and you risk a retry landing after the entry expired; more and your store grows without bound.</ArrowItem>
</ArrowList>

## Storage choices, ranked by ergonomics

<FeatureTable
  columns={[
{ key: "store", label: "Store" },
{ key: "primitive", label: "Atomic claim primitive" },
{ key: "fit", label: "When it fits" },
]}
  rows={[
{
  store: "Redis",
  primitive: "`SET event:<id> 1 NX EX 86400`",
  fit: "You already run Redis. Lowest-latency option. The TTL gives you garbage collection for free.",
},
{
  store: "Postgres",
  primitive: "`INSERT INTO processed_events (id, processed_at) VALUES ($1, now()) ON CONFLICT DO NOTHING RETURNING id`",
  fit: "You want the dedupe state in the same transaction as the side effect. Cleanup is a daily `DELETE WHERE processed_at < now() - interval '24 hours'`.",
},
{
  store: "Managed key-value store",
  primitive: "Conditional write keyed on `id` (e.g. `attribute_not_exists`) with a TTL attribute",
  fit: "Your platform provides a managed key-value store and you don't want to operate a Redis instance.",
},
{
  store: "In-memory `Set`",
  primitive: "Process-local",
  fit: "Single-process development only. Survives neither a restart nor a horizontal scale-out.",
},
]}
/>

## When your handler writes back to gavAI

A webhook handler that calls back into gavAI's API — say, `payment.succeeded` triggers a write to mark an order paid — needs to handle the case where *your* write succeeds but your handler then crashes before returning `200`. The next retry will re-run the write, and you'll have duplicate side effects unless the write itself is idempotent.

Attach an `Idempotency-Key` header to every non-idempotent write you make from a webhook handler. The server caches the first response for **24 hours** and replays it on any subsequent call with the same key, setting `Idempotent-Replayed: true` on the response. The operation does not re-execute.

```ts theme={null}
import { GavaiClient } from "@gavai/sdk-typescript";

const client = new GavaiClient(process.env.GAVAI_API_KEY!);

// Use the event id as the idempotency key — same logical event, same key.
await client.workspaces.members.invite(
  "acme",
  { email: "new-hire@example.com" },
  { idempotencyKey: event.id },
);
// A retry of the same webhook hits the cached response. ONE invite created.
```

`@gavai/sdk-typescript` auto-mints a key for every `POST`, `PATCH`, `PUT`, and `DELETE` if you don't pass one. Pass your own when you want the retry behavior tied to the upstream event rather than to the SDK call — using `event.id` ties the dedupe to the logical webhook, which is almost always what you want.

### Routes where the key is mandatory

The three routes below are not naturally idempotent — each call creates a fresh row, and a double-submit creates duplicate state that's hard to unwind.

<FeatureTable
  columns={[
{ key: "route", label: "Route" },
{ key: "method", label: "Method" },
{ key: "consequence", label: "Without an idempotency key, a retry…" },
]}
  rows={[
{
  route: "`/v1/workspaces/{slug}/members`",
  method: "POST (invite)",
  consequence: "Creates two pending invitations to the same address",
},
{
  route: "`/v1/workspaces/{slug}/api-keys`",
  method: "POST (mint)",
  consequence: "Issues two API keys; the second secret is unrecoverable",
},
{
  route: "`/v1/workspaces/{slug}/sso`",
  method: "POST",
  consequence: "Registers two links to the same upstream provider",
},
]}
/>

`PATCH` and `DELETE` on these resources are naturally idempotent — the same body produces the same end state regardless of how many times you call it. The key is optional there, useful only as defense-in-depth.

### Mismatch behavior and the cache window

Sending the same key with a different request body returns `422 Unprocessable Entity` with `error.code = "IDEMPOTENCY_MISMATCH"`. The server refuses to execute a modified request under an already-seen key — that protects you from a bug where you reuse a key by accident across two different operations.

The 24-hour cache window is sized to cover the realistic span: webhook retries finish in \~1 hour 6 minutes, CI re-runs typically complete within an hour, a human who clicks twice will notice within a working day, and multi-region failover completes in roughly 30 seconds. Anything older than 24 hours is treated as a new attempt and will execute the operation again.

<Note>
  The idempotency store is eventually consistent within approximately 60 seconds across regions. A retry made within that window via a different region may still execute the operation. For payment-class routes, attach an idempotency key **and** request `nonceStore: "strong"` on the wrapper call, which routes through a strongly-consistent edge state primitive. {/* TODO: Confirm whether nonceStore: "strong" is exposed in the public SDK surface before publishing — the source flags this as an internal wrapper option. */}
</Note>

## Where to next

<Columns cols={2}>
  <BrandCard eyebrow="WEBHOOKS" title="Verify webhook signatures" href="/webhooks/verification">
    Confirm each delivery came from gavAI before processing it.
  </BrandCard>

  <BrandCard eyebrow="API REFERENCE" title="The full idempotency model" href="/api-reference/idempotency">
    The cross-API rules — key format, cache behavior, and per-route notes.
  </BrandCard>
</Columns>
