STOP WAR IN UKRAINE 🇺🇦
All Articles

NestJS: Webhook transmitter (send event to other urls)

Published Jun 28, 2022

Intro

Imagine that you have an application with a 3rd-party service integration (e.g. Stripe, Sendgrid, etc.). Of course, the application is deployed to different environments (dev, staging, prod).

Let’s say, we have to listen to some webhook events from a 3rd party integration (for instance, Sendgrid). Here comes the problem: what if the integration supports only one webhook endpoint to post events to (Sendgrid does)? Which environment should we post events to? Is is staging, prod or test?

Of course, we should be able to listen to webhook events on any environment. What’s the solution?

It’s easy. Webhook events will be published to the most stable environment only (e.g. prod) and then will be reposted to other environments.

Solution

Firstly, create a new module: nest g module webhooks.

Edit webhooks.controller.ts:

@Controller('webhooks')
export class WebhooksController {
  constructor(private readonly service: WebhooksService) {}

  @Post('/example')
  @UseInterceptors(RepostWebhookInterceptor('example'))
  handler(@Body() body: any) {
    //
  }
}

This API will be available by /webhooks/example in all environments.

Interceptor

import { HttpService } from '@nestjs/axios';
import { CallHandler, ExecutionContext, Inject, mixin, NestInterceptor } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { from, lastValueFrom, Observable, tap } from 'rxjs';

export function RepostWebhookInterceptor(hookName: string): any {
  class RepostWebhookMixin implements NestInterceptor {
    constructor(
      private readonly httpService: HttpService,
      @Inject(ConfigService) private readonly configService: ConfigService,
    ) {}

    intercept(
      context: ExecutionContext,
      next: CallHandler<any>,
    ): Observable<any> | Promise<Observable<any>> {
      const body = context.switchToHttp().getRequest().body;

      const isEnabled = this.configService.get('SHOULD_REPOST_WEBHOOKS', { infer: true });
      const urls = JSON.parse(this.configService.get('WEBHOOK_REPOST_URLS', { infer: true }));

      if (isEnabled) {
        return next
          .handle()
          .pipe(
            tap(() =>
              from(urls).forEach((url) =>
                lastValueFrom(this.httpService.post(url + hookName, body)),
                // you can add authorization here, if needed
              ),
            ),
          );
      }

      return next.handle();
    }
  }

  return mixin(RepostWebhookMixin);
}

As you can see we have two env variables: SHOULD_REPOST_WEBHOOKS and WEBHOOK_REPOST_URLS.

The first variable must be set to true only on the one of the environments.

Example

Production .env:

SHOULD_REPOST_WEBHOOKS=true
WEBHOOK_REPOST_URLS=["https://test.env/webhooks/", "https://staging.env/webhooks/"]

The second variable should include the webhook controller path. In our example, it’s /webhooks/.

So, RepostWebhookInterceptor receives a hook name that should be reposted.

@Post('/example')
@UseInterceptors(RepostWebhookInterceptor('example'))

The hook name must be the same as the webhook handler URL.

Now, when webhook handler is triggered it’ll also check if it should repost this event to any other environment.

Okay, as you see, this method just transmits webhooks to other urls. It’s the easiest solution and it works.