Idempotency

Deduplicate deliveries and survive retries correctly.

Webhook delivery is at least once, so the same event can arrive twice. Events are remembered by their provider event id for 24 hours; repeats are acknowledged with a 200 without calling your handler again.

The default store lives in memory, which is fine on a single long-running server. On serverless, each instance has its own memory, so bring a shared store.

The built-in Upstash store

A ready-made store for Upstash Redis ships with the package. It talks to the REST API over fetch, so it is Edge-safe and adds no dependencies, and it works with databases provisioned through the Vercel Marketplace. add maps onto the atomic SET NX PX, and a Redis outage fails closed: the route answers 500 and the provider retries later, so an event is never processed twice.

app/api/webhooks/stripe/route.ts
import { webhook, stripe } from "next-webhooks";
import { upstash } from "next-webhooks/stores";

export const POST = webhook({
  provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
  // Reads UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN,
  // or the Vercel KV_REST_API_* pair. Or pass { url, token }.
  idempotency: upstash(),
  handler: async (event) => {
    // ...
  },
});

Bring your own store

Any other backend implements the same two-method contract. With a Redis client, for example:

lib/webhook-store.ts
import { Redis } from "@upstash/redis";
import type { IdempotencyStore } from "next-webhooks";

const redis = Redis.fromEnv();

export const webhookStore: IdempotencyStore = {
  // Return true if the id is new, false if it was already seen.
  add: async (id, ttlMs) => {
    const result = await redis.set(id, "1", { nx: true, px: ttlMs });
    return result !== null;
  },
  // Called after a handler failure, so the retry gets processed.
  remove: async (id) => {
    await redis.del(id);
  },
};

Why retries work after a failure

When your handler throws, the event id is released from the store before the 500 goes out. Without that, the provider's retry would look like a duplicate and be acknowledged without ever being processed. This is a classic webhook bug and it is handled for you.

Options

  • Pass idempotency: false to turn deduplication off
  • Set idempotencyTtlMs to change how long ids are remembered (default 24 hours)
  • Events without a provider id are never deduplicated

Slow work

Providers retry when you respond too slowly. Acknowledge fast and push heavy work past the response with next/server's after(). Anything inside after() runs once the 200 is already sent, so keep work that must never be lost inside the handler itself.

import { after } from "next/server";

export const POST = webhook({
  provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
  handler: async (event) => {
    await markInvoicePaid(event);          // retried if it throws
    after(() => sendReceiptEmail(event));  // best effort, after the 200
  },
});