Multi-Tenant Payment Webhook Processor

Build a NestJS backend that verifies, routes, and queues Paystack webhooks.

Introduction

30 Second Summary

Every time you tap "Pay" on a food delivery app or subscription service, a payment provider fires a notification to the merchant's backend. That backend has to verify the notification is real, figure out which merchant it belongs to, and record the transaction without dropping a single event.

In this project, you will build a multi-tenant payment webhook processor using NestJS, MongoDB, and BullMQ. Your service will receive payment notifications, verify their authenticity with cryptographic signatures, route them to isolated per-tenant databases, and serve real-time analytics.

What You'll Build

When you POST a simulated Paystack payment event to your running service, you will watch it get verified, queued for async processing, persisted to the correct tenant's database, and then query an analytics endpoint that computes revenue totals and success rates on the fly.

By the end of this project, you'll have:

  • A working webhook receiver that accepts a payment event, verifies its HMAC SHA512 signature, and rejects forged requests with a 401.
  • An event-driven processing pipeline where BullMQ queues handle webhook events asynchronously with automatic retries and exponential backoff, backed by Upstash Redis.
  • A real-time analytics endpoint powered by a MongoDB aggregation pipeline that computes per-tenant revenue, transaction count, success rate, and channel breakdown in one query.
  • Secret Mission: Add Jest unit tests for the webhook signature verification guard and the BullMQ payment processor, proving the architecture is testable in isolation.

Are there any prerequisites?

This project assumes you are comfortable with NestJS and TypeScript (modules, decorators, dependency injection). You should also have a basic understanding of REST APIs and MongoDB documents.

Before We Start

Before diving into code, let's lock in what you're about to build and why it matters.

This project tackles the kind of multi-tenant, event-driven payment architecture that powers real African FinTech backends.

Set Up the Project

Your multi-tenant webhook processor needs three pieces of infrastructure before you can write any processing logic: a local MongoDB database to store per-tenant transactions, a cloud Redis queue (Upstash) for async job processing, and a Paystack test account to generate webhook events.

In this step, you will install all three services, scaffold the NestJS project, and wire up the database connections so the app boots cleanly.

In this step, get ready to:
  • Install MongoDB 8.0 and create Upstash Redis and Paystack test accounts.
  • Scaffold the NestJS project and install all dependencies.
  • Configure the app module to connect to MongoDB and Redis, then verify both connections succeed on startup.
Install MongoDB and create external accounts

You need three external services running before your NestJS app can process anything. Let's set up each one.

  • Open a terminal and check whether MongoDB is already installed by running:
mongod --version

✔️ I see version 8.0 or higher

MongoDB is ready. Skip ahead to the Upstash Redis setup below.

ⓧ I see an older version

You need MongoDB 8.0 for this project. Download the MongoDB 8.0 Community Edition MSI installer from the official download page:

  • Go to mongodb.com/try/download/community.
  • Select version 8.0.28, platform Windows, and package msi.
  • Run the installer and select Complete setup type.
  • Check the Install MongoDB as a Service option (leave defaults).
  • Complete the installation wizard.
  • Close and reopen your terminal, then run mongod --version again to confirm it now shows 8.0.

ⓧ Command not found

MongoDB is not installed. Download and install MongoDB 8.0 Community Edition:

  • Go to mongodb.com/try/download/community.
  • Select version 8.0.28, platform Windows, and package msi.
  • Run the downloaded MSI installer.
  • Select Complete setup type when prompted.
  • Check the Install MongoDB as a Service option and keep the default service configuration.
  • Complete the installation wizard.
  • Close and reopen your terminal, then run mongod --version to confirm it shows version 8.0.

Where does MongoDB install?

The MSI installer places MongoDB at C:\Program Files\MongoDB\Server\8.0\bin and adds it to your system PATH automatically.

  • Verify MongoDB is running and accessible by opening the MongoDB Shell:
mongosh

You should see a connection message and a prompt like test>. This confirms MongoDB is running as a Windows service.

  • Type exit to close the shell.

mongosh not recognized?

The MongoDB Shell (mongosh) is installed separately from the server. If the MSI installer did not include it, download it from the MongoDB Shell download page. After installing, close and reopen your terminal.

Help me troubleshoot my MongoDB installation.

Next, create a free Upstash Redis database for your job queue.

  • Go to console.upstash.com and create a free account (no credit card required).
  • Click Create Database.
  • Give it any name (e.g. payment-webhooks) and select a region close to you.
  • Once created, find the Endpoint, Port, and Password in the database details. Copy all three values to a temporary text file.

Why Upstash Redis?

Upstash provides a serverless Redis instance with a generous free tier (500K commands/month). It enforces TLS on all connections, which means your data is encrypted in transit. Your BullMQ job queue will use this Redis to persist and retry jobs.

Finally, create a free Paystack test account for webhook events.

  • Go to paystack.com/signup and create a free account.
  • Your account starts in Test Mode automatically.
  • In the dashboard, navigate to Settings, then API Keys and Webhooks.
  • Copy the Test Secret Key (starts with sk_test_). Save it to your temporary text file alongside the Upstash credentials.

Keep credentials handy

You now have three values saved: Upstash endpoint, Upstash password, and Paystack test secret key. You will paste these into your .env file shortly.

Scaffold the project and install dependencies
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold a new NestJS project by running:
nest new payment-webhook-processor

What will the CLI ask?

The NestJS CLI will prompt you to choose a package manager. Select npm and press Enter. It then installs the initial project dependencies automatically.

  • Move into the new project folder by running:
cd payment-webhook-processor
  • Install the database and queue dependencies by running:
npm install @nestjs/config @nestjs/mongoose mongoose @nestjs/bullmq bullmq ioredis

What are these packages?

@nestjs/config loads environment variables from your .env file. @nestjs/mongoose and mongoose handle MongoDB connections and schemas.

@nestjs/bullmq and bullmq provide the job queue framework. ioredis is the Redis client that BullMQ uses under the hood to connect to Upstash.

You should see a success message once all packages are added to node_modules.

  • Generate the four placeholder modules your app will use by running these commands:
nest g module webhooks
nest g module tenant
nest g module payment-queue
nest g module analytics

Why generate modules now?

The final app.module.ts imports all four modules. Generating them now creates the files so NestJS can resolve the imports at startup. You will flesh out each module in later steps.

  • Create a .env file in the project root (payment-webhook-processor/) with your credentials:
MONGODB_URI=mongodb://localhost:27017/payment-processor
UPSTASH_REDIS_HOST=your-endpoint.upstash.io
UPSTASH_REDIS_PORT=6379
UPSTASH_REDIS_PASSWORD=your-upstash-password
PAYSTACK_SECRET_KEY=sk_test_your-paystack-test-secret-key
  • Replace your-endpoint.upstash.io with the Endpoint you copied from the Upstash dashboard.
  • Replace your-upstash-password with the Password from Upstash.
  • Replace sk_test_your-paystack-test-secret-key with the Paystack Test Secret Key you copied earlier.
  • Save the file.

Seeing file not found errors later?

Make sure .env is in the project root (same level as package.json), not inside src/. The @nestjs/config module looks for it at the project root by default.

Help me fix .env loading issues in my NestJS project.

Configure the app module and verify connections

