hooksentinel

Idempotency

Deduplicating webhook deliveries with memory, Redis, and Prisma stores — plus the same-transaction recipe and the BullMQ fast-ack pattern.

Every major webhook provider delivers at least once, not exactly once. Network blips, non-2xx responses, and timeouts all cause redelivery of the same event, sometimes minutes or days later. If your onEvent handler isn't idempotent, a redelivered charge.succeeded can double-fulfill an order or double-send an email.

hooksentinel handles this by tracking which event IDs have already been processed, using a pluggable IdempotencyStore. Pass one via the idempotency option on createWebhookHandler — omit it and every delivery, including retries, runs onEvent again.

The IdempotencyStore interface

All built-in stores, and any custom store you write, implement the same three methods:

interface IdempotencyStore {
  has(eventId: string): Promise<boolean>;
  markProcessed(eventId: string, ttlSeconds?: number): Promise<void>;
  release(eventId: string): Promise<void>;
}

release is called automatically if onEvent throws, so a failed attempt doesn't get permanently marked as processed — the next retry from the provider gets a fair shot at running your handler again.

Memory store

import { memoryStore } from '@hooksentinel/core/stores';

memoryStore({
  maxSize: 10_000, // optional — oldest entries evicted past this, default 10,000
});

Backed by an in-process Map. Zero setup, zero dependencies — and zero durability. It doesn't survive a process restart or work across multiple instances behind a load balancer, so it's for local development only. Use redisStore() or prismaStore() for anything running more than one instance.

Redis store

import { redisStore } from '@hooksentinel/core/stores';

redisStore({
  url: process.env.REDIS_URL!,
  ttlSeconds: 86_400, // optional — default 24h
});

Or pass an existing ioredis client instead of a URL, so hooksentinel shares your app's connection pool rather than opening its own:

import { redisStore } from '@hooksentinel/core/stores';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

redisStore({ client: redis });

has and markProcessed are implemented with SET ... NX EX, so the check-and-mark is atomic — two concurrent deliveries of the same event ID can't both pass the check before either has finished marking it, which is a real race with a naive GET then SET.

Prisma store

For teams that don't want a separate Redis instance just for webhook dedup, prismaStore() uses your existing Postgres/MySQL/SQLite database through Prisma.

schema.prisma
model ProcessedWebhookEvent {
  eventId     String   @id
  processedAt DateTime @default(now())

  @@map("processed_webhook_events")
}
import { prismaStore } from '@hooksentinel/core/stores';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

prismaStore({
  client: prisma,
  model: 'processedWebhookEvent', // must match the model above
});

markProcessed uses create() and relies on the @id unique constraint to fail with a known Prisma error code (P2002) on a duplicate — that failure is caught internally and treated as "already processed," so a race between two concurrent deliveries resolves correctly at the database level rather than in application code.

Same-transaction recipe

The strongest idempotency guarantee comes from marking an event processed in the same database transaction as the business logic it triggers, so either both commit or neither does. If your business write commits but the "processed" marker doesn't (a crash between the two), a redelivery reprocesses a request that already succeeded.

With prismaStore(), do the marking yourself inside a $transaction instead of letting hooksentinel call markProcessed on its own, using the low-level verifyOnly mode:

import { createWebhookHandler, stripe } from '@hooksentinel/core';
import { prismaStore } from '@hooksentinel/core/stores';
import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient();

const stripeWebhook = createWebhookHandler({
  provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
  idempotency: prismaStore({ client: prisma, model: 'processedWebhookEvent', verifyOnly: true }),
  onEvent: async (event, ctx) => {
    if (event.type !== 'checkout.session.completed') return;

    try {
      await prisma.$transaction([
        prisma.order.update({
          where: { checkoutSessionId: event.data.object.id },
          data: { status: 'fulfilled' },
        }),
        prisma.processedWebhookEvent.create({ data: { eventId: ctx.eventId } }),
      ]);
    } catch (err) {
      if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
        // already processed by a concurrent delivery — not an error
        return;
      }
      throw err;
    }
  },
});

With verifyOnly: true, hooksentinel still uses the store's has() check as a fast pre-filter (skipping obviously-duplicate work before you even open a transaction) but never calls markProcessed itself — that responsibility moves into your transaction, next to the write it's guarding.

BullMQ fast-ack pattern

Providers time out webhook deliveries — Stripe after 20 seconds, others sooner — and treat a timeout as a failed delivery, triggering a retry. If onEvent does slow work (calling out to another API, sending emails, heavy computation), you risk retries piling up for a request that was actually fine, just slow.

The fix is to ack immediately and do the real work asynchronously via a queue. onEvent marks the event processed and enqueues a job; a separate BullMQ worker does the slow part.

src/webhooks/stripe.ts
import { createWebhookHandler, stripe } from '@hooksentinel/core';
import { redisStore } from '@hooksentinel/core/stores';
import { Queue } from 'bullmq';

const fulfillmentQueue = new Queue('fulfillment', {
  connection: { url: process.env.REDIS_URL! },
});

export const stripeWebhook = createWebhookHandler({
  provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
  idempotency: redisStore({ url: process.env.REDIS_URL! }),
  onEvent: async (event, ctx) => {
    if (event.type === 'checkout.session.completed') {
      // enqueue and return — don't await the actual fulfillment here
      await fulfillmentQueue.add(
        'fulfill-order',
        { sessionId: event.data.object.id },
        { jobId: ctx.eventId }, // dedupes at the queue level too
      );
    }
  },
});
src/workers/fulfillment.worker.ts
import { Worker } from 'bullmq';

new Worker(
  'fulfillment',
  async (job) => {
    const { sessionId } = job.data as { sessionId: string };
    await fulfillOrder(sessionId); // the slow part, now off the request path
  },
  { connection: { url: process.env.REDIS_URL! } },
);

async function fulfillOrder(sessionId: string) {
  // ...
}

hooksentinel's idempotency store still prevents the same webhook delivery from enqueueing twice; BullMQ's jobId deduplication (set to the same ctx.eventId) is a second layer that also protects against the queue itself retrying a job. With this pattern, onEvent typically returns in single-digit milliseconds, well inside any provider's timeout, regardless of how slow fulfillment actually is.

Last updated on

On this page