Skip to main content
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: 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.

The receiver-side recipe

The shape of an idempotent handler:
handler.ts
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:

Storage choices, ranked by ergonomics

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

Where to next