With credentials in place, you will now configure the root module to connect to both MongoDB and Upstash Redis on startup.

  • Open src/app.module.ts and replace its entire contents with the following:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { BullModule } from '@nestjs/bullmq';
import { WebhooksModule } from './webhooks/webhooks.module';
import { TenantModule } from './tenant/tenant.module';
import { PaymentQueueModule } from './payment-queue/payment-queue.module';
import { AnalyticsModule } from './analytics/analytics.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    MongooseModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('MONGODB_URI'),
      }),
    }),
    BullModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        connection: {
          host: config.get<string>('UPSTASH_REDIS_HOST'),
          port: config.get<number>('UPSTASH_REDIS_PORT'),
          password: config.get<string>('UPSTASH_REDIS_PASSWORD'),
          tls: {},
        },
      }),
    }),
    WebhooksModule,
    TenantModule,
    PaymentQueueModule,
    AnalyticsModule,
  ],
})
export class AppModule {}

What does this configuration do?

ConfigModule.forRoot({ isGlobal: true }) loads your .env file and makes ConfigService available to every module without re-importing.

MongooseModule.forRootAsync() connects to your local MongoDB using the URI from the environment. BullModule.forRootAsync() sets up the Redis connection for job queues. The tls: {} object is required because Upstash enforces TLS on all connections.

  • Save src/app.module.ts.

Before you run the app, what do you expect to see if both MongoDB and Redis connections succeed? What would you see if one fails?

  • Start the development server by running:
npm run start:dev

Your terminal should show the NestJS boot sequence completing with the app listening on port 3000, with no connection errors. If MongoDB or Redis credentials were wrong, you would see connection-refused or authentication errors instead.

Seeing connection errors on startup?

  • If you see a MongoDB connection error, confirm the MongoDB service is running. Open a new terminal and run mongosh to check.
  • If you see a Redis or TLS error, double-check that UPSTASH_REDIS_HOST in your .env does not include a protocol prefix. It should be just the hostname (e.g. your-endpoint.upstash.io).
  • If you see Cannot find module errors for one of the four modules, make sure you ran all four nest g module commands from the previous substep.

Help me debug NestJS startup connection errors.

✔️ Awesome, I've got everything!

Great. Double check that you have saved both files, then leave the dev server running.

ⓧ I'd like to double check the full code

Here is the complete .env file:

MONGODB_URI=mongodb://localhost:27017/payment-processor
UPSTASH_REDIS_HOST=your-endpoint.upstash.io
UPSTASH_REDIS_PORT=6379
UPSTASH_REDIS_PASSWORD=your-upstash-password
PAYSTACK_SECRET_KEY=sk_test_your-paystack-test-secret-key

Here is the complete src/app.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { BullModule } from '@nestjs/bullmq';
import { WebhooksModule } from './webhooks/webhooks.module';
import { TenantModule } from './tenant/tenant.module';
import { PaymentQueueModule } from './payment-queue/payment-queue.module';
import { AnalyticsModule } from './analytics/analytics.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    MongooseModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('MONGODB_URI'),
      }),
    }),
    BullModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        connection: {
          host: config.get<string>('UPSTASH_REDIS_HOST'),
          port: config.get<number>('UPSTASH_REDIS_PORT'),
          password: config.get<string>('UPSTASH_REDIS_PASSWORD'),
          tls: {},
        },
      }),
    }),
    WebhooksModule,
    TenantModule,
    PaymentQueueModule,
    AnalyticsModule,
  ],
})
export class AppModule {}

Your infrastructure is wired up and the app boots cleanly. Next up, you will build the webhook receiver that accepts and verifies incoming Paystack payment events.

Build the Webhook Receiver

Your NestJS app is running with MongoDB and Redis connected. Now it needs something useful to do.

The first job of a payment backend is accepting webhook events from the payment provider and verifying they are genuine. Paystack signs every webhook with an HMAC SHA512 hash of the payload. If your service does not verify that signature, any bad actor could forge payment events and trick your system into recording fake transactions.

In this step, get ready to:
  • Create a webhook endpoint that accepts POST requests from Paystack.
  • Build a signature verification guard using HMAC SHA512.
  • Test the endpoint to confirm it accepts valid signatures and rejects forged ones.
Create the webhooks module and controller

Your app.module.ts already imports WebhooksModule as a stub. You need to replace that stub with a working module that registers a controller with a POST endpoint.

  • Open src/webhooks/webhooks.module.ts in VS Code and replace its contents with this code:
import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';

@Module({
  controllers: [WebhooksController],
})
export class WebhooksModule {}

What does this code do?

This module registers WebhooksController so NestJS knows to mount its routes. The controller file does not exist yet, so you will create it next.

  • Create a new file at src/webhooks/webhooks.controller.ts and add this code:
import { Controller, Post, Body, HttpCode } from '@nestjs/common';

@Controller('webhooks')
export class WebhooksController {
  @Post('paystack')
  @HttpCode(200)
  async handlePaystackWebhook(@Body() body: any) {
    return { status: 'received' };
  }
}

What does this code do?

The @Controller('webhooks') decorator sets the base route to /webhooks. The @Post('paystack') decorator creates a POST endpoint at /webhooks/paystack.

The @HttpCode(200) decorator forces a 200 response. Paystack requires your webhook endpoint to return 200 quickly to acknowledge receipt. If it does not, Paystack retries the delivery.

  • Save both files.
  • Check the terminal where npm run start:dev is running. The app should recompile without errors.

Before you test this, think about what will happen. Your endpoint has no authentication yet. What do you expect if you send any POST request to it?

  • Test the endpoint by sending a POST request with any JSON body:

macOS

curl -X POST http://localhost:3000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -d '{"event":"charge.success"}'

Windows

curl.exe -X POST http://localhost:3000/webhooks/paystack -H "Content-Type: application/json" -d "{\"event\":\"charge.success\"}"

You get {"status":"received"} back with no questions asked. Any request succeeds. That is the gap you will close next.

Seeing 'Cannot POST /webhooks/paystack'?

  • Make sure you saved both webhooks.module.ts and webhooks.controller.ts.
  • Check the terminal for TypeScript errors. A missing import or typo in the decorator will prevent the route from mounting.
  • Confirm the module file exports WebhooksModule and lists WebhooksController in its controllers array.

help me debug why my webhook endpoint returns Cannot POST

Create the signature verification guard

Right now your endpoint accepts anything. In production, Paystack sends an x-paystack-signature header on every webhook. This header contains an HMAC SHA512 hash of the JSON body, signed with your secret key. A NestJS Guard is the right place to verify it because guards run before the route handler and can reject requests early.

  • Create a new file at src/webhooks/paystack-signature.guard.ts and add this code:
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';

@Injectable()
export class PaystackSignatureGuard implements CanActivate {
  constructor(private readonly configService: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const signature = request.headers['x-paystack-signature'];

    if (!signature) {
      throw new UnauthorizedException('Missing x-paystack-signature header');
    }

    const secret = this.configService.get<string>('PAYSTACK_SECRET_KEY');
    const hash = crypto
      .createHmac('sha512', secret)
      .update(JSON.stringify(request.body))
      .digest('hex');

    if (hash !== signature) {
      throw new UnauthorizedException('Invalid webhook signature');
    }

    return true;
  }
}

