Effortless Stripe Events in NestJS with Event Emitters (Generated with DALL-E)

Effortless Stripe Events in NestJS with Event Emitters

Combine the strength of Stripe's webhooks with the flexibility of NestJS event emitters to listen for any Stripe event anywhere in your app with minimal effort! Bring the event closer to the appropriate service and reduce the complexity of handlers.

TC

Tristan Chin

Stripe has a wide variety of different events that occur during your customer's lifecycle. Each of those events can be listened to, on your own backend, via webhooks configured in the Stripe dashboard. With so many possible events and the more events you listen to, you might start to feel overwhelmed on how to handle all of them and how to delegate them to appropriate services.

This post will show you a way to handle these events closer to the services that need them in your NestJS app, instead of in a single place. This approach is specifically designed for NestJS, though you might be able to extrapolate it to another framework.

This post will be relatively short and sweet. I'm not going to go over setting up a new NestJS project, registering new modules in the AppModule, configuring webhooks in the Stripe dashboard, etc. I want to focus on the technique and aspects specific to it, so I'll assume you know the basics about how NestJS and Stripe work. If you have any question about something I don't cover here, feel free to ask in the comments at the end of this post!

The "naïve" approach

One quick solution could be to setup a single endpoint and service that receive all possible events. Then, in a switch statement, you can call different methods of your service, depending on the event's type. These methods may need to delegate the events to more services spread across your app, so you'd need to import each of those. Because you don't want to risk circular dependencies, you might also be tempted to do all of this in a dedicated "WebhooksModule" that only depends on other modules and is never depended on.

  • webhooks.controller.ts
  • webhooks.service.ts
@Controller("webhooks")
export class WebhooksController {
  private stripe: Stripe;
  constructor(
    private webhooksService: WebhooksService
  ) {
    this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  }
  
  @Post("stripe")
  async handleStripeWebhook(@Req() req: RawBodyRequest<Request>) {
    // Shortened for brievety, but you would probably also do some error handling here
    const event = this.stripe.webhooks.constructEvent(
      req.rawBody,
      req.headers["stripe-signature"],
      process.env.STRIPE_WEBHOOK_SECRET
    );
    return this.webhooksService.handleStripeEvent(event);
  }
}

The more events you start to support, the larger this file will grow. It's also a bit annoying to have to "register" the event in the WebhooksService AND the appropriate service that handles some events. While I don't think this is "bad code" necessarily, I personally don't like having extremely huge files that just end up delegating to other services (files). If you're already doing this and it works, then you don't necessarily need to look for a better way.

Using event emitters

Setup

Let's look at how we can leverage NestJS' event emitters to dispatch arbitrary events across our app. First, if you haven't done so already, install the required packages and import them in your AppModule.

npm i @nestjs/event-emitter
  • app.module.ts
import { EventEmitterModule } from "@nestjs/event-emitter"; 

@Module({
  imports: [
    EventEmitterModule.forRoot({
      wildcard: true,
    }),
    // ...
  ],
  // ...
})
export class AppModule {}

That's all it takes to setup the event-emitter package for your app. Now you can start emitting and subscribing to events anywhere you want!

OnStripeEvent decorator

We'll create a wrapper around the OnEvent decorator so that we can properly type Stripe events. Since event emitters support wildcard patterns, we can listen to more than just one Stripe event. For example, Stripe has these different events:

  • customer.created
  • customer.deleted
  • customer.discount.created
  • customer.discount.deleted

While you're probably fine referencing these events individually, it's nice to know that you can listen to customer.* to listen for customer.created and customer.deleted all at once. You can also listen to customer.** to listen to all the previously listed events (and more). We want our wrapper to support these wildcards too.

For this, I highly suggest you install ts-toolbelt to create the type I'm about to show you. But I'll also leave a much more complex type that achieves the same, should you choose not to install it.

npm i ts-toolbelt
  • with ts-toolbelt
  • without ts-toolbelt
import { applyDecorators } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import Stripe from "stripe";
import { L, S } from "ts-toolbelt";

type WildcardPatterns<
  T extends string,
  Namespace extends string = S.Join<L.Pop<S.Split<T, ".">>, ".">,
> = T extends `${string}.${string}`
  ? T | `${Namespace}.*` | `${Namespace}.**` | Exclude<WildcardPatterns<Namespace>, Namespace>
  : never;

