Stripe Checkout and subscriptions with NestJS: the webhook parts that matter
Published
Short answer
Create the Checkout session on the server, send the customer to Stripe, and treat the webhook, not the redirect, as the source of truth. Verify the webhook signature with the raw request body, store the Stripe customer and subscription IDs, handle checkout.session.completed, customer.subscription.updated, customer.subscription.deleted and invoice.payment_failed, and make your handlers safe to run twice. At Qrispy I built Stripe checkout and subscription flows with NestJS.
The flow in short
- The user picks a plan and the frontend asks your server for a Checkout session.
- The server creates the session and returns the URL of Stripe's payment page.
- The user pays on Stripe and is redirected back to your site.
- Stripe sends events to your webhook endpoint, and you update your database from those events.
Landing on the success page does not prove the payment completed. The user can close the tab or type the URL by hand. Update subscription state only from the webhook.
Creating the Checkout session
const session = await this.stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${appUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/billing`,
});
return { url: session.url };The webhook and signature check in NestJS
Stripe computes the signature over the raw request body. If the body is parsed as JSON first, the signature won't match. In NestJS, create the app with the rawBody option and use the raw body in the handler.
// main.ts
const app = await NestFactory.create(AppModule, { rawBody: true });
// stripe.controller.ts
@Post("webhooks/stripe")
async handle(
@Req() req: RawBodyRequest<Request>,
@Headers("stripe-signature") signature: string,
) {
const event = this.stripe.webhooks.constructEvent(
req.rawBody as Buffer,
signature,
process.env.STRIPE_WEBHOOK_SECRET as string,
);
await this.billing.process(event);
return { received: true };
}The events you need to handle
- checkout.session.completed: link the customer and subscription IDs to the user.
- customer.subscription.updated: record the plan or status change.
- customer.subscription.deleted: remove access.
- invoice.payment_failed: tell the user and send them to update their payment method.
Idempotency and local testing
Stripe can deliver the same event more than once. Store the event IDs you've processed and upsert subscriptions by subscription ID, so a repeated event breaks nothing.
Locally, the Stripe CLI can forward events to your machine. For the test card, use 4242 4242 4242 4242.
stripe listen --forward-to localhost:3000/webhooks/stripeQuestions
- Does landing on the success page mean the payment completed?
- No. The user may never return to that page or may open the URL by hand. Learn the real state from the webhook.
- Why does webhook signature verification fail?
- Usually because the body was parsed as JSON. You must verify the signature against the raw body; in NestJS, enable the rawBody option.
- What happens if the same webhook arrives twice?
- Stripe may resend an event. Store processed event IDs and write database updates so they give the same result when run again.
I do this kind of work
Written by Mert Yılmaz, freelance fullstack developer.