Shopify Webhook Processing in Nestjs

Shopify Webhook Processing in Nestjs

Webhooks are simple requests that many apps or websites use to be in sync with each other. An example would be working with Razorpay API. Razorpay will handle the payment processing of users and inform us about payment information. We can subscribe to Razorpay webhooks to get information about users' payments.

Webhook endpoints are public and anyone can send a request to these endpoints. To protect these endpoints we process the request before handling them.

Prerequisites

I am assuming that you have some knowledge of building Shopify apps. You have a basic understanding of how nestjs project works.

Shopify provides a method to process the webhook request i.e. Shopify.Webhooks.Registry.process(request,response) but let's see how to process it manually in nestjs.

Shopify webhook processing method.


@Controller('/api/webhook')
export class ShopifyWebhookController {

  @Post('/')
  async processWebhook(
    @Req() req: RawBodyRequest<IncomingMessage>,
    @Res() res: Response,
  ) {
       await Shopify.Webhooks.Registry.process(req, res);
       console.log(`Webhook processed, returned status code 200`);
       res.status(200).send("completed");
  }
}

Now let's see how to process Shopify webhooks. First, we have to create a Shopify webhook module with its controller and service.

src/shopify-webhook/shopify-webhook.module.ts

@Module({
  controllers: [ShopifyWebhookController],
  providers: [ShopifyWebhookService],
  exports: [ShopifyWebhookService],
})
export class ShopifyWebhookModule {}

We have exported the webhook service so that other modules can use this service. We can register and add handlers for Shopify webhooks in the onModuleInit function.

@Module({
  controllers: [ShopifyWebhookController],
  providers: [ShopifyWebhookService],
  exports: [ShopifyWebhookService],
})
export class ShopifyWebhookModule implements OnModuleInit {
  constructor(private readonly shopifyWebhookService: ShopifyWebhookService) {}

  onModuleInit() {
    Shopify.Webhooks.Registry.addHandlers({
      APP_UNINSTALLED: {
        path: '/api/webhook/',
        webhookHandler: this.shopifyWebhookService.processUninstall,
      },
    });
  }
}

This will add an app uninstall webhook to the local Shopify registry. To register the webhook we have to call Shopify.Webhooks.Registry.register({topic:"",shop:"",accessToken:"",path:""}).

The register function takes in the topic or type of webhook. Shop's name to register webhook for that shop. The access token that we get when we install the app. It also requires a path. When someone uninstalls the app, Shopify will send a request to this path.

To validate webhook requests we require a raw body. To get the raw body of the request we have to tell nest.js to provide it with every request. We can do this in nest bootstrap file

src/main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    rawBody: true,
  });

  const port = process.env.BACKEND_PORT || process.env.PORT;
  await app.listen(port);
}
bootstrap();

Let's create the controller which will handle the webhook request.

src/shopify-webhook/shopify-webhook.controller.ts

@Controller('/api/webhook')
export class ShopifyWebhookController {
  @Post('/')
  async processWebhook(
    @Req() req: RawBodyRequest<IncomingMessage>,
    @Res() res: Response,
  ) {}
}

All the Shopify webhooks will hit this endpoint and this controller will handle it. We have to protect this endpoint from random requests. We can add a guard before our controller to protect the endpoint. Leave the controller empty for now.

Let's create the guard that will protect the controller.

@Injectable()
export class VerifyWebhook implements CanActivate {
  canActivate(context: ExecutionContext): boolean | Promise<boolean> {

  }
}

Shopify will send a hash with every webhook request that we can use to verify the request. Shopify creates this hash with the request's raw body and Shopify API secret. We have to generate a hash and compare it with the webhook request's hash. Let's see how to do it.

import {
  BadRequestException,
  CanActivate,
  ExecutionContext,
  Injectable,
  InternalServerErrorException,
  RawBodyRequest,
  UnauthorizedException,
} from '@nestjs/common';
import Shopify from '@shopify/shopify-api';
import { createHmac, timingSafeEqual } from 'crypto';
import { IncomingMessage } from 'http';

