Stripe webhooks in NestJS, handled exactly once
Your app learns what happened in Stripe from webhooks: a checkout finished, a plan changed, an invoice was paid. The handler looks simple, and the first version usually works in testing. Then production shows you the three ways it goes wrong:
- Anyone can call it. The URL is public. Without checking the signature, a forged request can put a workspace on a paid plan.
- Stripe sends some events more than once. Delivery is at least once. Handle a repeat naively and the customer gets a second receipt, or a second “welcome to Pro” email.
- Events arrive out of order. A
customer.subscription.updatedcan land before thecreated, or an older update after a newer one. Copy each payload into your database as it comes and you can roll a plan back to a state that is no longer true.
This guide fixes all three in NestJS, with Postgres and Drizzle. The ideas carry over to any ORM.
1. Verify the signature, on the raw body
Stripe signs every request. Checking the signature needs the exact bytes Stripe sent, and Express parses the JSON before your controller sees it. In main.ts, ask Nest to keep the raw body too:
const app = await NestFactory.create(AppModule, { rawBody: true });Then verify it in the controller, before anything else:
@Controller('billing/webhook')export class WebhookController { constructor( private readonly stripe: Stripe, private readonly billing: BillingService, ) {}
@Public() // no session: the signature proves it is Stripe @Post() @HttpCode(200) async receive( @Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') signature: string, ) { let event: Stripe.Event; try { event = await this.stripe.webhooks.constructEventAsync( req.rawBody!, signature, process.env.STRIPE_WEBHOOK_SECRET!, ); } catch { throw new BadRequestException('Invalid signature'); } await this.billing.handleEvent(event); return { received: true }; }}@Public() stands for however your app skips its auth guard on one route. The signature does that job here.
2. Record the event’s ID, in the same transaction as its effects
Every event has an ID, and a repeat has the same one. Keep a table of the events you have handled, keyed by that ID:
export const billingEvents = pgTable('billing_events', { id: text().primaryKey(), type: text().notNull(), processedAt: timestamp({ withTimezone: true }) .defaultNow() .notNull(),});Inserting the ID tells you whether you have seen it, in one statement and without a race: on conflict do nothing inserts no row for a repeat.
type Outcome = 'processed' | 'duplicate';
async handleEvent(event: Stripe.Event): Promise<Outcome> { return this.db.transaction(async (tx) => { const recorded = await tx .insert(billingEvents) .values({ id: event.id, type: event.type }) .onConflictDoNothing() .returning({ id: billingEvents.id }); if (recorded.length === 0) return 'duplicate';
await this.apply(tx, event); return 'processed'; });}The transaction is the important part. Recording the ID and applying the event either both happen or neither does:
- Record first and save the effects separately, and a crash in between marks the event handled when it was not. Stripe’s retry is then skipped as a duplicate, and the change is lost.
- Apply first and record after, and a crash in between applies it twice.
In one transaction, a failure saves nothing. Stripe sends the event again, and it is handled from scratch.
3. Don’t trust the payload: fetch the latest state
Because events can arrive late or out of order, do not copy the subscription out of the event. Use the event only to learn which subscription changed (from checkout.session.completed or a customer.subscription.* event), and fetch its current state from Stripe:
private async apply(tx: Transaction, event: Stripe.Event) { const id = subscriptionOf(event); if (!id) return;
const row = toRow(await this.stripe.subscriptions.retrieve(id)); await tx .insert(subscriptions) .values(row) .onConflictDoUpdate({ target: subscriptions.subscriptionId, set: row, });}Now the order does not matter. An old event that arrives late fetches the newest state, the same as the latest event would. It costs one API call per event, which is nothing next to a customer on the wrong plan.
4. Send emails only if the change is saved
A paid invoice should send a receipt. Send it straight from the handler, though, and two things go wrong. If the transaction then fails, the customer has a receipt for a change you did not save. And when Stripe retries, they get a second one.
Instead, write the email into a table in the same transaction (an outbox), and let a worker send what is in it:
if (event.type === 'invoice.paid') { const invoice = event.data.object; await tx.insert(outbox).values({ key: `receipt:${invoice.id}`, // unique: one per invoice job: 'send-email', payload: { to: invoice.customer_email, template: 'receipt', invoiceUrl: invoice.hosted_invoice_url, }, });}The email exists only if the event was recorded. The unique key is a second guard: whatever else queues a receipt for this invoice, only one is sent. A worker reads the outbox and sends it, with its own retries if your email provider is down.
5. Let failures fail
It is tempting to wrap the handler in a try and always answer 200, so Stripe stops complaining. Don’t. An error answer is how Stripe knows to send the event again, and in live mode it keeps retrying for up to three days. Because of steps 2 to 4, handling an event a second time is safe, so let it.
Answer 400 only for a bad signature, which no retry can fix. Everything else that fails should answer 500.
Test it locally
The Stripe CLI forwards real test events to your machine:
stripe listen --forward-to localhost:3000/api/billing/webhookstripe trigger checkout.session.completedstripe listen prints the signing secret to put in STRIPE_WEBHOOK_SECRET. To check that repeats are harmless, send the same event again with stripe events resend <event id>: your log should say duplicate, and no second email should arrive.
The checklist
- Verify the signature on the raw body, and answer 400 when it is wrong
- Record each event’s ID with a unique key, in the same transaction as its effects
- Fetch the subscription’s current state instead of copying the event
- Queue emails and other side effects in that transaction, with a unique key
- Answer 500 when something fails, and let Stripe retry
This is how SaaS Stack handles billing webhooks, for Stripe and for Polar, with tests for repeats, bad signatures and events that arrive late. The billing docs say more about how it fits together.
All of this, already built
SaaS Stack comes with this done and tested, along with auth, teams, billing, emails, a phone app and deploy. Click through it in the live demo.