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;
}
}
- Stripe: event.id. GitHub: X-GitHub-Delivery. Shopify: X-Shopify-Webhook-Id. Khalti: idx. If the provider has no id, hash the raw body and use that.
- P2002 (or SQLSTATE 23505) means you already have the row. Answer 200 so the provider stops retrying.
- Business idempotency is separate. Two different event ids can still mean “this order is already paid.” Your apply function must no-op on a paid row.
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.
- The return URL may poll applyStripeSubscription. It must not SET status = paid from a query string.
- invoice.paid and checkout.session.completed can both land. Unique event id stops double work. The upsert stops double entitlements.
- Same rule off Stripe: look the payment up at the provider, then write your row. A ?ok=1 redirect is UX, not a source of truth.
Step 5: What I would copy, and what I would not
- Copy: raw-body signatures, unique (provider, eventId), pending then worker, refetch or version-compare, one secret per provider.
- Copy: a daily reconcile. List recent events from the provider and claim any id you do not have. Webhooks get lost. The job is the backstop.
- Do not copy: Kafka, SQS, or Redis as the first inbox. A Postgres row is enough until it is not.
- Do not copy: verifying a parsed JSON object because a tutorial used req.body.
- Do not copy: marking paid from success_url, ?pidx=, or a client-side “Payment successful” screen.
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.