@Injectable()
export class VerifyWebhook implements CanActivate {
  canActivate(context: ExecutionContext): boolean | Promise<boolean> {
    const req = context
      .switchToHttp()
      .getRequest<RawBodyRequest<IncomingMessage>>();

    const hmacHeader = req.headers['X-Shopify-Hmac-Sha256'] as string;

    if (!hmacHeader || typeof hmacHeader !== 'string') {
      throw new UnauthorizedException(
        `Not Authorized`,
      );
    }  

    if (!req.rawBody) {
      throw new InternalServerErrorException(
        `Raw body not found`,
      );
    }

    const generatedHash = createHmac('sha256', 'shopify api secret')
      .update(req.rawBody)
      .digest('base64');

    const generatedHashBuffer = Buffer.from(generatedHash);
    const hmacBuffer = Buffer.from(hmacHeader);

    if (generatedHashBuffer.length !== hmacBuffer.length) {
       throw new UnauthorizedException(
        `Not Authorized`,
      );
    }

    if (!timingSafeEqual(generatedHashBuffer, hmacBuffer)) {
       throw new UnauthorizedException(
        `Not Authorized`,
      );
    }

    return true;
  }
}

Here we are using the request object from context. We are first checking whether the current request has a hash or not. If the request doesn't have a hash then we reject it. Then we are generating a hash from createHmac function by the crypto library. We are using both Shopify API secret and raw body to generate the hash. Finally, we just compare them with the timingSafeEqual function.

To learn more about createHmac and timingSafeEqual you can check out nodejs crypto's documentation. (Link)

Let's use this guard in Shopify webhook controller.

@Controller('/api/webhook')
export class ShopifyWebhookController {
  @UseGuards(VerifyWebhook)
  @Post('/')
  async processWebhook(
    @Req() req: RawBodyRequest<IncomingMessage>,
    @Res() res: Response,
  ) {}
}

Now we have to handle which service method will run depending on the webhook topic. So let's see how to do it


@Controller('/api/webhook')
export class ShopifyWebhookController {
  @UseGuards(VerifyWebhook)
  @Post('/')
  async processWebhook(
    @Req() req: RawBodyRequest<IncomingMessage>,
    @Res() res: Response,
  ) {
    const topic = req.headers['X-Shopify-Topic'];

    if (!req.rawBody) {
      throw new InternalServerErrorException(
        'raw body not found',
      );
    }

    const domain = req.headers['X-Shopify-Shop-Domain'];

    if (!domain || !topic) {
      throw new BadRequestException(
        `something went wrong`,
      );
    }

    const graphqlTopic = topic.toUpperCase().replace(/\//g, '_');
    const webhookEntry = Shopify.Webhooks.Registry.getHandler(graphqlTopic);

    if (!webhookEntry) {
      throw new NotFoundException(
        `No handler found`,
      );
    }

    await webhookEntry.webhookHandler(
      graphqlTopic,
      domain as string,
      rawBody.toString(),
    );

    res.status(200).send('completed');

  }
}

Here we are checking whether we have registered a handler to handle this topic. If we found a handler then we pass some arguments to it and call it. The arguments are the current topic, the shop's URL and data in raw body format. We can access these arguments in the handler.

Let's create the processUninstall handler in Shopify webhook service.

src/shopify-webhook/shopify-webhook.service.ts

import { Injectable } from '@nestjs/common';
import Shopify from '@shopify/shopify-api';

@Injectable()
export class ShopifyWebhookService {
  constructor() {}
  async processUninstall(topic: string, shop: string, body: any) {
    console.log('process uninstall function');
  }
}

The processUninstall method will have three parameters. These are the same parameters that we passed to the handler above.

With that our custom webhook processing is complete.