seifashraf.tech
01work02notes03blog04contact
CV
open to work
All writing

Published 8 October 2026· 4 min read

Stripe webhooks in NestJS: raw body, signature, safe retries

A Stripe webhook handler has four jobs: verify, ignore what is not yours, apply each payment once, and know when to return 200. Here is mine in NestJS.

Written by Seif Ashrafnestjsstripepostgresqlarchitecture

A Stripe webhook endpoint looks like ten lines of code. Then Stripe delivers the same event twice, or delivers it late, or your handler throws after charging the customer.

This is how I handle payment webhooks on Lov, a booking platform where a confirmed payment turns a held appointment slot into a real one.

1. Keep the raw body

Signature verification needs the exact bytes Stripe sent. NestJS parses JSON by default and throws those bytes away. Turn on rawBody when creating the app:

const app = await NestFactory.create(AppModule, { rawBody: true });

The controller then stays tiny. It has no session guard, because the signature is the authentication.

@Controller("v1/webhooks/payments")
export class StripeWebhooksController {
  constructor(private readonly payments: PaymentsService) {}
 
  @Post("stripe")
  @HttpCode(200)
  async stripe(
    @Req() req: Request & { rawBody?: Buffer },
    @Headers("stripe-signature") signature?: string,
  ): Promise<void> {
    await this.payments.processStripeWebhook(req.rawBody, signature);
  }
}

Everything interesting lives in the service, where it can be tested without HTTP.

2. Verify, then translate

stripe.webhooks.constructEvent(rawBody, signature, secret) throws when the signature is wrong. I catch that and return 401. Nothing else in the handler runs for an unsigned request.

Then I translate the Stripe event into a small shape of my own:

type PaymentWebhookEvent =
  | { kind: "confirmed"; paymentId: string }
  | { kind: "failed"; paymentId: string }
  | { kind: "ignored" };

A checkout session that completed with payment_status === "paid" becomes confirmed. Event types I do not care about become ignored. So does any event without my payment id in its metadata.

The rest of the code never sees a Stripe type. Changing provider later means rewriting one adapter.

3. Return 200 for things that are not yours

Two cases get a quiet 200:

  • ignored events.
  • A verified event whose payment id does not exist in my database.

The second one happens when a Stripe account is shared between environments. Returning an error would make Stripe retry for days. Returning 200 without detail also avoids confirming whether a given payment exists.

4. Apply each payment once

Stripe retries, and its docs say handlers must cope with duplicates. I do not keep a table of seen event ids for this. The payment row is the idempotency key.

await db.transaction(async (tx) => {
  const payment = await payments.lockById(tx, paymentId); // SELECT ... FOR UPDATE
  if (!payment || payment.status !== "initiated") return; // already settled
 
  await payments.markConfirmed(tx, payment.id);
  await bookings.promoteHold(tx, payment.consultationId);
  await tx.insert(outbox).values({
    eventType: "payment.confirmed",
    aggregateId: payment.id,
    payload: { paymentId: payment.id },
  });
});

The row lock makes two simultaneous deliveries run one after the other. The status check makes the second one a no-op. A payment only ever leaves initiated once.

The confirmation email is not sent here. It goes out through the outbox, in the same transaction, which I covered in the post on BullMQ and the outbox pattern.

5. Know which errors to acknowledge

Stripe retries on any non-2xx response. That is only useful when a retry could succeed.

SituationResponseWhy
Bad signature401Not Stripe, or the wrong secret
Database down, timeout500A retry will probably work
Payment already confirmed by another attempt200The outcome is recorded, a retry changes nothing
Customer changed the order after checkout started200A mismatch can never produce a different answer

The last two are domain errors that my confirmation transaction already recorded. I catch those specific exception types and return 200. Everything else is rethrown, so Stripe tries again.

Catch specific exception classes. A blanket catch that returns 200 turns a real outage into silently lost payments.

6. A payment that arrives late

On Lov, a slot is held while the patient pays, and the hold expires. Sometimes the webhook arrives after that.

I do not auto-book and I do not auto-refund. The payment is recorded as an anomaly for a person to resolve. Money that arrived for something no longer available is a decision, not a code path.

7. Do not trust webhooks to arrive

Webhooks get lost. An endpoint is down during a deploy, or a secret is rotated badly.

So a scheduled job looks for payments still initiated after a configurable number of minutes and asks Stripe directly what happened. It then runs the same confirm or fail logic as the webhook.

The webhook stays the main path. The sweep is only the recovery path. Because both go through the same locked, status-checked transaction, they can overlap safely.

What I'd do again

  • Controller does nothing. Service does everything.
  • One adapter that turns provider events into three kinds.
  • The payment row as the idempotency key, with a lock and a status check.
  • 200 for errors a retry cannot fix, 500 for the ones it can.
  • A reconciliation sweep, written on day one.

If you are wiring payments into a product and want it to survive real traffic, get in touch.

Share Email

See it live

Lovlov.build8.dev

Speech-therapy assessment platform, France

Building something like this?

I reply within 24 hours.

Tell me about your project
PreviousBullMQ jobs that run once: the outbox pattern in NestJSNextNext.js 16 Cache Components: what broke when I turned it on

© 2026 Seif Ashraf · Cairo · +20 100 700 4828

GitHubLinkedInInstagramFacebookblog· no cookies
Let's talk