VOOZH about

URL: https://dev.to/imercy/flutterwave-v4-integration-with-nestjs-mastering-mobile-money-transfers-part-1-5cc6

⇱ Flutterwave v4 Integration with NestJS: Mastering Mobile Money Transfers (Part 1) - DEV Community


Introduction
If you’ve spent any time in the African fintech space, you’ve likely used Flutterwave’s v3 API. It was the standard for a while. However, Flutterwave recently launched Version 4 (v4), and it’s not just a minor update—it’s a complete architectural overhaul.

V4 moves away from static Public/Secret keys in favor of OAuth 2.0 authentication and introduces the Orchestrator Flow. As a developer, this means better security and more robust transaction handling, but it also means our old integration patterns are officially outdated.

In this guide, I’ll show you how to build a production-ready Mobile Money integration using NestJS.

Why v4 is a Game Changer
The biggest shift in v4 is the OAuth 2.0 lifecycle. Instead of sending your Secret Key with every request, you now exchange your credentials for a Bearer Token that expires.

Additionally, the Orchestrator Flow (Direct Transfers) allows you to process a payment by sending all details—Sender, Recipient, and Amount—in one single request. This is a massive improvement over the multi-step process of the past, which is still an option if you prefer.

Step 1: Getting Your v4 Credentials

Before we code, you need the right keys.

In your NestJS project, set up your environment variables like this:

FLUTTERWAVE_CLIENT_ID=your_v4_client_id_here
FLUTTERWAVE_SECRET_KEY=your_v4_secret_key_here
FLUTTERWAVE_BASE_URL=https://developersandbox-api.flutterwave.com

Step 2: The "Silent" Auth Service

Since v4 tokens expire, we shouldn't fetch a new one for every transaction—that’s a recipe for rate-limiting. Instead, we’ll build a service that caches the token and only refreshes it when it's nearing expiration.

import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class FlutterwaveAuthService {
 private readonly logger = new Logger(FlutterwaveAuthService.name);

 private accessToken: string | null = null;
 private tokenExpiryTime: number = 0; // Epoch time in milliseconds when the token expires

 constructor(
 private readonly httpService: HttpService,
 private readonly configService: ConfigService,
 ) {}

 /**
 * Returns a valid access token, fetching a new one if necessary.
 */
 async getValidToken(): Promise<string> {
 const currentTime = Date.now();
 const timeRemaining = this.tokenExpiryTime - currentTime;

 // Refresh if we have no token, or if it expires in less than 60 seconds (60000 ms)
 if (!this.accessToken || timeRemaining < 60000) {
 this.logger.log(
 'Token expired or nearing expiration. Fetching new OAuth token...',
 );
 await this.refreshAccessToken();
 }

 return this.accessToken;
 }

 private async refreshAccessToken(): Promise<void> {
 const clientId = this.configService.get<string>('FLUTTERWAVE_CLIENT_ID');
 const clientSecret = this.configService.get<string>(
 'FLUTTERWAVE_SECRET_KEY',
 );
 const tokenUrl =
 'https://idp.flutterwave.com/realms/flutterwave/protocol/openid-connect/token';

 try {
 // Axios requires URLSearchParams for x-www-form-urlencoded data
 const payload = new URLSearchParams({
 client_id: clientId,
 client_secret: clientSecret,
 grant_type: 'client_credentials',
 });

 const { data } = await firstValueFrom(
 this.httpService.post(tokenUrl, payload, {
 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
 }),
 );

 this.accessToken = data.access_token;
 // Calculate exact expiry time: Current Time + (expires_in seconds * 1000)
 this.tokenExpiryTime = Date.now() + data.expires_in * 1000;

 this.logger.log(
 `Successfully generated new Flutterwave token. Valid for ${data.expires_in} seconds.`,
 );
 } catch (error) {
 this.logger.error(
 'Failed to fetch Flutterwave OAuth token',
 error.response?.data || error.message,
 );
 throw new HttpException(
 'Failed to authenticate with payment gateway',
 HttpStatus.INTERNAL_SERVER_ERROR,
 );
 }
 }
}