What does this code do?

The guard reads the x-paystack-signature header from the incoming request. If the header is missing, it throws an UnauthorizedException immediately.

If the header exists, the guard computes its own HMAC SHA512 hash of the request body using your PAYSTACK_SECRET_KEY from the .env file. It then compares its computed hash to the one Paystack sent. If they do not match, the request is forged and gets rejected with 401.

  • Save paystack-signature.guard.ts.

Now apply the guard to the webhook endpoint.

  • Open src/webhooks/webhooks.controller.ts and find this import line at the top:
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
  • Replace that line with these two import statements, adding UseGuards and the guard itself:
import { Controller, Post, Body, UseGuards, HttpCode } from '@nestjs/common';
import { PaystackSignatureGuard } from './paystack-signature.guard';
  • Find the @Post('paystack') decorator and add @UseGuards(PaystackSignatureGuard) directly below it, so the method looks like this:
  @Post('paystack')
  @UseGuards(PaystackSignatureGuard)
  @HttpCode(200)
  async handlePaystackWebhook(@Body() body: any) {
    return { status: 'received' };
  }
  • Save webhooks.controller.ts.
  • Check the terminal. The app should recompile without errors.

Try the same curl command from before (no signature header). What do you expect to happen now?

macOS

curl -X POST http://localhost:3000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -d '{"event":"charge.success"}'

Windows

curl.exe -X POST http://localhost:3000/webhooks/paystack -H "Content-Type: application/json" -d "{\"event\":\"charge.success\"}"

You now get a 401 Unauthorized response. The guard is rejecting the request because there is no x-paystack-signature header. Your endpoint is no longer open to anyone.

Still seeing 200 OK?

  • Make sure you added UseGuards to the import from @nestjs/common.
  • Check that @UseGuards(PaystackSignatureGuard) appears directly above or below @Post('paystack') on the method, not on the class.
  • Verify you imported PaystackSignatureGuard from './paystack-signature.guard'.

help me figure out why my guard is not blocking requests

Test with valid and invalid signatures

You have confirmed the guard rejects requests without a signature. Now verify it accepts a correctly signed request and rejects a tampered one.

  • Compute the HMAC SHA512 hash of a test payload using your Paystack secret key. Run this in a new terminal:
node -e "const crypto = require('crypto'); const body = JSON.stringify({event:'charge.success',data:{reference:'ref_test123'}}); console.log(crypto.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY || 'sk_test_your-paystack-test-secret-key').update(body).digest('hex'));"

What does this command do?

This one-liner uses Node.js to compute the same HMAC SHA512 hash that Paystack would generate. It takes the JSON body, hashes it with your secret key, and prints the hex digest. Copy the output hash for the next step.

  • Copy the hash that was printed in your terminal.
  • Send a request with the valid signature. Replace YOUR_HASH_HERE with the hash you just copied:

macOS

curl -X POST http://localhost:3000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -H "x-paystack-signature: YOUR_HASH_HERE" \
  -d '{"event":"charge.success","data":{"reference":"ref_test123"}}'

Windows

curl.exe -X POST http://localhost:3000/webhooks/paystack -H "Content-Type: application/json" -H "x-paystack-signature: YOUR_HASH_HERE" -d "{\"event\":\"charge.success\",\"data\":{\"reference\":\"ref_test123\"}}"

You should see {"status":"received"} with a 200 OK. The signature matched, so the guard allowed the request through.

  • Now test with a tampered signature. Send the same request but change a few characters in the hash:

macOS

curl -X POST http://localhost:3000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -H "x-paystack-signature: invalid_signature_abc123" \
  -d '{"event":"charge.success","data":{"reference":"ref_test123"}}'

Windows

curl.exe -X POST http://localhost:3000/webhooks/paystack -H "Content-Type: application/json" -H "x-paystack-signature: invalid_signature_abc123" -d "{\"event\":\"charge.success\",\"data\":{\"reference\":\"ref_test123\"}}"

You get 401 Unauthorized. The guard computed the correct hash, compared it to the fake one, and rejected the request. Your webhook endpoint is now secured against forged events.

Valid signature still returns 401?

  • The JSON body in your curl command must be character-for-character identical to the body you hashed. Even extra whitespace or different key ordering breaks the match.
  • Make sure the secret key used in the node -e command matches the PAYSTACK_SECRET_KEY in your .env file exactly.
  • If you changed the payload between computing the hash and sending the curl request, recompute the hash with the new payload.

help me figure out why my valid HMAC signature is being rejected

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in src/webhooks/.

ⓧ I'd like to double check the full code

Here are the three files you should have in src/webhooks/:

import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';

@Module({
  controllers: [WebhooksController],
})
export class WebhooksModule {}
import { Controller, Post, Body, UseGuards, HttpCode } from '@nestjs/common';
import { PaystackSignatureGuard } from './paystack-signature.guard';

@Controller('webhooks')
export class WebhooksController {
  @Post('paystack')
  @UseGuards(PaystackSignatureGuard)
  @HttpCode(200)
  async handlePaystackWebhook(@Body() body: any) {
    return { status: 'received' };
  }
}
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';

@Injectable()
export class PaystackSignatureGuard implements CanActivate {
  constructor(private readonly configService: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const signature = request.headers['x-paystack-signature'];

    if (!signature) {
      throw new UnauthorizedException('Missing x-paystack-signature header');
    }

    const secret = this.configService.get<string>('PAYSTACK_SECRET_KEY');
    const hash = crypto
      .createHmac('sha512', secret)
      .update(JSON.stringify(request.body))
      .digest('hex');

    if (hash !== signature) {
      throw new UnauthorizedException('Invalid webhook signature');
    }

    return true;
  }
}

Your webhook endpoint is locked down. Only requests signed with your Paystack secret key get through. Next up, you will add multi-tenancy so each merchant's payment data is stored in its own isolated database.

Add Multi-Tenancy with Database-Per-Tenant

Right now, every webhook your service receives disappears into the void. There is no data persistence and no way to separate one merchant's transactions from another.

In a real multi-tenant SaaS, each merchant (tenant) needs completely isolated data. You will build a tenant resolution layer using MongoDB's useDb() pattern to route each webhook to the correct tenant's database without opening new connections.

In this step, get ready to:
  • Define a Transaction schema that captures the shape of a Paystack payment event.
  • Create a TenantService that returns tenant-scoped Mongoose models using the database-per-tenant pattern.
  • Update the webhook controller to persist transactions to isolated tenant databases.
Define the Transaction schema

Before you can persist payment data, you need to define the shape of a transaction document. This schema tells MongoDB exactly what fields to store and validate for every payment event.

  • Create a new file src/tenant/transaction.schema.ts and add the following code:
import { Schema, Document } from 'mongoose';

// Define the shape of a transaction document
export interface Transaction extends Document {
  event: string;
  reference: string;
  amount: number;
  currency: string;
  status: string;
  channel: string;
  paidAt: Date | null;
  customerEmail: string;
  createdAt: Date;
}

