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.
- Log into your Flutterwave Dashboard.
- Look for the v4 Developer Toggle.
- Generate a Client ID and a Secret Key.
- Note the new Sandbox URL: https://developersandbox-api.flutterwave.com
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!
Top comments (0)