Bonaventure OgetoBy Bonaventure Ogeto|

Verify Paystack Payments in NestJS

To verify a Paystack payment in NestJS, inject your PaystackService and call the verify endpoint with the transaction reference. Check that data.data.status is "success", the amount matches your stored price, and the currency is correct. Use an idempotent database update to prevent double-granting.

Verification in the PaystackService

Add a typed verification method to your PaystackService:

// In paystack.service.ts
async verifyTransaction(reference: string): Promise<{
  success: boolean;
  amount?: number;
  currency?: string;
  email?: string;
  reference?: string;
  status?: string;
}> {
  var response = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: {
        Authorization: 'Bearer ' + this.secretKey,
      },
    }
  );

  var data = await response.json();

  if (!data.status || data.data.status !== 'success') {
    return {
      success: false,
      status: data.data ? data.data.status : 'unknown',
    };
  }

  return {
    success: true,
    amount: data.data.amount,
    currency: data.data.currency,
    email: data.data.customer.email,
    reference: data.data.reference,
  };
}

This returns a clean typed object your controllers can use without knowing the Paystack API response shape.

Full Verification with Amount Check

Create a dedicated fulfillment method that combines verification with database checks:

// src/payment/payment.service.ts
import { Injectable } from '@nestjs/common';
import { PaystackService } from './paystack.service';

@Injectable()
export class PaymentService {
  constructor(private paystackService: PaystackService) {}

  async fulfillOrder(reference: string) {
    // 1. Look up the order
    var order = await this.orderRepository.findOne({ where: { reference } });

    if (!order) {
      return { error: 'Order not found' };
    }

    if (order.paid) {
      return { success: true, alreadyFulfilled: true };
    }

    // 2. Verify with Paystack
    var result = await this.paystackService.verifyTransaction(reference);

    if (!result.success) {
      return { error: 'Payment not successful', status: result.status };
    }

    // 3. Check amount (stored in kobo)
    if (result.amount !== order.amountInKobo) {
      return { error: 'Amount mismatch' };
    }

    // 4. Check currency
    if (result.currency !== order.currency) {
      return { error: 'Currency mismatch' };
    }

    // 5. Idempotent fulfillment
    var updated = await this.orderRepository.update(
      { reference: reference, paid: false },
      { paid: true, paidAt: new Date() }
    );

    if (updated.affected === 0) {
      return { success: true, alreadyFulfilled: true };
    }

    // 6. Deliver value
    await this.sendConfirmationEmail(order.email, order);

    return { success: true, alreadyFulfilled: false };
  }
}

Using Verification in Controllers

// In payment.controller.ts
@Get('callback')
async handleCallback(
  @Query('reference') reference: string,
  @Res() res: Response,
) {
  if (!reference) {
    return res.redirect('/payment/failed?error=missing_reference');
  }

  var result = await this.paymentService.fulfillOrder(reference);

  if (result.error) {
    return res.redirect('/payment/failed?error=' + encodeURIComponent(result.error));
  }

  return res.redirect('/payment/success?reference=' + reference);
}

@Get('verify')
async verifyPayment(@Query('reference') reference: string) {
  if (!reference) {
    return { error: 'Missing reference' };
  }

  return this.paymentService.fulfillOrder(reference);
}

Both the callback handler (for redirect checkout) and the verify endpoint (for inline popup) use the same fulfillOrder method. This ensures consistent verification logic across all payment paths.

Error Handling with Filters

Create a custom exception filter for payment-related errors:

// src/payment/filters/payment-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';

export class PaymentVerificationException extends HttpException {
  constructor(message: string) {
    super({ error: 'Payment verification failed', message: message }, 400);
  }
}

@Catch(PaymentVerificationException)
export class PaymentExceptionFilter implements ExceptionFilter {
  catch(exception: PaymentVerificationException, host: ArgumentsHost) {
    var ctx = host.switchToHttp();
    var response = ctx.getResponse();

    response.status(400).json({
      success: false,
      error: exception.message,
    });
  }
}

This gives you a consistent error response format for all payment verification failures.

Testing Verification

Unit test the verification by mocking the PaystackService:

// src/payment/__tests__/payment.service.spec.ts
describe('PaymentService', function() {
  it('should fulfill order on successful verification', async function() {
    var mockPaystackService = {
      verifyTransaction: jest.fn().mockResolvedValue({
        success: true,
        amount: 500000,
        currency: 'NGN',
        email: 'test@example.com',
        reference: 'test_ref',
      }),
    };

    // Inject mock service and test fulfillOrder
  });

  it('should reject on amount mismatch', async function() {
    var mockPaystackService = {
      verifyTransaction: jest.fn().mockResolvedValue({
        success: true,
        amount: 100000, // Different from stored amount
        currency: 'NGN',
      }),
    };

    // Verify the service returns an amount mismatch error
  });
});

Mock the fetch calls rather than hitting the real Paystack API in tests. Use Paystack test keys for integration tests only.

Ship Payments Faster

Key Takeaways

  • Verification is a GET request to Paystack. Wrap it in a service method that returns a typed response.
  • Always check three things: status equals "success", amount matches your stored price, and currency is correct.
  • Use NestJS interceptors or pipes to handle common verification errors consistently across controllers.
  • The redirect callback is not proof of payment. Always call the verify endpoint server-side.
  • Both webhook and callback can fire for the same transaction. Use atomic database updates for idempotent fulfillment.
  • Inject the PaystackService into both your callback controller and webhook handler for consistent verification logic.

Frequently Asked Questions

Should I verify in the webhook handler or the callback handler?
Both. Each handler should call the same fulfillOrder method that verifies with Paystack and uses an idempotent database update. Whichever fires first fulfills the order. The other is a no-op.
Can I use NestJS Guards for payment verification?
Guards are better suited for authentication checks. Payment verification involves business logic (amount matching, database updates) that belongs in a service, not a guard.
What if the Paystack verify endpoint is down?
Retry with exponential backoff. The transaction state on Paystack does not change. If all retries fail, the webhook handler will independently deliver the event and can fulfill the order through that path.

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