Webhook Guides

Duplicate Webhook Events

Duplicate webhook events are normal in production. Stripe, GitHub, Shopify, Okta, Slack, and internal systems can all send the same business event more than once when a receiver times out, returns an error, or an operator triggers replay after an outage.

The goal is not to pretend duplicates will never happen. The goal is to make duplicates visible, route them safely, and keep the receiver from applying the same side effect twice.

Webhook duplicate inspection path through request, event, attempt, and deduplication records.
Treat duplicate events as evidence to inspect. Compare the provider id, FastHook request, routed event, and destination attempts before retrying a larger window.

Common causes

  • The provider did not receive a fast 2xx response.
  • The receiver timed out after partially applying the side effect.
  • The provider retried after a network or TLS failure.
  • An operator manually resent a provider event.
  • A gateway replay or event retry redelivered stored traffic.
  • Two destination branches intentionally received the same original request.

How duplicates look in logs

SignalWhat it meansAction
Same provider event idSame business event delivered again.Return success without repeating side effects.
Same payload, new delivery idProvider retry or manual redelivery.Inspect previous attempt status.
Same FastHook event retryGateway retried one destination branch.Confirm receiver idempotency.
Many failures, then replayOutage recovery window.Throttle destination and replay narrowly.

Receiver guard

Store the idempotency key before the business mutation. If the key already exists, return success and keep the previous processing result visible for debugging.

Duplicate guard
async function processWebhook(event) {
  const idempotencyKey = event.id || event.headers["x-provider-delivery"];

  const firstSeen = await store.reserveOnce(idempotencyKey);
  if (!firstSeen) {
    return { ok: true, duplicate: true };
  }

  await applySideEffect(event);
  await store.markProcessed(idempotencyKey);

  return { ok: true };
}

Related guides