Step 3: Defining the Mobile Money DTOs

V4 is very strict about data structures. To keep our API clean, we use class-validator. For Mobile Money, the msisdn (phone number) and network are vital.

import { ApiProperty } from '@nestjs/swagger';
import {
 IsNotEmpty,
 IsString,
 IsOptional,
 IsNumber,
 ValidateNested,
 IsEmail,
 Min,
} from 'class-validator';
import { Type } from 'class-transformer';

class NameDto {
 @ApiProperty({ example: 'Queen' })
 @IsString()
 @IsNotEmpty()
 first: string;

 @ApiProperty({ example: 'Leonne', required: false })
 @IsString()
 @IsOptional()
 middle?: string;

 @ApiProperty({ example: 'LeBron' })
 @IsString()
 @IsNotEmpty()
 last: string;
}

class PhoneDto {
 @ApiProperty({ example: '237' })
 @IsString()
 @IsNotEmpty()
 country_code: string;

 @ApiProperty({ example: '653033621' })
 @IsString()
 @IsNotEmpty()
 number: string;
}

class MobileMoneyDto {
 @ApiProperty({ example: 'MTN' }) // Or bank code like '044'
 @IsString()
 @IsNotEmpty()
 network: string;

 @ApiProperty({ example: 'CMR' })
 @IsString()
 @IsNotEmpty()
 country: string;

 @ApiProperty({
 example: '653033621',
 description: 'The recipient mobile number',
 })
 @IsString()
 @IsNotEmpty()
 msisdn: string;
}

class AddressDto {
 @ApiProperty({ example: 'Buea' })
 @IsString()
 @IsNotEmpty()
 city: string;

 @ApiProperty({ example: 'CM' })
 @IsString()
 @IsNotEmpty()
 country: string;

 @ApiProperty({ example: 'P.O. Box 1234' })
 @IsString()
 @IsNotEmpty()
 line1: string;

 @ApiProperty({ example: '0000', required: false })
 @IsString()
 @IsOptional()
 postal_code?: string;

 @ApiProperty({ example: 'South West' })
 @IsString()
 @IsNotEmpty()
 state: string;
}

class OrchestratorRecipientMomoDto {
 @ApiProperty()
 @ValidateNested()
 @Type(() => NameDto)
 name: NameDto;

 @ApiProperty()
 @ValidateNested()
 @Type(() => MobileMoneyDto)
 mobile_money: MobileMoneyDto;
}

// --- SENDER SPECIFIC ---

class OrchestratorSenderDto {
 @ApiProperty()
 @ValidateNested()
 @Type(() => NameDto)
 name: NameDto;

 @ApiProperty()
 @ValidateNested()
 @Type(() => PhoneDto)
 phone: PhoneDto;

 @ApiProperty({ example: 'mercy1@gmail.com' })
 @IsEmail()
 email: string;

 @ApiProperty()
 @ValidateNested()
 @Type(() => AddressDto)
 address: AddressDto;
}

// --- PAYMENT INSTRUCTION ---

class AmountDto {
 @ApiProperty({ example: 1000 })
 @IsNumber()
 @Min(1)
 value: number;

 @ApiProperty({
 example: 'destination_currency',
 enum: ['destination_currency', 'source_currency'],
 })
 @IsString()
 applies_to: string;
}

class OchestatorPaymentInstructionDto {
 @ApiProperty({ example: 'XAF' })
 @IsString()
 @IsNotEmpty()
 source_currency: string;

 @ApiProperty({ example: 'XAF' })
 @IsString()
 @IsNotEmpty()
 destination_currency: string;

 @ApiProperty()
 @ValidateNested()
 @Type(() => AmountDto)
 amount: AmountDto;