// Define the MongoDB schema with validation rules
export const TransactionSchema = new Schema<Transaction>({
  event: { type: String, required: true },
  reference: { type: String, required: true, unique: true },
  amount: { type: Number, required: true },
  currency: { type: String, required: true },
  status: { type: String, required: true },
  channel: { type: String, required: true },
  paidAt: { type: Date, default: null },
  customerEmail: { type: String, required: true },
  createdAt: { type: Date, default: Date.now },
});

What does this code do?

This file defines two things that work together:

  • The Transaction interface tells TypeScript what fields exist on a transaction document. This gives you autocomplete and type checking.
  • The TransactionSchema tells MongoDB how to validate and store those fields. The unique: true on reference prevents duplicate transactions from being saved.
Create the TenantService with database-per-tenant isolation

The key to multi-tenancy here is Mongoose's connection.useDb() method. It creates a logical database reference on top of your existing TCP connection pool. No new sockets, no extra overhead.

  • Create src/tenant/tenant.service.ts with the following code:
import { Injectable } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection, Model } from 'mongoose';
import { Transaction, TransactionSchema } from './transaction.schema';

@Injectable()
export class TenantService {
  constructor(@InjectConnection() private readonly connection: Connection) {}

  // Returns a Mongoose model scoped to the tenant's own database
  getTransactionModel(tenantId: string): Model<Transaction> {
    const dbName = `tenant_${tenantId}`;
    const tenantConnection = this.connection.useDb(dbName, { useCache: true });
    return tenantConnection.model<Transaction>('Transaction', TransactionSchema);
  }
}

What does this code do?

The getTransactionModel() method is where tenant isolation happens. Given a tenant ID like merchant_alpha, it creates a database reference called tenant_merchant_alpha.

The { useCache: true } option ensures that repeated calls for the same tenant reuse the same connection object instead of creating a new one every time. Under the hood, Mongoose reuses the existing TCP pool while giving you logically separate databases.

  • Now create src/tenant/tenant.module.ts to register the service as a global module:
import { Module, Global } from '@nestjs/common';
import { TenantService } from './tenant.service';

// @Global makes TenantService injectable anywhere without importing TenantModule
@Global()
@Module({
  providers: [TenantService],
  exports: [TenantService],
})
export class TenantModule {}

Why mark the module as @Global?

The @Global() decorator means every other module in the app can inject TenantService without explicitly importing TenantModule in their own imports array. Since tenant resolution is needed across webhooks, queues, and analytics, a global module saves repetitive boilerplate.

  • Save all three files. Switch to your terminal where npm run start:dev is running and confirm you see no errors.

You should see Nest application successfully started with no compilation errors in the output. The tenant module is now registered and ready.

Seeing a compilation error?

  • Check that transaction.schema.ts is in the same folder as tenant.service.ts (both inside src/tenant/). The relative import path './transaction.schema' requires them to be siblings.
  • Make sure the file name is exactly transaction.schema.ts (not transaction.schema.ts.ts or Transaction.schema.ts).
  • If you see Cannot find module errors for other modules like payment-queue or analytics, those are expected if the placeholder files from scaffolding are empty. They will be implemented in later steps.

help me fix a NestJS compilation error in my tenant module

Update the webhook controller to save transactions per tenant

Your TenantService is ready. Now you will wire it into the webhook controller so that each incoming payment event gets persisted to the correct tenant's isolated database.

  • Open src/webhooks/webhooks.controller.ts and replace its entire contents with the following:
import { Controller, Post, Body, UseGuards, HttpCode } from '@nestjs/common';
import { PaystackSignatureGuard } from './paystack-signature.guard';
import { TenantService } from '../tenant/tenant.service';

@Controller('webhooks')
export class WebhooksController {
  constructor(private readonly tenantService: TenantService) {}

  @Post('paystack')
  @UseGuards(PaystackSignatureGuard)
  @HttpCode(200)
  async handlePaystackWebhook(@Body() body: any) {
    // Extract tenant ID from Paystack's custom metadata
    const tenantId = body.data?.metadata?.tenantId;
    if (!tenantId) {
      return { status: 'ignored', reason: 'no tenantId in metadata' };
    }
    // Get the tenant-scoped model and persist the transaction
    const TransactionModel = this.tenantService.getTransactionModel(tenantId);
    await TransactionModel.create({
      event: body.event,
      reference: body.data.reference,
      amount: body.data.amount / 100,
      currency: body.data.currency,
      status: body.data.status,
      channel: body.data.channel,
      paidAt: body.data.paid_at || null,
      customerEmail: body.data.customer?.email || 'unknown',
    });
    return { status: 'saved' };
  }
}

What changed here?

  • The controller now injects TenantService via its constructor. Because TenantModule is global, NestJS resolves this automatically.
  • It extracts tenantId from body.data.metadata.tenantId. Paystack lets merchants attach custom metadata to transactions, which is how tenants identify themselves.
  • The amount / 100 conversion is necessary because Paystack sends amounts in the smallest currency unit (kobo for NGN). Dividing by 100 converts to the main unit (naira).
  • Save webhooks.controller.ts and confirm the app recompiles without errors in your terminal.

Now test it end-to-end. You will send two signed webhooks with different tenant IDs to prove that data lands in separate databases.

  • Open a new terminal and create a test script by running:
node -e "
const crypto = require('crypto');
const http = require('http');

const secret = process.env.PAYSTACK_SECRET_KEY || 'sk_test_your-paystack-test-secret-key';

const payload = JSON.stringify({
  event: 'charge.success',
  data: {
    reference: 'ref_alpha_001',
    amount: 500000,
    currency: 'NGN',
    status: 'success',
    channel: 'card',
    paid_at: '2026-07-30T10:00:00.000Z',
    customer: { email: 'alice@example.com' },
    metadata: { tenantId: 'merchant_alpha' }
  }
});

const hash = crypto.createHmac('sha512', secret).update(payload).digest('hex');
const options = { hostname: 'localhost', port: 3000, path: '/webhooks/paystack', method: 'POST', headers: { 'Content-Type': 'application/json', 'x-paystack-signature': hash } };
const req = http.request(options, (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => console.log(res.statusCode, d)); });
req.write(payload);
req.end();
"

What does this script do?

This sends a signed webhook for tenant merchant_alpha with a charge.success event. It computes the HMAC SHA512 signature using your Paystack secret key, just like Paystack would in production.

You should see 200 {"status":"saved"} printed in the terminal.

  • Now send a second webhook for a different tenant. Run this command (note the different tenantId and reference):
node -e "
const crypto = require('crypto');
const http = require('http');

const secret = process.env.PAYSTACK_SECRET_KEY || 'sk_test_your-paystack-test-secret-key';

const payload = JSON.stringify({
  event: 'charge.success',
  data: {
    reference: 'ref_beta_001',
    amount: 250000,
    currency: 'NGN',
    status: 'success',
    channel: 'bank',
    paid_at: '2026-07-30T11:00:00.000Z',
    customer: { email: 'bob@example.com' },
    metadata: { tenantId: 'merchant_beta' }
  }
});

const hash = crypto.createHmac('sha512', secret).update(payload).digest('hex');
const options = { hostname: 'localhost', port: 3000, path: '/webhooks/paystack', method: 'POST', headers: { 'Content-Type': 'application/json', 'x-paystack-signature': hash } };
const req = http.request(options, (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => console.log(res.statusCode, d)); });
req.write(payload);
req.end();
"

