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 clean200, 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 stableid 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
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.
@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 returns422 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.