Handle Paystack Webhooks in NestJS
To handle Paystack webhooks in NestJS, enable rawBody in NestFactory.create, create a WebhookGuard that verifies the HMAC SHA-512 signature, and build a controller that receives the event, returns 200 immediately, and processes it asynchronously.
Enable Raw Body Parsing
NestJS does not capture the raw request body by default. You need it for HMAC verification. Enable it in your bootstrap function:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
var app = await NestFactory.create(AppModule, {
rawBody: true,
});
await app.listen(3000);
}
bootstrap();
With rawBody: true, every request object gets a rawBody property containing the unparsed bytes. This is what you hash for signature verification.
Create a Webhook Signature Guard
// src/payment/guards/paystack-webhook.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
@Injectable()
export class PaystackWebhookGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
var request = context.switchToHttp().getRequest();
var signature = request.headers['x-paystack-signature'];
if (!signature) {
return false;
}
var secret = this.configService.get('PAYSTACK_SECRET_KEY');
var hash = crypto
.createHmac('sha512', secret)
.update(request.rawBody)
.digest('hex');
return hash === signature;
}
}
This guard runs before the controller method. If the signature does not match, NestJS returns a 403 automatically. The controller code never executes for invalid webhooks.
Build the Webhook Controller
// src/payment/webhook.controller.ts
import { Controller, Post, Body, HttpCode, UseGuards } from '@nestjs/common';
import { PaystackWebhookGuard } from './guards/paystack-webhook.guard';
import { PaymentService } from './payment.service';
@Controller('api/webhooks')
export class WebhookController {
constructor(private paymentService: PaymentService) {}
@Post('paystack')
@UseGuards(PaystackWebhookGuard)
@HttpCode(200)
async handleWebhook(@Body() event: any) {
// Process asynchronously
this.processEvent(event).catch(function(err) {
console.error('Webhook processing error:', err);
});
return { received: true };
}
private async processEvent(event: any) {
var eventType = event.event;
var data = event.data;
if (eventType === 'charge.success') {
await this.paymentService.fulfillOrder(data.reference);
} else if (eventType === 'subscription.create') {
await this.paymentService.createSubscription(data);
} else if (eventType === 'invoice.payment_failed') {
await this.paymentService.handleFailedRenewal(data);
}
}
}
@HttpCode(200) ensures NestJS returns 200 instead of the default 201 for POST requests. Paystack expects a 200 response.
Register in the Module
// src/payment/payment.module.ts
import { Module } from '@nestjs/common';
import { PaymentController } from './payment.controller';
import { WebhookController } from './webhook.controller';
import { PaystackService } from './paystack.service';
import { PaymentService } from './payment.service';
import { PaystackWebhookGuard } from './guards/paystack-webhook.guard';
@Module({
controllers: [PaymentController, WebhookController],
providers: [PaystackService, PaymentService, PaystackWebhookGuard],
exports: [PaystackService],
})
export class PaymentModule {}
Testing Webhooks Locally
Use ngrok to expose your local server:
npx ngrok http 3000
Set the webhook URL in your Paystack dashboard to the ngrok URL plus your webhook path: https://abc123.ngrok.io/api/webhooks/paystack.
For unit tests, create the HMAC signature yourself and pass it in the request header:
import * as crypto from 'crypto';
var payload = JSON.stringify({
event: 'charge.success',
data: { reference: 'test_123', amount: 500000, status: 'success' },
});
var hash = crypto
.createHmac('sha512', 'sk_test_xxx')
.update(Buffer.from(payload))
.digest('hex');
// POST to /api/webhooks/paystack with:
// Header: x-paystack-signature:
// Body:
Production Notes
- HTTPS required. Your webhook URL must use HTTPS in production.
- Respond quickly. Return 200 within a few seconds. Use a queue (Bull, RabbitMQ) for heavy processing.
- Idempotent handlers. Always check if the event was already processed before taking action.
- Log all events. Store every webhook event in a database table for debugging and audit trails.
- Monitor delivery. Check the Paystack dashboard webhook logs for failed deliveries.
Key Takeaways
- ✓Enable rawBody option in NestFactory.create to access the raw request bytes for signature verification.
- ✓Use a custom Guard to verify the HMAC SHA-512 signature. This keeps your controller clean.
- ✓Return 200 immediately from the controller. Queue heavy processing for background execution.
- ✓NestJS Guards work perfectly for webhook signature verification. They run before the controller method.
- ✓Handle events by type: charge.success, subscription.create, invoice.payment_failed are the most common.
- ✓Make your handlers idempotent. Paystack may retry and send the same event multiple times.
Frequently Asked Questions
- Why use a Guard instead of middleware for signature verification?
- Guards integrate with NestJS dependency injection, so you can inject ConfigService to get the secret key. Middleware works too, but Guards are the NestJS-idiomatic approach for request validation.
- Do I need to exclude the webhook route from CSRF protection?
- If you are using CSRF protection (like csurf), yes. Webhook requests come from Paystack servers, not from your frontend, so they will not have a CSRF token. Exclude the webhook route from CSRF checks.
- Can I use NestJS queues for webhook processing?
- Yes. Bull queues work well with NestJS. Add the event to a queue in the controller, return 200, and process the event in a queue consumer. This ensures Paystack gets a quick response while you handle the business logic asynchronously.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon