A browser redirect is not a payment. Webhooks retry, so your table has to refuse the second delivery

Stripe, GitHub, Shopify, and every wallet you have ever integrated will POST the same event twice. Here is a five-step inbox that works on NestJS and Prisma — and still works if your provider is not Stripe.

I opened daily.dev this morning and the feed was the same argument in three costumes. A Stripe webhook production guide. A cart post whose whole second half is “they sent me duplicates.” A NestJS prompt about charge.succeeded on Black Friday. The useful sentence is not about my apps. It is this: a webhook is at-least-once delivery. If you treat the HTTP request like a normal POST, you will double-charge, double-fulfil, or double-provision a workspace.

Alex CloudStar wrote the guide that keeps recirculating: verify the raw bytes, unique on event id, ACK fast, refetch current state, do not trust payload order. That list is not Stripe-only. GitHub signs X-Hub-Signature-256. Shopify sends X-Shopify-Hmac-Sha256. Khalti, Razorpay, PayPal, and eSewa all retry. The inbox is the same. The header name changes.

Webhooks are not a normal API call. They are a message queue with weird rules, and if you do not respect those rules you ship a product that quietly corrupts billing state.

Alex CloudStar, Stripe Webhooks in Production — via daily.dev

I ship this on Lahebo (Stripe) and HelloFutsall (Khalti). The steps below do not require either product. If your success_url marks an order paid, you have the same bug.

Step 1: Verify the bytes the provider signed

Most frameworks parse JSON before your handler runs. The signature was computed over the raw body. If you pass req.body into constructEvent, the check is theatre. NestJS needs rawBody: true on the app, and only this route should read it. Express needs express.raw({ type: 'application/json' }) on the webhook path, not a global json() parser. Next.js App Router: req.text(), not req.json().

import {
  Controller,
  Headers,
  HttpCode,
  Post,
  Req,
} from "@nestjs/common";
import type { RawBodyRequest } from "@nestjs/common";
import type { Request } from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

@Controller("webhooks")
export class StripeWebhookController {
  @Post("stripe")
  @HttpCode(200)
  async handle(
    @Req() req: RawBodyRequest<Request>,
    @Headers("stripe-signature") signature: string,
  ) {
    if (!req.rawBody || !signature) return { received: false };

    const event = stripe.webhooks.constructEvent(
      req.rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );

    await claimEvent("stripe", event.id, event.type, event.data);
    return { received: true };
  }
}

GitHub is the same function with a different header and HMAC. Drop the request if the signature misses. Do not parse first and verify second.

Step 2: Unique on (provider, event id). A duplicate is a 200, not a retry

One table for every provider you listen to. The provider is part of the key so Stripe’s evt_… and a numeric wallet idx cannot collide. Insert first. If the unique constraint fires, return 200 and stop. Do not findFirst then create. Two deliveries of the same event will both pass the read.

model WebhookEvent {
  id        String   @id @default(cuid())
  provider  String
  eventId   String
  eventType String
  payload   Json
  status    String   @default("pending")
  createdAt DateTime @default(now())

  @@unique([provider, eventId])
}
import { Prisma } from "@prisma/client";

export async function claimEvent(
  provider: string,
  eventId: string,
  eventType: string,
  payload: unknown,
) {
  try {
    return await prisma.webhookEvent.create({
      data: {
        provider,
        eventId,
        eventType,
        payload: payload as object,
        status: "pending",
      },
    });
  } catch (err) {
    if (
      err instanceof Prisma.PrismaClientKnownRequestError &&
      err.code === "P2002"
    ) {
      return null; // already in the inbox
    }
    throw err;
  }
}

Step 3: ACK in the request. Do the work in a worker

If the handler sends mail, calls the provider again, and writes the order in one request, a slow database becomes a retry. Stripe’s practical window is a few seconds. GitHub is similar. Insert pending, return 200, let a worker apply. You do not need Kafka. A table and a cron are an inbox.

export async function processPendingWebhooks() {
  const batch = await prisma.webhookEvent.findMany({
    where: { status: "pending" },
    orderBy: { createdAt: "asc" },
    take: 20,
  });

  for (const row of batch) {
    await applyEvent(row);
    await prisma.webhookEvent.update({
      where: { id: row.id },
      data: { status: "applied" },
    });
  }
}

Do not insert applied in the HTTP handler If you write the event as applied and then crash before the order is paid, the retry sees the unique key and skips. The provider thinks you succeeded. Your database does not. Claim as pending. Apply in the worker. Only then mark applied. A crash leaves a pending row a cron can finish.

Step 4: Refetch. The payload is a rumour

Events do not arrive in order. A subscription.updated for plan C can land before the one for plan B. The body is what was true when they queued it. Retrieve the live object by id and upsert that. For providers without a retrieve API, store a timestamp or version from the payload and ignore older rows.

export async function applyStripeSubscription(subscriptionId: string) {
  const live = await stripe.subscriptions.retrieve(subscriptionId);
  return prisma.subscription.upsert({
    where: { stripeId: live.id },
    create: {
      stripeId: live.id,
      status: live.status,
      priceId: live.items.data[0]?.price.id,
    },
    update: {
      status: live.status,
      priceId: live.items.data[0]?.price.id,
    },
  });
}

// Call this from the webhook worker and from success_url.
// Whichever runs first wins. The other is a no-op.

Step 5: What I would copy, and what I would not

The feed will move on. The useful part is boring and it travels. Verify the bytes. Unique on the event. Return 200. Refetch current state. The redirect is for the human. The webhook is for the money — and for the GitHub deploy, the Shopify order, and the wallet callback you have not named yet.