 @ApiProperty({ description: 'The object of the recipient' })
 @ValidateNested()
 @Type(() => OrchestratorRecipientMomoDto)
 @IsNotEmpty()
 recipient: OrchestratorRecipientMomoDto;

 @ApiProperty({ description: 'The object of the sender' })
 @IsNotEmpty()
 @ValidateNested()
 @Type(() => OrchestratorSenderDto)
 sender: OrchestratorSenderDto;
}

// --- MAIN DTO ---

export class DirectTransferDto {
 @ApiProperty({
 example: 'mobile_money',
 enum: ['bank', 'mobile_money', 'wallet'],
 })
 @IsString()
 type: string;

 @ApiProperty({
 example: 'instant',
 enum: ['instant', 'scheduled', 'deferred'],
 })
 @IsString()
 @IsNotEmpty()
 action: string;

 @ApiProperty({ example: 'unique-ref-999888' })
 @IsString()
 @IsNotEmpty()
 reference: string;

 @ApiProperty({ example: 'Payment for supplies' })
 @IsString()
 @IsOptional()
 narration?: string;

 @ApiProperty()
 @ValidateNested()
 @Type(() => OchestatorPaymentInstructionDto)
 payment_instruction: OchestatorPaymentInstructionDto;
}

Step 4: Implementing Direct Transfers (Orchestrator)

The "Direct Transfer" is the star of the show in v4. It’s perfect for one-off Mobile Money payments. Here is how we implement the service logic:

 async directTransfer(dto: DirectTransferDto) {
 try {
 const token = await this.authService.getValidToken();

 const headers = {
 Authorization: `Bearer ${token}`,
 'Content-Type': 'application/json',
 'X-Idempotency-Key': `direct-${uuidv4()}`,
 'X-Trace-Id': uuidv4(),
 };

 Logger.log(`Initiating Direct Transfer: ${dto.reference}`);

 const { data } = await firstValueFrom(
 this.httpService.post(`${this.baseUrl}/direct-transfers`, dto, {
 headers,
 }),
 );

 return data;
 } catch (error) {
 Logger.error(
 `Direct Transfer Failed: ${JSON.stringify(error.response?.data)}`,
 );
 throw new HttpException(
 error.response?.data?.message || 'Direct transfer failed',
 error.response?.status || HttpStatus.BAD_REQUEST,
 );
 }
 }

Idempotency & Trace IDs

In fintech, retries are common.

X-Idempotency-Key: Ensures that if you send the same request twice (due to a network glitch), Flutterwave only processes it once.

X-Trace-Id: This is your best friend for debugging. If a transaction fails, give this ID to Flutterwave support, and they can find the exact logs instantly.

Step 5: Exposing the API via the Controller

Finally, let's look at the Controller. Notice how clean it is—we just pass our validated DTOs straight to the service.

@ApiTags('Flutterwave')
@Controller('flutterwave')
export class FlutterwaveController {
 constructor(private readonly flutterwaveService: FlutterwaveApiService) {}

 @Post('direct-transfers')
 @ApiOperation({ summary: 'Initiate a one-shot direct transfer (Orchestrator)' })
 async initiateDirectTransfer(@Body() dto: DirectTransferDto) {
 return await this.flutterwaveService.directTransfer(dto);
 }

 // Plus other endpoints for customer/recipient management...
}

Summary

By using this modular approach in NestJS, we’ve handled the most complex part of v4: the OAuth 2.0 lifecycle. Our code is now efficient, secure, and ready for high-volume transactions.

What’s next?
Mobile Money is just the beginning. In Part 2, we will cover:

  • Handling the complexity of Bank Transfers (EGP, NGN, XAF).
  • Setting up Webhooks to listen for transaction status updates.
  • Advanced error handling for fintech-specific failure codes.

Have you started playing with v4 yet? Drop your questions or "gotchas" in the comments below!