Skip to content
Webhooks SDK
Esc
navigateopen⌘Jpreview
On this page

Idempotency

Duplicate deliveries are routine, not an edge case. Suppress them with a store.

Providers retry on non-2xx, and several deliver at-least-once even when you succeed. Duplicate deliveries are routine, not an edge case — any handler that charges a card or sends an email needs to be safe against seeing the same event twice.

import { createWebhookHandler, memoryIdempotencyStore } from 'webhooks-sdk'

const handler = createWebhookHandler({
  provider: stripe({ secret }),
  idempotency: memoryIdempotencyStore(),
  on: { /* … */ },
})

With a store configured, a redelivered event is suppressed before dispatch and the result reports outcome: 'duplicate'.

How suppression works

The store is keyed on `${provider.id}:${event.id}`, and an event is only remembered after your handlers succeed — so a delivery that failed mid-handler is re-run on retry instead of being acknowledged as a duplicate. An event whose provider supplies no usable id skips deduplication entirely rather than collapsing every delivery onto one key.

The in-memory store takes tuning options:

memoryIdempotencyStore({
  ttlMs: 24 * 60 * 60 * 1000, // default: 24 hours
  maxSize: 10_000,            // default: oldest evicted first
})

A day’s TTL is plenty — retry windows are hours, not weeks.

The in-memory store is single-process

memoryIdempotencyStore() suits one long-lived process. On serverless or across several instances, each instance keeps its own map and catches nothing. There, implement the two-method IdempotencyStore interface over shared storage — Redis, a KV namespace, a Durable Object:

import type { IdempotencyStore } from 'webhooks-sdk'

const redisStore: IdempotencyStore = {
  async seen(key) {
    return (await redis.exists(`webhook:${key}`)) === 1
  },
  async remember(key, ttlMs) {
    await redis.set(`webhook:${key}`, '1', { PX: ttlMs ?? 24 * 60 * 60 * 1000 })
  },
}

Replay protection for body-only HMACs

Some schemes (GitHub’s, for one) sign the body alone: the signature proves who sent the request but not when, so the same bytes replayed a week later verify perfectly. For those providers the store doubles as replay protection, keyed on a digest of the signed body — not on the delivery id, which lives in an unsigned header a replay could mint fresh.

See scheme families for which providers need this.

Was this page helpful?