Published · 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.
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:
ignoredevents.- 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.
| Situation | Response | Why |
|---|---|---|
| Bad signature | 401 | Not Stripe, or the wrong secret |
| Database down, timeout | 500 | A retry will probably work |
| Payment already confirmed by another attempt | 200 | The outcome is recorded, a retry changes nothing |
| Customer changed the order after checkout started | 200 | A 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.
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.