You should see 200 {"status":"saved"} again. Both tenants have been served.

Before you check MongoDB, what do you think you will see when you list databases? How many new databases should exist?

  • Open a new terminal and launch the MongoDB shell by running:
mongosh
  • List all databases by running:
show dbs

You should see tenant_merchant_alpha and tenant_merchant_beta in the list. Two separate databases, one per tenant.

  • Verify the data inside the first tenant's database by running:
use tenant_merchant_alpha
db.transactions.find()

You should see a single document with reference: 'ref_alpha_001', amount: 5000, and customerEmail: 'alice@example.com'. Complete data isolation between tenants.

Not seeing the tenant databases?

  • If you see {"status":"ignored","reason":"no tenantId in metadata"} in the response, the payload structure is wrong. Make sure tenantId is nested inside data.metadata.tenantId, not at the top level.
  • If you get 401 Unauthorized, the HMAC signature does not match. Make sure the secret in your test script matches the PAYSTACK_SECRET_KEY in your .env file exactly.
  • If mongosh shows no tenant databases, confirm the MongoDB service is running and your MONGODB_URI in .env points to mongodb://localhost:27017/payment-processor.

help me debug why my tenant databases aren't appearing in mongosh

Why does this approach have a limitation?

Right now, the controller saves the transaction synchronously inside the webhook handler. If MongoDB is slow, Paystack times out waiting for your 200 response, then retries the same event. This creates duplicates.

The fix is to queue the event and return 200 immediately. That is exactly what the next step introduces with BullMQ.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: transaction.schema.ts, tenant.service.ts, tenant.module.ts, and webhooks.controller.ts.

ⓧ I'd like to double check the full code

Here are the four files you created or modified in this step. Confirm each matches exactly.

import { Schema, Document } from 'mongoose';

export interface Transaction extends Document {
  event: string;
  reference: string;
  amount: number;
  currency: string;
  status: string;
  channel: string;
  paidAt: Date | null;
  customerEmail: string;
  createdAt: Date;
}

export const TransactionSchema = new Schema<Transaction>({
  event: { type: String, required: true },
  reference: { type: String, required: true, unique: true },
  amount: { type: Number, required: true },
  currency: { type: String, required: true },
  status: { type: String, required: true },
  channel: { type: String, required: true },
  paidAt: { type: Date, default: null },
  customerEmail: { type: String, required: true },
  createdAt: { type: Date, default: Date.now },
});
import { Injectable } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection, Model } from 'mongoose';
import { Transaction, TransactionSchema } from './transaction.schema';

@Injectable()
export class TenantService {
  constructor(@InjectConnection() private readonly connection: Connection) {}

  getTransactionModel(tenantId: string): Model<Transaction> {
    const dbName = `tenant_${tenantId}`;
    const tenantConnection = this.connection.useDb(dbName, { useCache: true });
    return tenantConnection.model<Transaction>('Transaction', TransactionSchema);
  }
}
import { Module, Global } from '@nestjs/common';
import { TenantService } from './tenant.service';

@Global()
@Module({
  providers: [TenantService],
  exports: [TenantService],
})
export class TenantModule {}
import { Controller, Post, Body, UseGuards, HttpCode } from '@nestjs/common';
import { PaystackSignatureGuard } from './paystack-signature.guard';
import { TenantService } from '../tenant/tenant.service';

@Controller('webhooks')
export class WebhooksController {
  constructor(private readonly tenantService: TenantService) {}

  @Post('paystack')
  @UseGuards(PaystackSignatureGuard)
  @HttpCode(200)
  async handlePaystackWebhook(@Body() body: any) {
    const tenantId = body.data?.metadata?.tenantId;
    if (!tenantId) {
      return { status: 'ignored', reason: 'no tenantId in metadata' };
    }
    const TransactionModel = this.tenantService.getTransactionModel(tenantId);
    await TransactionModel.create({
      event: body.event,
      reference: body.data.reference,
      amount: body.data.amount / 100,
      currency: body.data.currency,
      status: body.data.status,
      channel: body.data.channel,
      paidAt: body.data.paid_at || null,
      customerEmail: body.data.customer?.email || 'unknown',
    });
    return { status: 'saved' };
  }
}

Your service now routes payment events to completely isolated databases per tenant. Next up, you will decouple the save from the webhook response using BullMQ queues for fault-tolerant async processing.

Wire Up BullMQ Event-Driven Processing

Your webhook handler works, but it has a critical design flaw. Right now, the controller saves transactions synchronously inside the request handler.

If MongoDB is slow or unavailable, Paystack's request times out. Paystack then retries the webhook, causing duplicate transactions. By queuing the event and returning 200 immediately, you get fault tolerance, automatic retries with exponential backoff, and the ability to scale workers independently.

In this step, get ready to:
  • Create a BullMQ queue module that registers the payment-events queue with retry configuration.
  • Build a processor class that consumes jobs from the queue and persists transactions to the correct tenant database.
  • Confirm retry behavior by temporarily simulating a failure and observing exponential backoff in the logs.
Create the payment queue module

The queue module registers a named BullMQ queue and configures its default job options. It also hosts the processor that consumes jobs from that queue.

  • Create a new folder src/payment-queue inside your project.
  • Create a file named payment-queue.module.ts inside that folder with the following code:
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { PaymentProcessor } from './payment.processor';

@Module({
  imports: [
    BullModule.registerQueue({
      name: 'payment-events',
      defaultJobOptions: {
        attempts: 3,
        backoff: {
          type: 'exponential',
          delay: 3000,
        },
      },
    }),
  ],
  providers: [PaymentProcessor],
  exports: [BullModule],
})
export class PaymentQueueModule {}

What does this code do?

BullModule.registerQueue() creates a named queue called payment-events that BullMQ workers will listen on.

The defaultJobOptions tell BullMQ to retry failed jobs up to 3 times with exponential backoff starting at 3 seconds. That means retries happen at 3s, 6s, then 12s.

Exporting BullModule makes the queue injectable in other modules (like WebhooksModule).

Update the webhook controller to queue jobs instead of saving directly

Instead of calling TransactionModel.create() synchronously, the controller will now add a job to the queue and return 200 immediately.

  • Open src/webhooks/webhooks.controller.ts and replace its entire contents with:
import { Controller, Post, Body, UseGuards, HttpCode } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PaystackSignatureGuard } from './paystack-signature.guard';

@Controller('webhooks')
export class WebhooksController {
  constructor(@InjectQueue('payment-events') private readonly paymentQueue: Queue) {}

  @Post('paystack')
  @UseGuards(PaystackSignatureGuard)
  @HttpCode(200)
  async handlePaystackWebhook(@Body() body: any) {
    const tenantId = body.data?.metadata?.tenantId;
    if (!tenantId) {
      return { status: 'ignored', reason: 'no tenantId in metadata' };
    }

    const job = await this.paymentQueue.add('process-payment', {
      tenantId,
      event: body.event,
      data: body.data,
    });

    console.log(`Job queued: ${job.id}`);
    return { status: 'queued', jobId: job.id };
  }
}

What changed here?

The constructor now injects the payment-events queue using @InjectQueue('payment-events') instead of TenantService.

