So far, in this series, we’ve interacted with Stripe by sending requests. It was either by requesting the Stripe API directly on the frontend, or the backend. With webhooks, Stripe can communicate with us the other way around.
Webhook is a URL in our API that Stripe can request to send us various events such as information about payments or customer updates. In this article, we explore the idea of Webhooks and implement them into our application to avoid asking Stripe about the status of user’s subscriptions. By doing that, we aim to improve the performance of our application and avoid exceeding rate limits.
Using Stripe webhooks with NestJS
We aim to develop with Stripe webhooks while running the application on localhost. When working with webhooks, we expect Stripe to make requests to our API. By default, our app can’t be accessed from outside while running locally. Because of that, we need an additional step to test webhooks. To perform it, we need Stripe CLI. We can download it here.
We need to forward received events to our local API. To do it, we need to run the following:
1stripe listen --forward-to localhost:3000/webhookHandling webhook signing secret
In response, we receive the webhook signing secret. We will need it in our API to validate requests made to our /webhook endpoint.
A valid approach is to keep the webhook secret in our environment variables.
1import { Module } from '@nestjs/common';
2import { ConfigModule } from '@nestjs/config';
3import * as Joi from '@hapi/joi';
4
5@Module({
6 imports: [
7 ConfigModule.forRoot({
8 validationSchema: Joi.object({
9 STRIPE_WEBHOOK_SECRET: Joi.string(),
10 // ...
11 })
12 }),
13 // ...
14 ],
15 controllers: [],
16 providers: [],
17})
18export class AppModule {}1STRIPE_WEBHOOK_SECRET=whsec_...
2# ...Accessing the raw body of a request
NestJS uses the body-parser library to parse incoming request bodies. Because of that, we don’t get to access the raw body straightforwardly. The Stripe package that we need to use to work with webhooks requires it, though.
To deal with the above issue, we can create a middleware that attaches the raw body to the request.
1import { Response } from 'express';
2import { json } from 'body-parser';
3import RequestWithRawBody from '../stripeWebhook/requestWithRawBody.interface';
4
5function rawBodyMiddleware() {
6 return json({
7 verify: (request: RequestWithRawBody, response: Response, buffer: Buffer) => {
8 if (request.url === '/webhook' && Buffer.isBuffer(buffer)) {
9 request.rawBody = Buffer.from(buffer);
10 }
11 return true;
12 },
13 })
14}
15
16export default rawBodyMiddlewareIf you want to know more about middleware, check out TypeScript Express tutorial #1. Middleware, routing, and controllers
Above, we use the RequestWithRawBody interface. We need to define it.
1import { Request } from 'express';
2
3interface RequestWithRawBody extends Request {
4 rawBody: Buffer;
5}
6
7export default RequestWithRawBody;For the middleware to work, we need to use it in our bootstrap function.
1import { NestFactory } from '@nestjs/core';
2import { AppModule } from './app.module';
3import rawBodyMiddleware from './utils/rawBody.middleware';
4
5async function bootstrap() {
6 const app = await NestFactory.create(AppModule);
7
8 app.use(rawBodyMiddleware());
9
10 // ...
11
12 await app.listen(3000);
13}
14bootstrap();Parsing the webhook request
When Stripe requests our webhook route, we need to parse the request. To do that successfully, we need three things:
- the webhook secret,
- the raw request payload,
- the stripe-signature request header.
With the stripe-signature header we can verify that the events were sent by Stripe and not by some third party.
When we have all of the above, we can use the Stripe library to construct the event data.
1import { Injectable } from '@nestjs/common';
2import { ConfigService } from '@nestjs/config';
3import Stripe from 'stripe';
4
5@Injectable()
6export default class StripeService {
7 private stripe: Stripe;
8
9 constructor(
10 private configService: ConfigService
11 ) {
12 this.stripe = new Stripe(configService.get('STRIPE_SECRET_KEY'), {
13 apiVersion: '2020-08-27',
14 });
15 }
16
17 public async constructEventFromPayload(signature: string, payload: Buffer) {
18 const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET');
19
20 return this.stripe.webhooks.constructEvent(
21 payload,
22 signature,
23 webhookSecret
24 );
25 }
26
27 // ...
28}The last step in managing the Stripe webhook with NestJS is to create a controller with the /webhook route.
1import { Controller, Post, Headers, Req, BadRequestException } from '@nestjs/common';
2import StripeService from '../stripe/stripe.service';
3import RequestWithRawBody from './requestWithRawBody.interface';
4
5@Controller('webhook')
6export default class StripeWebhookController {
7 constructor(
8 private readonly stripeService: StripeService,
9 ) {}
10
11 @Post()
12 async handleIncomingEvents(
13 @Headers('stripe-signature') signature: string,
14 @Req() request: RequestWithRawBody
15 ) {
16 if (!signature) {
17 throw new BadRequestException('Missing stripe-signature header');
18 }
19
20 const event = await this.stripeService.constructEventFromPayload(signature, request.rawBody);
21
22 // ...
23 }
24}Tracking the status of subscriptions
One of the things we could do with webhooks is tracking the status of subscriptions. To do that, let’s expand the User entity.
1import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
2
3@Entity()
4class User {
5 @PrimaryGeneratedColumn()
6 public id: number;
7
8 @Column({ unique: true })
9 public email: string;
10
11 @Column({ nullable: true })
12 public monthlySubscriptionStatus?: string;
13
14 // ...
15}
16
17export default User;We also need a way to set the monthlySubscriptionStatus property. To do that, we need a new method in our UsersService:
1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository, Connection, In } from 'typeorm';
4import User from './user.entity';
5import { FilesService } from '../files/files.service';
6import StripeService from '../stripe/stripe.service';
7
8@Injectable()
9export class UsersService {
10 constructor(
11 @InjectRepository(User)
12 private usersRepository: Repository<User>,
13 ) {}
14
15 async updateMonthlySubscriptionStatus(
16 stripeCustomerId: string, monthlySubscriptionStatus: string
17 ) {
18 return this.usersRepository.update(
19 { stripeCustomerId },
20 { monthlySubscriptionStatus }
21 );
22 }
23
24 // ...
25}To use the above logic, we need to expand our StripeWebhookController:
1import { Controller, Post, Headers, Req, BadRequestException } from '@nestjs/common';
2import StripeService from '../stripe/stripe.service';
3import RequestWithRawBody from './requestWithRawBody.interface';
4import { UsersService } from '../users/users.service';
5import Stripe from 'stripe';
6
7@Controller('webhook')
8export default class StripeWebhookController {
9 constructor(
10 private readonly stripeService: StripeService,
11 private readonly usersService: UsersService
12 ) {}
13
14 @Post()
15 async handleIncomingEvents(
16 @Headers('stripe-signature') signature: string,
17 @Req() request: RequestWithRawBody
18 ) {
19 if (!signature) {
20 throw new BadRequestException('Missing stripe-signature header');
21 }
22
23 const event = await this.stripeService.constructEventFromPayload(signature, request.rawBody);
24
25 if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.created') {
26 const data = event.data.object as Stripe.Subscription;
27
28 const customerId: string = data.customer as string;
29 const subscriptionStatus = data.status;
30
31 await this.usersService.updateMonthlySubscriptionStatus(customerId, subscriptionStatus)
32 }
33 }
34}Above, we had to sort out some TypeScript issues. Currently, Stripe recommends casting to deal with them.
In our flow, Stripe calls our /webhook endpoint and sends us events. We check if they are connected to subscriptions by checking the event.type property. If that’s the case, we can assume that the event.data.object property is a subscription. With that knowledge, we can update the monthlySubscriptionStatus property of a user.
Webhook idempotency
According to the Stripe documentation, Stripe might occasionally send the same event more than once. They advise us to create a mechanism to guard the application against processing the same event multiple times and making our event processing idempotent.
One way of doing so would be to keep the id of every processed event in the database.
1import { Entity, PrimaryColumn } from 'typeorm';
2
3@Entity()
4class StripeEvent {
5 @PrimaryColumn()
6 public id: string;
7}
8
9export default StripeEvent;Please notice that above we define a primary column that is not auto-generated. We aim to use the event id from Stripe to populate this column.
1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import StripeEvent from './StripeEvent.entity';
4import { Repository } from 'typeorm';
5
6@Injectable()
7export default class StripeWebhookService {
8 constructor(
9 @InjectRepository(StripeEvent)
10 private eventsRepository: Repository<StripeEvent>
11 ) {}
12
13 createEvent(id: string) {
14 return this.eventsRepository.insert({ id });
15 }
16}A crucial thing to notice is that the createEvent throws an error when we try to use an id that already exists in the database. We can use it to improve our StripeWebhookController.
1import { Controller, Post, Headers, Req, BadRequestException } from '@nestjs/common';
2import StripeService from '../stripe/stripe.service';
3import RequestWithRawBody from './requestWithRawBody.interface';
4import { UsersService } from '../users/users.service';
5import StripeWebhookService from './stripeWebhook.service';
6
7@Controller('webhook')
8export default class StripeWebhookController {
9 constructor(
10 private readonly stripeService: StripeService,
11 private readonly usersService: UsersService,
12 private readonly stripeWebhookService: StripeWebhookService
13 ) {}
14
15 @Post()
16 async handleIncomingEvents(
17 @Headers('stripe-signature') signature: string,
18 @Req() request: RequestWithRawBody
19 ) {
20 if (!signature) {
21 throw new BadRequestException('Missing stripe-signature header');
22 }
23
24 const event = await this.stripeService.constructEventFromPayload(signature, request.rawBody);
25
26 if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.created') {
27 return this.stripeWebhookService.processSubscriptionUpdate(event);
28 }
29 }
30}Since our controller keeps growing, let’s move part of the logic to our StripeWebhookService.
1import { BadRequestException, Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import StripeEvent from './StripeEvent.entity';
4import { Repository } from 'typeorm';
5import Stripe from 'stripe';
6import PostgresErrorCode from '../database/postgresErrorCode.enum';
7import { UsersService } from '../users/users.service';
8
9@Injectable()
10export default class StripeWebhookService {
11 constructor(
12 @InjectRepository(StripeEvent)
13 private eventsRepository: Repository<StripeEvent>,
14 private readonly usersService: UsersService,
15 ) {}
16
17 createEvent(id: string) {
18 return this.eventsRepository.insert({ id });
19 }
20
21 async processSubscriptionUpdate(event: Stripe.Event) {
22 try {
23 await this.createEvent(event.id);
24 } catch (error) {
25 if (error?.code === PostgresErrorCode.UniqueViolation) {
26 throw new BadRequestException('This event was already processed');
27 }
28 }
29
30 const data = event.data.object as Stripe.Subscription;
31
32 const customerId: string = data.customer as string;
33 const subscriptionStatus = data.status;
34
35 await this.usersService.updateMonthlySubscriptionStatus(customerId, subscriptionStatus);
36 }
37}With the above code, our endpoint throws an error when Stripe sends the same event again.
Deleting old events with cron might be a good idea. If you want to do that, check out API with NestJS #25. Sending scheduled emails with cron and Nodemailer
Summary
In this article, we’ve learned more about Stripe and improved our application by reacting to Stripe events. To do that, we’ve had to implement a webhook that accepts requests from Stripe. When doing so, we’ve started to track changes in the subscription statuses. We’ve also made sure that we don’t parse the same event more than once.