export const OnStripeEvent = (event: "*" | WildcardPatterns<Stripe.Event["type"]>) => {
  return applyDecorators(OnEvent(`stripe.${event}`, { suppressErrors: false }));
};

export type StripeEventData<T extends Stripe.Event["type"]> = Extract<Stripe.Event, { type: T }>;

Note the { suppressErrors: false } . By default, errors thrown from event listeners won't propagate to the place that emitted them. By toggling this to false, we'll be able to catch errors later, which is extremely important if you want Stripe to know when something went wrong. Without it, you'll always be sending back 201 OK to Stripe and will never know something went wrong!

Webhook setup

Now, all that's left is to setup our webhook endpoint and dispatch the event. Since we're using event emitters, it doesn't matter where you setup your endpoint, since we won't be depending on any other service (we're emitting the event for the other services to listen to). I'm actually going to do this on my StripeModule, but place yours wherever it makes sense to you!

  • stripe.controller.ts
  • stripe.service.ts
  • main.ts
import { Controller, Post, RawBodyRequest, Req } from "@nestjs/common";
import { Request } from "express";
import { StripeService } from "./stripe.service";

@Controller("stripe")
export class StripeController {
  constructor(private stripeService: StripeService) {}

  @Post()
  async stripeWebhook(@Req() req: RawBodyRequest<Request>) {
    const event = this.stripeService.webhooks.constructEvent(
      req.rawBody,
      req.headers["stripe-signature"],
      process.env.STRIPE_WEBHOOK_SECRET
    );
    return this.stripeService.handleStripeEvent(event);
  }
}

Note the use of emitAsync, rather than the traditional emit method. This will wait for event handlers to finish before continuing. It also returns an array of each handlers' result, if that's something you want to use. Note that this will only throw the first error encountered. So if multiple handlers fail, you will only find out about the first error.

Listening for events

After you've setup your webhook in Stripe's dashboard or using a local listener with stripe listen --forward-to localhost:3001/stripe, you're ready to listen to Stripe events anywhere in your app. For example, let's update the Stripe's customer ID from the profile in our database when the customer is deleted. To do this, we can use our OnStripeEvent decorator inside the ProfileService.

  • profile.service.ts
import { Injectable } from "@nestjs/common";
import { OnStripeEvent, StripeEventData } from "src/stripe/stripe.decorator";
import { ProfileRepository } from "./profile.repository";

@Injectable()
export class ProfileService {
  constructor(private profileRepository: ProfileRepository) {}

  async findByStripeId(stripeId: string) {
    return this.profileRepository.findOne([
      { field: "stripeId", operator: "==", value: stripeId },
    ]);
  }

  @OnStripeEvent("customer.deleted")
  async handleStripeCustomerDeleted(
    event: StripeEventData<"customer.deleted">
  ) {
    const profile = await this.findByStripeId(event.data.object.id);
    if (!profile) return;
    await this.profileRepository.update(profile.id, { stripeId: null });
  }

  @OnStripeEvent("customer.*")
  async handleCustomerEvents(
    event: StripeEventData<
      "customer.deleted" | "customer.created" | "customer.updated"
    >
  ) {
    switch (event.type) {
      case "customer.created":
        // handle customer created event
        break;
      case "customer.updated":
        // handle customer updated event
        break;
      case "customer.deleted":
        // handle customer deleted event
        break;
    }
  }
}

Notice that we're using the StripeEventData type created earlier to extract the correct types for the event argument. Unfortunately, you can't use wildcards in that type. So if you use wildcards for the events, you'll need to specify each related event. It's probably possible to type it if you're a TypeScript Master, but I haven't looked into it. Let me know if you find a way!

Final thoughts

That's all there is to it! You're now ready to listen to Stripe events anywhere in your NestJS app. I hope this helps you build a cleaner codebase for your backend!

While this is nice, make sure to understand the implications and limits of the event emitters. If you're subscribing to the same event in a lot of places, you might still want to consider mixing a bit of the naïve approach by catching it in one place and delegating. This is because having too many event listeners on a same event can have a performance impact on your app. That being said, in my experience, I've never noticed any significant impact of this pattern that made me question it's use.

Buy Me a Coffee

Share this post