DEV Community

Cover image for Setting Up Paystack for Subscription-Based Billing in NestJS
Idris Akintobi
Idris Akintobi

Posted on

Setting Up Paystack for Subscription-Based Billing in NestJS

Subscriptions are integral to many applications, enabling recurring billing for customers. This tutorial walks you through integrating Paystack for subscription-based billing using NestJS, providing insights into key steps, potential pitfalls, and best practices.


1. Understanding Paystack’s Subscription Model

Before diving into the code, let’s clarify some basics:

  • Account Setup: Create a Paystack account. Note that, as at the time of writing this article, Paystack accepts account creation per currency and supports only a few countries. Confirm if your country and currency are supported.
  • Currency Handling: A business operating in multiple currencies needs separate accounts for each. Payments in foreign currencies update your account balance in your default currency unless specified otherwise. Reach out to Paystack support for clarification.
  • Testing Mode: Always test in Test Mode before switching to Live Mode.

2. Bootstrapping the NestJS Application

Let’s start by creating a new NestJS project using the Nest CLI:

nest new paystack-nestjs --strict
Enter fullscreen mode Exit fullscreen mode

Move into the project directory:

cd paystack-nestjs
Enter fullscreen mode Exit fullscreen mode

For simplicity, we’ll leave the default files intact and focus on implementing our features.


3. Managing Environment Variables

We’ll use the @nestjs/config package to manage environment variables:

npm i --save @nestjs/config
Enter fullscreen mode Exit fullscreen mode

Import the ConfigModule into the application module to make environment variables accessible.

add config module

Setting Up Environment Variables

Create a .env file:

touch .env && echo "\n.env" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

Add the following variables:

PAYSTACK_SECRET=your_paystack_secret_key
PAYSTACK_BASE_URL=https://api.paystack.co
PAYSTACK_SUCCESS_URL=https://example.com #Page the customer will be redirected to after a successful charge.
Enter fullscreen mode Exit fullscreen mode

Retrieve the secret key from the API & Webhooks tab on the Paystack.

Retrieving secret key on payst


4. Webhook Configuration

Paystack uses webhooks to notify your system for events like successful charges. You can use Webhook.site to view and confirm that the payloads were received.

  • Generate a webhook URL and paste it into the Paystack Webhook field.
  • Save your changes and keep the Webhook.site page open to monitor events.

Webhook site image


5. Creating a Subscription Plan

To enable subscriptions, create a plan in Paystack:

  • Set the plan interval and maximum charges or leave it untouched for continuous billing.

Create paystack plan


6. Implementing Paystack Client

Generate a PaystackClient class to handle API calls:

nest generate class paystack/PaystackClient --no-spec --flat
Enter fullscreen mode Exit fullscreen mode

Install Axios to make HTTP requests:

npm install --save axios
Enter fullscreen mode Exit fullscreen mode

PaystackClient Methods

The client will include:

  • createCustomer: Registers a customer on Paystack.
  • findCustomerByEmail: Searches for a customer using their email.
  • listSubscriptions: Lists active customer subscriptions.
  • initializeTransaction: Creates a payment link for customer charge authorization. Various payment methods is available. This is enough if you only want a one time payment because you can set the amount you want to charge the customer in the request.
  • initializeSubscription: Creates a payment link for customer charge authorization. This is to charge and subscribe a customer a plan. We are passing the planId here which the plan cost will override any amount set in the request. Only card payment is available with this.
  • getSubscription: Fetches subscription details by ID.
  • createSubscription: Subscribes a customer to a plan.
  • cancelSubscription: Cancels an active subscription.
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import type { Customer } from 'src/types/dtos';
import type {
    CustomerResponseDTO,
    PaystackCustomer,
    PaystackPaymentSessionResponse,
    PaystackPaymentSessionResult,
    PlanResponse,
    SubscriptionData,
    SubscriptionResponse,
} from 'src/types/paystack-api-types';

@Injectable()
export class PaystackClient {
    private readonly httpInstance: AxiosInstance;

    private readonly checkoutSuccessUrl: string;

    private readonly logger: Logger;

    public constructor(private readonly configService: ConfigService) {
        this.httpInstance = axios.create({
            baseURL: this.configService.get('PAYSTACK_BASE_URL'),
            headers: {
                Authorization: `Bearer ${this.configService.get('PAYSTACK_SECRET')}`,
                'Content-Type': 'application/json',
            },
        });
        this.logger = new Logger('PAYSTACK CLIENT');
        this.checkoutSuccessUrl = this.configService.get('PAYSTACK_SUCCESS_URL');
    }

    async createCustomer(customerData: Customer): Promise<PaystackCustomer> {
        try {
            const { data } = await this.httpInstance.post<CustomerResponseDTO>('/customer', {
                first_name: customerData.firstName,
                last_name: customerData.lastName,
                email: customerData.email,
                phone: customerData.phoneNumber,
            });

            return data.data;
        } catch (e) {
            this.handleError(e);
        }
    }

