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
Move into the project directory:
cd paystack-nestjs
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
Import the ConfigModule
into the application module to make environment variables accessible.
Setting Up Environment Variables
Create a .env
file:
touch .env && echo "\n.env" >> .gitignore
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.
Retrieve the secret key from the API & Webhooks tab on the Paystack.
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.
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.
6. Implementing Paystack Client
Generate a PaystackClient
class to handle API calls:
nest generate class paystack/PaystackClient --no-spec --flat
Install Axios to make HTTP requests:
npm install --save axios
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);
}
}
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
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);
}
8. Creating the Controller
Generate a controller for the service:
nest generate controller paystack/Paystack --no-spec --flat
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);
}
}
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
andsubscription.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.
Add webhook-event route in the controller
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:
Generate a guard for signature validation:
nest generate guard paystack/PaystackWebhook --no-spec --flat
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');
}
}
}
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.
Final Checks
Simulate a payment, monitor webhook logs, and confirm subscription creation on the Paystack dashboard.
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)