Instead of saving the transaction directly, the controller calls this.paymentQueue.add() to push the event onto the queue. The response returns immediately with the job ID, so Paystack gets its 200 within milliseconds.

Why queuing instead of direct saves?

Paystack expects a 200 response within seconds. If your database save takes too long, Paystack retries the webhook and you get duplicate records.

With a queue, the HTTP handler finishes instantly. A background worker processes the job at its own pace, with built-in retries if something goes wrong.

  • Save webhooks.controller.ts.

Seeing a dependency resolution error?

If the app fails to start with an error about BullQueue_payment-events not being available in the WebhooksModule context, confirm that PaymentQueueModule is imported in app.module.ts and that it exports BullModule.

help me fix a BullMQ queue dependency injection error in my NestJS webhook controller

Create the payment processor

The processor is a class that listens on the payment-events queue and handles each job by persisting the transaction to the correct tenant's database.

  • Create a new file src/payment-queue/payment.processor.ts with the following code:
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { TenantService } from '../tenant/tenant.service';

@Processor('payment-events')
export class PaymentProcessor extends WorkerHost {
  constructor(private readonly tenantService: TenantService) {
    super();
  }

  async process(job: Job<{ tenantId: string; event: string; data: any }>): Promise<void> {
    const { tenantId, event, data } = job.data;

    if (!tenantId) {
      throw new Error('Missing tenantId in job data');
    }

    const TransactionModel = this.tenantService.getTransactionModel(tenantId);

    await TransactionModel.create({
      event,
      reference: data.reference,
      amount: data.amount / 100,
      currency: data.currency,
      status: data.status,
      channel: data.channel,
      paidAt: data.paid_at || null,
      customerEmail: data.customer?.email || 'unknown',
    });

    console.log(`Job processed: ${job.id} for tenant ${tenantId}`);
  }
}

What does the processor do?

The @Processor('payment-events') decorator tells BullMQ this class handles jobs from the payment-events queue. Extending WorkerHost is required by the NestJS BullMQ integration.

The process() method extracts the tenant ID and event data from the job, gets the tenant-scoped Mongoose model, and persists the transaction. This is the same save logic that was previously in the controller, now running asynchronously in a background worker.

  • Save payment.processor.ts.
  • Check your terminal where npm run start:dev is running. The app should restart without errors.

Before you test the queue, what do you expect to see in the terminal when you POST a webhook now?

  • Test by sending a valid webhook with curl (use the same signature computation from the previous step):
curl -X POST http://localhost:3000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -H "x-paystack-signature: YOUR_COMPUTED_HASH" \
  -d '{"event":"charge.success","data":{"reference":"ref-queue-001","amount":250000,"currency":"NGN","status":"success","channel":"card","paid_at":"2026-07-30T10:00:00.000Z","customer":{"email":"queue@test.com"},"metadata":{"tenantId":"merchant_alpha"}}}'

You should see two log lines in your terminal: first Job queued: [jobId] from the controller, then a moment later Job processed: [jobId] for tenant merchant_alpha from the processor.

Only seeing 'Job queued' but not 'Job processed'?

Check that your Upstash Redis credentials in .env are correct. BullMQ needs a working Redis connection to dispatch jobs to workers.

Also confirm that PaymentProcessor is listed in the providers array of PaymentQueueModule. Without it, no worker picks up the job.

help me debug why my BullMQ processor is not picking up jobs

Add failure handling and test retries

Production queues need visibility into failures. You will add a failure event handler, then intentionally break the processor to watch BullMQ retry with exponential backoff.

  • In src/payment-queue/payment.processor.ts, find the closing brace of the process() method (the } after the console.log line).
  • Add the following method below it, inside the class:
  @OnWorkerEvent('failed')
  onFailed(job: Job, error: Error) {
    console.error(`Job ${job.id} failed (attempt ${job.attemptsMade}): ${error.message}`);
  }

What does this handler do?

The @OnWorkerEvent('failed') decorator fires each time a job fails. It logs the job ID, which attempt just failed, and the error message. This gives you visibility into retry behavior without checking the Redis dashboard.

  • Save payment.processor.ts.
  • Now temporarily add a failure simulation. Find this line inside the process() method:
    const { tenantId, event, data } = job.data;
  • Add this line directly below it:
    throw new Error('simulated failure');
  • Save the file and send another curl request with a new reference value (e.g. ref-retry-001).

Watch your terminal. You will see the job fail on attempt 1, then retry after about 3 seconds (attempt 2), then again after about 6 seconds (attempt 3). Each failure logs via the onFailed handler.

  • Once you have confirmed the retries, remove the throw new Error('simulated failure'); line from the process() method.
  • Save payment.processor.ts.

Exponential backoff in practice

The delay doubles each time: 3000ms, 6000ms, 12000ms. In production, this prevents a failing service from being hammered by rapid retries while giving transient issues time to resolve.

Send one more valid webhook to confirm everything works end-to-end after removing the simulated failure:

curl -X POST http://localhost:3000/webhooks/paystack \
  -H "Content-Type: application/json" \
  -H "x-paystack-signature: YOUR_COMPUTED_HASH" \
  -d '{"event":"charge.success","data":{"reference":"ref-final-001","amount":100000,"currency":"NGN","status":"success","channel":"bank","paid_at":"2026-07-30T12:00:00.000Z","customer":{"email":"final@test.com"},"metadata":{"tenantId":"merchant_alpha"}}}'

You should see Job queued followed by Job processed with no failure logs.

  • Verify the transaction was saved by opening mongosh and running:
mongosh
use tenant_merchant_alpha
db.transactions.find().pretty()

You should see transaction documents in the collection, confirming the queue processed and persisted them correctly.

✔️ Awesome, I've got everything!

Great. Double check you have saved both payment-queue.module.ts and payment.processor.ts, and that the simulated failure line is removed.

ⓧ I'd like to double check the full code

Here is the complete payment-queue.module.ts:

import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { PaymentProcessor } from './payment.processor';

@Module({
  imports: [
    BullModule.registerQueue({
      name: 'payment-events',
      defaultJobOptions: {
        attempts: 3,
        backoff: {
          type: 'exponential',
          delay: 3000,
        },
      },
    }),
  ],
  providers: [PaymentProcessor],
  exports: [BullModule],
})
export class PaymentQueueModule {}

Here is the complete payment.processor.ts:

import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { TenantService } from '../tenant/tenant.service';

@Processor('payment-events')
export class PaymentProcessor extends WorkerHost {
  constructor(private readonly tenantService: TenantService) {
    super();
  }

  async process(job: Job<{ tenantId: string; event: string; data: any }>): Promise<void> {
    const { tenantId, event, data } = job.data;

    if (!tenantId) {
      throw new Error('Missing tenantId in job data');
    }

    const TransactionModel = this.tenantService.getTransactionModel(tenantId);

    await TransactionModel.create({
      event,
      reference: data.reference,
      amount: data.amount / 100,
      currency: data.currency,
      status: data.status,
      channel: data.channel,
      paidAt: data.paid_at || null,
      customerEmail: data.customer?.email || 'unknown',
    });

    console.log(`Job processed: ${job.id} for tenant ${tenantId}`);
  }