    async findCustomerByEmail(email: string): Promise<PaystackCustomer> {
        try {
            const { data } = await this.httpInstance.get<CustomerResponseDTO>(`/customer/${email}`);
            return data.data;
        } catch (e) {
            this.handleError(e);
        }
    }

    async listSubscriptions(email: string): Promise<SubscriptionData[] | null> {
        const { subscriptions } = await this.findCustomerByEmail(email);

        return subscriptions.length ? subscriptions : null;
    }

    /**
     * This method is to initialize a charge transaction. We need to pass the amount that we want the customer to be charged.
     * Various payment method is available.
     * We concatenate the planId and transactionId as the value to the reference because we will need the planId to subscribe the user to the plan when the webhook event is received.
     */
    async initializeTransaction(
        customerEmail: string,
        planId: string,
        transactionId: string,
    ): Promise<PaystackPaymentSessionResult> {
        try {
            const planCost = await this.getPlanCost(planId);
            const { data } = await this.httpInstance.post<PaystackPaymentSessionResponse>('/transaction/initialize', {
                email: customerEmail,
                amount: planCost,
                reference: planId + '__' + transactionId,
                callback_url: this.checkoutSuccessUrl + '?' + transactionId, // This is to add the transactionId as a query parameter to the checkoutSuccessUrl
            });

            return {
                authorizationUrl: data.data.authorization_url,
                accessCode: data.data.access_code,
                reference: data.data.reference,
            };
        } catch (e) {
            this.handleError(e);
        }
    }

    /**
     * This method is to charge a customer and subscribe them to a plan.
     * Only card payment is allowed. Other payment methods can not be charged automatically
     */
    async initializeSubscription(
        customerEmail: string,
        planId: string,
        transactionId: string,
    ): Promise<PaystackPaymentSessionResult> {
        try {
            const { data } = await this.httpInstance.post<PaystackPaymentSessionResponse>('/transaction/initialize', {
                email: customerEmail,
                plan: planId,
                reference: transactionId,
                callback_url: this.checkoutSuccessUrl + '?' + transactionId,
            });

            return {
                authorizationUrl: data.data.authorization_url,
                accessCode: data.data.access_code,
                reference: data.data.reference,
            };
        } catch (e) {
            this.handleError(e);
        }
    }

    async getSubscription(id: string): Promise<SubscriptionData> {
        try {
            const { data } = await this.httpInstance.get<SubscriptionResponse>(`/subscription/${id}`);
            return data.data;
        } catch (e) {
            this.handleError(e);
        }
    }

    async createSubscription(customerId: string, planId: string): Promise<void> {
        try {
            await this.httpInstance.post<SubscriptionResponse>('/subscription/', {
                customer: customerId,
                plan: planId,
            });
        } catch (e) {
            this.handleError(e);
        }
    }

    async cancelSubscription(subCode: string, emailToken: string): Promise<void> {
        try {
            await this.httpInstance.post(`/subscription/disable`, {
                code: subCode,
                token: emailToken,
            });
        } catch (e) {
            this.handleError(e);
        }
    }

    /**
     * This is a function to get the cost of a plan.
     * For simplicity a map can be used but we want a single source of truth.
     */
    private async getPlanCost(id: string): Promise<string> {
        try {
            const { data } = await this.httpInstance.get<PlanResponse>(`/plan/${id}`);
            return data.data.amount.toString();
        } catch (e) {
            if (axios.isAxiosError(e)) {
                this.handleError(e);
            }
        }
    }

    private handleError(e: Error) {
        if (axios.isAxiosError(e)) {
            const response = e.response?.data as Record<string, unknown>;
            this.logger.warn('Paystack API error', JSON.stringify(response));
            throw new BadRequestException(response.message || 'Paystack API error');
        }
        throw new InternalServerErrorException((e as Error).message);
    }
}
Enter fullscreen mode Exit fullscreen mode

We will be using the initializeTransaction in our implementation because we want to be able to show various payment methods to our customers and manually subscribe them to the plan when we receive the charge.success event. We had to concatenate the planId and unique transactionId with the separator __ because the charge.success event payload will not contain any plan details if initializeTransaction method was used.
With this implementation, auto renewal will only be applicable to users who has the card authorization; user who paid with their cards.


7. Building the Paystack Service

Generate the Paystack service:

nest generate service paystack/Paystack --no-spec --flat
Enter fullscreen mode Exit fullscreen mode

PaystackService Methods

This service will include:

  • createPaymentSession: Generates an authorization URL for the customer.
  • cancelSubscription: Cancels an active subscription.
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { PaystackEvents } from 'src/types/enums';
import { PaystackPaymentSessionResult, PaystackWebhookPayload } from 'src/types/paystack-api-types';
import { PaystackPaymentSessionDTO } from '../types/dtos';
import { PaystackClient } from './paystack-client';

@Injectable()
export class PaystackService {
    private readonly logger: Logger;

