DEV Community

iMercy Massang
iMercy Massang

Posted on

Flutterwave v4 Integration with NestJS: Mastering Mobile Money Transfers (Part 1)

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
Enter fullscreen mode Exit fullscreen mode

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,
      );
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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,
      );
    }
  }
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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)