  @OnWorkerEvent('failed')
  onFailed(job: Job, error: Error) {
    console.error(`Job ${job.id} failed (attempt ${job.attemptsMade}): ${error.message}`);
  }
}

Here is the updated webhooks.controller.ts:

import { Controller, Post, Body, UseGuards, HttpCode } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PaystackSignatureGuard } from './paystack-signature.guard';

@Controller('webhooks')
export class WebhooksController {
  constructor(@InjectQueue('payment-events') private readonly paymentQueue: Queue) {}

  @Post('paystack')
  @UseGuards(PaystackSignatureGuard)
  @HttpCode(200)
  async handlePaystackWebhook(@Body() body: any) {
    const tenantId = body.data?.metadata?.tenantId;
    if (!tenantId) {
      return { status: 'ignored', reason: 'no tenantId in metadata' };
    }

    const job = await this.paymentQueue.add('process-payment', {
      tenantId,
      event: body.event,
      data: body.data,
    });

    console.log(`Job queued: ${job.id}`);
    return { status: 'queued', jobId: job.id };
  }
}

Your webhook handler now responds instantly while a background worker handles the heavy lifting. Next up, you will build a real-time analytics endpoint that uses MongoDB aggregation pipelines to compute per-tenant payment stats on the fly.

Build Real-Time Aggregation Analytics

You have transactions flowing into isolated tenant databases, but no way to ask "how much revenue did this merchant process today?" or "what percentage of payments succeeded?"

Instead of pre-computing stats every time a transaction lands, you will build a MongoDB aggregation pipeline that computes analytics on the fly from raw documents. One GET request, one database round-trip, real-time numbers.

In this step, get ready to:
  • Create an AnalyticsModule with a GET endpoint for per-tenant stats.
  • Build a MongoDB aggregation pipeline using $facet, $group, and $project.
  • Seed test data and verify the analytics output matches expected calculations.
Create the analytics module

Your analytics endpoint needs its own NestJS module and controller, following the same service-oriented pattern as the webhooks and queue modules.

  • Create a new folder src/analytics inside your project.
  • Create src/analytics/analytics.module.ts with the following code:
import { Module } from '@nestjs/common';
import { AnalyticsController } from './analytics.controller';

@Module({
  controllers: [AnalyticsController],
})
export class AnalyticsModule {}

What does this code do?

This module registers the AnalyticsController with NestJS. It does not import TenantModule because TenantModule is decorated with @Global(), making TenantService available everywhere without explicit imports.

Why is AnalyticsModule already in app.module.ts?

If you look at src/app.module.ts, you will see AnalyticsModule is already imported. This was wired up earlier so you could focus on building the actual analytics logic in this step.

Build the aggregation pipeline controller

The controller exposes a single GET endpoint that takes a tenant ID, retrieves the tenant-scoped model, and runs a $facet aggregation to compute summary stats and per-channel breakdowns in one query.

  • Create src/analytics/analytics.controller.ts and paste the following code as the first section of the file:
import { Controller, Get, Param } from '@nestjs/common';
import { TenantService } from '../tenant/tenant.service';

@Controller('analytics')
export class AnalyticsController {
  constructor(private readonly tenantService: TenantService) {}

  @Get(':tenantId')
  async getTenantAnalytics(@Param('tenantId') tenantId: string) {
    const TransactionModel = this.tenantService.getTransactionModel(tenantId);

    const results = await TransactionModel.aggregate([
      {
        $facet: {
          summary: [
            {
              $group: {
                _id: null,
                totalRevenue: {
                  $sum: { $cond: [{ $eq: ['$status', 'success'] }, '$amount', 0] },
                },
                totalTransactions: { $sum: 1 },
                successCount: {
                  $sum: { $cond: [{ $eq: ['$status', 'success'] }, 1, 0] },
                },
              },
            },
  • Directly below the code you just pasted, add the $project stage for the summary branch and the byChannel branch:
            {
              $project: {
                _id: 0,
                totalRevenue: 1,
                totalTransactions: 1,
                successCount: 1,
                successRate: {
                  $cond: [
                    { $eq: ['$totalTransactions', 0] },
                    0,
                    { $divide: ['$successCount', '$totalTransactions'] },
                  ],
                },
              },
            },
          ],
          byChannel: [
            {
              $group: {
                _id: '$channel',
                count: { $sum: 1 },
                revenue: {
                  $sum: { $cond: [{ $eq: ['$status', 'success'] }, '$amount', 0] },
                },
              },
            },
  • Complete the file by adding the byChannel $project, closing brackets, and the result-handling logic:
            {
              $project: {
                _id: 0,
                channel: '$_id',
                count: 1,
                revenue: 1,
              },
            },
          ],
        },
      },
    ]);

    const summary = results[0]?.summary[0] || {
      totalRevenue: 0,
      totalTransactions: 0,
      successCount: 0,
      successRate: 0,
    };

    return {
      tenantId,
      ...summary,
      byChannel: results[0]?.byChannel || [],
    };
  }
}

What does this aggregation pipeline do?

The $facet stage runs two sub-pipelines in parallel over the same set of transaction documents:

  • summary groups all transactions into one document. It sums revenue only for success transactions using $cond, counts total transactions, counts successes, then projects a successRate by dividing successCount by totalTransactions.
  • byChannel groups transactions by their payment channel (card, bank, etc.), computing count and revenue per channel.

MongoDB processes both branches in a single round-trip and returns one document containing both result arrays.

  • Save analytics.module.ts and analytics.controller.ts.
  • Check the terminal where npm run start:dev is running. NestJS should recompile without errors and map the new GET /analytics/:tenantId route.