    constructor(@Inject(PaystackClient) private readonly client: PaystackClient) {
        this.logger = new Logger('PAYSTACK SERVICE');
    }

    public async createPaymentSession(
        paymentSession: PaystackPaymentSessionDTO,
    ): Promise<PaystackPaymentSessionResult> {
        this.logger.localInstance;
        if (!paymentSession.planId) {
            throw new BadRequestException('Paystack Plan ID is required');
        }

        if (!paymentSession.transactionId) {
            throw new BadRequestException('transaction ID is required');
        }

        const customer = await this.client.createCustomer(paymentSession.customer);

        const activeSubscriptionResult = await this.client.listSubscriptions(customer.email);

        if (activeSubscriptionResult) {
            throw new BadRequestException('customer already has an active subscription');
        }

        const subscriptionResponse = await this.client.initializeTransaction(
            paymentSession.customer.email,
            paymentSession.planId,
            paymentSession.transactionId,
        );

        return subscriptionResponse;
    }

    // In our implementation, the customer can only be subscribed to a plan at a time.
    //There will only be one active subscription for a customer and they can cancel it if they want to subscribe to another plan.
    public async cancelSubscription(email: string): Promise<void> {
        const customerSubscriptions = await this.client.listSubscriptions(email);
        if (!customerSubscriptions) {
            throw new BadRequestException('User have no active subscription');
        }

        const activeSubscriptions = customerSubscriptions[0];
        await this.client.cancelSubscription(activeSubscriptions.subscription_code, activeSubscriptions.email_token);
    }
Enter fullscreen mode Exit fullscreen mode

8. Creating the Controller

Generate a controller for the service:

nest generate controller paystack/Paystack --no-spec --flat
Enter fullscreen mode Exit fullscreen mode

The controller will include /create endpoint to handle payment session creation requests.

import { Body, Controller, Inject, Post } from '@nestjs/common';
import { PaystackPaymentSessionDTO } from 'src/types/dtos';
import { PaystackService } from './paystack.service';

@Controller('paystack')
export class PaystackController {
    constructor(@Inject(PaystackService) private readonly paystackService: PaystackService) {}

    @Post('/create')
    async createPaymentSession(@Body() body: PaystackPaymentSessionDTO) {
        return await this.paystackService.createPaymentSession(body);
    }
}
Enter fullscreen mode Exit fullscreen mode

Create payment session request using VS Code's Thunder Client


9. Handling Webhooks

Webhook Payload Handling

When a charge succeeds, We need to subscribe the user to the plan. We can get the planId to subscribe the customer to from the reference by separating it with __. We have created the customer record and it is updated with their payment authorization after the charge.

  • Event Handling: We will handle events charge.success and subscription.create events.

Update the Paystack service with a handleWebhookEvents method to process these events and update the controller with the route to handle webhook events.

handleWebhookEvents code

Add webhook-event route in the controller
Add webhook event route

Securing Webhooks

Use x-paystack-signature in there webhook event request to verify payload authenticity. Add the rawBody option to your application to ensure you can get the raw request body:

Set rawBody to true in application

Generate a guard for signature validation:

nest generate guard paystack/PaystackWebhook --no-spec --flat
Enter fullscreen mode Exit fullscreen mode
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';

@Injectable()
export class PaystackWebhookGuard implements CanActivate {
    constructor(private readonly config: ConfigService) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const http = context.switchToHttp();
        const request = http.getRequest();
        const secret = this.config.get<string>('PAYSTACK_SECRET');

        const signature = request.headers['x-paystack-signature'];
        if (!signature) {
            throw new UnauthorizedException('No signature provided');
        }

        const hash = crypto.createHmac('sha512', secret).update(JSON.stringify(request.body)).digest('hex');

        const signatureBuffer = Buffer.from(signature);
        const hashBuffer = Buffer.from(hash);

        try {
            const isValid =
                signatureBuffer.length === hashBuffer.length && crypto.timingSafeEqual(signatureBuffer, hashBuffer);

            if (!isValid) {
                throw new UnauthorizedException('Invalid signature');
            }

            return true;
        } catch (error) {
            throw new UnauthorizedException('Invalid signature');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Add the guard to the webhook-event route

Add the guard to the webhook-event route


10. Testing and Deployment

  • You can host the service publicly or use VS Code port forwarding tunnels for testing.
  • Update the webhook URL on Paystack with your public endpoint.

Update the webhook URL on Paystack

Final Checks

Simulate a payment, monitor webhook logs, and confirm subscription creation on the Paystack dashboard.

Test webhook implementation


Conclusion

This guide covered integrating Paystack for subscription billing in a NestJS application. We explored creating subscriptions, handling webhooks, and securing webhook endpoints. While we focused on the basics, additional steps like proper logging, error handling, and input validation are crucial for production environments.

You can find the complete code for this implementation on GitHub: https://github.com/IdrisAkintobi/paystack-subscription-nestjs.

Happy coding! 🚀

Top comments (0)