Seeing a compilation error?

  • Make sure the file path is exactly src/analytics/analytics.controller.ts. The import in analytics.module.ts expects that filename.
  • Confirm the import { TenantService } path is '../tenant/tenant.service'. A wrong relative path causes Cannot find module.
  • Check for unmatched brackets. The aggregation has deeply nested objects. Ensure every opening { and [ has a matching close.

Why isn't my analytics controller compiling?

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

The full controller file is assembled from the three code sections above. Scroll up and confirm each section is present in order:

  • Section 1: Imports, class declaration, constructor, method start, $facet opening, and the summary $group stage.
  • Section 2: The summary $project stage, closing of summary array, and the byChannel $group stage.
  • Section 3: The byChannel $project, all closing brackets, result handling with fallback defaults, and the return statement closing the class.
Seed test data and verify analytics

To see the aggregation in action, you need transaction documents in a tenant database. You will send several webhook requests with varying amounts, statuses, and channels, then query the analytics endpoint.

  • Open a new terminal (keep your dev server running in the other one).
  • Send six webhook requests to seed test data by running this script with Node.js:
node -e "
const crypto = require('crypto');
const secret = process.env.PAYSTACK_SECRET_KEY || 'sk_test_your-paystack-test-secret-key';

const payloads = [
  { event: 'charge.success', data: { reference: 'ref-a1', amount: 500000, currency: 'NGN', status: 'success', channel: 'card', paid_at: '2026-07-30T10:00:00.000Z', customer: { email: 'a@test.com' }, metadata: { tenantId: 'merchant_alpha' } } },
  { event: 'charge.success', data: { reference: 'ref-a2', amount: 300000, currency: 'NGN', status: 'success', channel: 'bank', paid_at: '2026-07-30T11:00:00.000Z', customer: { email: 'b@test.com' }, metadata: { tenantId: 'merchant_alpha' } } },
  { event: 'charge.success', data: { reference: 'ref-a3', amount: 150000, currency: 'NGN', status: 'failed', channel: 'card', paid_at: null, customer: { email: 'c@test.com' }, metadata: { tenantId: 'merchant_alpha' } } },
  { event: 'charge.success', data: { reference: 'ref-a4', amount: 750000, currency: 'NGN', status: 'success', channel: 'card', paid_at: '2026-07-30T12:00:00.000Z', customer: { email: 'd@test.com' }, metadata: { tenantId: 'merchant_alpha' } } },
  { event: 'charge.success', data: { reference: 'ref-a5', amount: 200000, currency: 'NGN', status: 'failed', channel: 'bank', paid_at: null, customer: { email: 'e@test.com' }, metadata: { tenantId: 'merchant_alpha' } } },
  { event: 'charge.success', data: { reference: 'ref-a6', amount: 400000, currency: 'NGN', status: 'success', channel: 'ussd', paid_at: '2026-07-30T13:00:00.000Z', customer: { email: 'f@test.com' }, metadata: { tenantId: 'merchant_alpha' } } },
];

async function send(p) {
  const body = JSON.stringify(p);
  const sig = crypto.createHmac('sha512', secret).update(body).digest('hex');
  const res = await fetch('http://localhost:3000/webhooks/paystack', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-paystack-signature': sig }, body });
  console.log(p.data.reference, res.status);
}

(async () => { for (const p of payloads) await send(p); })();
"

What does this script do?

It sends six webhook payloads to your running service, each with a valid HMAC SHA512 signature computed from your Paystack secret key. The payloads include four successful payments (card, bank, ussd channels) and two failed ones. All are routed to the merchant_alpha tenant.

Your dev server terminal should show "Job queued" and "Job processed" logs for each request. Wait 2-3 seconds for BullMQ to finish processing all jobs.

  • You should see six 200 status codes printed, one per line.

Before you hit the analytics endpoint, think about what numbers you expect. Four payments succeeded with amounts 5000, 3000, 7500, and 4000 (kobo converted to naira by dividing by 100). That is 19500 total revenue. Six total transactions. Four successes out of six gives a success rate of 0.6667.

  • Query the analytics endpoint by running:
curl http://localhost:3000/analytics/merchant_alpha

Do you expect to see the numbers you calculated above? Run it and check.

  • You should see a JSON response like this:
{
  "tenantId": "merchant_alpha",
  "totalRevenue": 19500,
  "totalTransactions": 6,
  "successCount": 4,
  "successRate": 0.6666666666666666,
  "byChannel": [
    { "count": 3, "revenue": 12500, "channel": "card" },
    { "count": 2, "revenue": 3000, "channel": "bank" },
    { "count": 1, "revenue": 4000, "channel": "ussd" }
  ]
}

Why do the numbers match?

The $group stage with $cond only sums amounts where status equals success. Failed transactions count toward totalTransactions but contribute zero to totalRevenue. The byChannel branch independently groups by channel, giving you revenue attribution per payment method.

Getting an empty response or zeros?

  • Check your dev server logs for "Job processed" entries. If you only see "Job queued" but no "Job processed", BullMQ may not have connected to Redis. Verify your Upstash credentials in .env.
  • Confirm you are querying the same tenant ID that was in the webhook metadata. The URL must be /analytics/merchant_alpha (matching the tenantId field in the payloads).
  • If the seed script shows 401 errors, your PAYSTACK_SECRET_KEY in .env does not match the secret used in the script. Update the script's secret variable to match your actual key.

Help me debug why my analytics endpoint returns empty results.

Your payment processor now answers real-time business questions. A merchant can see total revenue, success rates, and channel breakdowns without any pre-computed caches. Next up, you will extend this architecture with automated tests in the Secret Mission.

Secret mission

Add Jest Tests

Your multi-tenant webhook processor works. But how do you prove it works without spinning up MongoDB and Redis every time? A senior engineer writes isolated unit tests that verify critical business logic with mocked dependencies. Your mission: test the PaystackSignatureGuard and PaymentProcessor in complete isolation.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses a local MongoDB instance (free) and an Upstash Redis free tier database, so there are no ongoing costs.

Resources you used:

  • MongoDB local databases (tenant_* databases stored on your local disk).
  • Upstash Redis database (free tier, 500K commands/month).
  • Local project folder payment-webhook-processor on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project soon.

  • MongoDB continues running as a Windows service in the background. It uses minimal resources when idle.
  • Your Upstash Redis free tier database stays active at no cost unless you exceed 500K commands/month.
  • Your Paystack test account remains in test mode. No real money is ever charged.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping your files and accounts intact.

  • Stop the NestJS dev server by pressing Ctrl+C in the terminal where it is running.
  • Stop the MongoDB Windows service by opening a Command Prompt as Administrator and running the command below.
net stop MongoDB
  • Your Upstash Redis free tier database is automatically archived after 30 days of inactivity. Your data is backed up and restorable when you return.
  • Your project files stay on your Desktop. When you are ready to resume, start MongoDB again with net start MongoDB in an Administrator terminal, then run npm run start:dev in the project folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Stop the NestJS dev server by pressing Ctrl+C in its terminal.
  • Drop the tenant databases in MongoDB. Open a terminal and run the commands below.
mongosh
use tenant_merchant_alpha
db.dropDatabase()
use tenant_merchant_beta
db.dropDatabase()
exit
  • Stop and remove the MongoDB Windows service. Open a Command Prompt as Administrator and run these commands.
net stop MongoDB
sc.exe delete MongoDB
  • Uninstall MongoDB from Windows. Open Settings, select Apps, then Installed apps. Find MongoDB in the list, click the three dots on the right, and select Uninstall.
  • Delete the Upstash Redis database. Open the Upstash console at console.upstash.com. Select your Redis database, scroll to the bottom of the Details page, and click Delete this database. Follow the confirmation prompts.
  • Delete the local project folder. Open a terminal and run the command for your shell below.

macOS

cd ~/Desktop
rm -rf payment-webhook-processor

Windows

cd $HOME\Desktop
Remove-Item -Recurse -Force payment-webhook-processor

Nice Work!

Nice Work!

You just built a production-grade multi-tenant payment webhook processor using NestJS, MongoDB, and BullMQ. This is the kind of event-driven backend architecture that powers real FinTech platforms across Africa.

You've learned how to:

  • Build multi-tenant database isolation using MongoDB's useDb() pattern, routing each merchant's payment data to a separate database while reusing a single connection pool.
  • Implement webhook signature verification with HMAC SHA512 inside a NestJS Guard, rejecting forged Paystack payment events before they reach your business logic.
  • Wire up event-driven async processing with BullMQ and Upstash Redis for fault-tolerant job queues, and build a MongoDB aggregation pipeline that computes real-time per-tenant payment analytics.
  • Secret Mission: Proved the architecture is testable by writing isolated Jest unit tests for the signature guard and payment processor, mocking infrastructure dependencies with @nestjs/testing.

Ready to quiz yourself?