Flutterwave Integration With Compose

Abdullahi MusaAbdullahi Musa
8 min read

Flutterwave remains one of the most reliable gateway for businesses across Africa and beyond. However, most existing resources focus on traditional XML-based UI and deprecated onActivityResult() methods, leaving a gap for developers building apps with Jetpack Compose. In this tutorial, we’ll walk through the step-by-step process of integrating Flutterwave into a Compose project—from setup to handling transactions—so you can start accepting payments seamlessly in your app.

Project Requirements

It is important to note that this tutorial will utilise Flutterwave's default Drop In UI thus, apart from the knowing the basis of Android Native Development, the only other requirements are

  1. A Flutterwave account with encryption and public keys (for testing purposes only, feel free to use the ones in this tutorial).

  2. Gradle dependencies.

  3. Manifest permission.

Setting Up Your Project

Now create a new Android project with compose and use Kotlin DSL for your gradle. After it builds, add the following dependencies to the app-level gradle like in the example below:

implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.code.gson:gson:2.11.0")
implementation("com.github.flutterwave.rave-android:rave_android:2.2.1")
implementation("org.parceler:parceler-api:1.1.13")

Side bar: Flutterwave requires AppCompactActivity thus we need to add it to our gradle (if not already present) and tweak our themes.xml to avoid crashes. Simply change the parent from parent="android:Theme.Material.Light.NoActionBar" to parent="Theme.AppCompat.Light.NoActionBar"

build.gradle.kts

In our settings.gradle.kts, in the dependencyResolutionManagement section, replace :

repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)

With:

repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)

Now in the repositories section just below, add the following:

maven { url = uri("https://jitpack.io") }

Your dependencyResolutionManagement in settings.gradle.kts should look like this:

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
    repositories {
        maven { url = uri("https://jitpack.io") }
        mavenCentral()
        google()
    }
}

settings.gradle.kts

Sync the changes.

Add the following permission to our mainifest just before the application tag, and we're good to go:

<uses-permission android:name="android.permission.INTERNET"/>

AndroidManifest.xml

Overview

The basic idea is represented by the diagram below. A user initiates the payment by pressing a button [1] which sends a request to a secure backend server. The response contains public and encryption keys of our Flutterwave account as well as other required parameters to configure a payment request to Flutterwave [2] . The configurations are then packaged as an intent [3] launched using rememberLauncherForActivityResult to start the Flutterwave default Drop UI [4] . Flutterwave then processes the transaction and sends a response back [5] .

transaction flow image

Setting up UI

For the UI, a simple TextField and a Button should do. The TextField is wired to accept only decimals with two decimal places

import android.app.Activity
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.flutterwavecompseintegration.ui.theme.FlutterwaveCompseIntegrationTheme

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            FlutterwaveCompseIntegrationTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    ScreenContent(this,innerPadding)
                }
            }
        }
    }
}


@Composable
fun ScreenContent(activity:Activity, innerPadding: PaddingValues) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(innerPadding),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
            modifier = Modifier.fillMaxWidth()
        ) {
            var amount by remember { mutableStateOf("") }

            TextField(
                value = amount,
                onValueChange = { input ->
                    // Regex: optional digits, optional decimal with up to 2 digits
                    val regex = Regex("^(0|[1-9]\\d*)(\\.\\d{0,2})?$")

                    if (input.isEmpty()) {
                        amount = ""
                    } else if (regex.matches(input)) {
                        amount = input
                    }
                },
                placeholder = { Text("Enter Amount") },
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Decimal,
                    imeAction = ImeAction.Done
                ),
                modifier = Modifier
                    .fillMaxWidth(0.8f) // 80% of screen width
            )

            Spacer(modifier = Modifier.height(16.dp))

            Button(
                onClick = { /* Handle click */ },
                modifier = Modifier.fillMaxWidth(0.5f) // 50% of screen width
            ) {
                Text("Pay")
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ScreenContentPreview() {
    FlutterwaveCompseIntegrationTheme {
        ScreenContent(PaddingValues())
    }
}

UI all set up

MainActivity.kt

Integrating Flutterwave

Let's create a file in our project directory to hold our Flutterwave utilities called flutterwave_utils (or whatever that works for you).

Ideally, the encryption and public key should come from a secure backend; it should never be hardcoded in any file as I am about to do here. In addition, if you do get it from a backend, do not log it nor cache it to a room Db or shared preferences.

Once a user initiates a payment, it should send a request to your backend which then sends a response containing the keys, narration and other useful information like additional Metadata. A class that shows what a response could look like is shown below:

import com.google.gson.annotations.SerializedName


data class FlutterwavePaymentRequest(
    @SerializedName("email")
    val email: String? = null,
    @SerializedName("country")
    val country: String? = null,
    @SerializedName("currency")
    val currency: String? = null,
    @SerializedName("amount")
    val amount: Double? = null,
    @SerializedName("first_name")
    val firstName: String? = null,
    @SerializedName("last_name")
    val lastName: String? = null,
    @SerializedName("narration")
    val narration: String? = null,
    @SerializedName("reference")
    val reference: String? = null,
    @SerializedName("public_key")
    val publicKey: String? = null,
    @SerializedName("encryption_key")
    val encryptionKey: String? = null,
    @SerializedName("is_staging")
    val isStaging: Boolean? = null,
    @SerializedName("metadata") val metadata: Map<String, Any>? = null
)

flutterwave_utils.kt

For this tutorial, an hardcoded instance would be used Public and Encryption keys provided by GideonJon. Thanks

in our Button's onClick

onClick = {
    val amountToPay = amount.toDoubleOrNull() ?: return@Button
    val fee = 209.09
    val paymentRequest = FlutterwavePaymentRequest(
        email = "tester@email.com",
        country = "NG",
        currency = "NGN",
        amount = amountToPay,
        firstName = "first name",
        lastName = "last name",
        narration = "payment for item",
        reference = "txt_ref123456",
        isStaging = true,
        publicKey = "FLWPUBK_TEST-06fe0b1c5d0e3af287d0ec5c99dec6f0-X",
        encryptionKey = "FLWSECK_TESTf5dca3a1293a",
        metadata = mapOf(
            "paymentUserId" to "user_id_1234",
            "fee" to fee,
            "total" to amountToPay + fee,
            "totalInKobo" to (amountToPay + fee) * 100
        )
    )
}

Using the hardcoded FlutterwavePaymentRequest instance, we will create an instance of RavePayInitializer from Flutterwave rather than start the RaveUiManager(Flutterwave's default Drop In UI activity). One of the cool things about Flutterwave is its extensive payment options. From card and USSD to Mpessa, UgMobileMoney and many more so here's where we can customise it:

fun createRavePayParams(
    paymentRequest: FlutterwavePaymentRequest,
    acceptAccountPayments: Boolean = true,
    acceptUssdPayments: Boolean = true,
    acceptBankTransferPayments: Boolean = true,
    acceptMpesaPayments: Boolean = false,
    acceptUkPayments: Boolean = false,
    acceptUgMobileMoneyPayments: Boolean = false,
    acceptZmMobileMoneyPayments: Boolean = false,
    acceptRwfMobileMoneyPayments: Boolean = false,
    acceptAchPayments: Boolean = false,
    acceptGHMobileMoneyPayments: Boolean = false,
    acceptSaBankPayments: Boolean = false,
    acceptFrancMobileMoneyPayments: Boolean = false,
    theme: Int = com.flutterwave.raveandroid.R.style.DefaultTheme
): RavePayInitializer {
    val paymentMethods = arrayListOf(RaveConstants.PAYMENT_TYPE_CARD)
    if (acceptAccountPayments) {
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_ACCOUNT)
    }
    if (acceptUssdPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_USSD)
    }
    if (acceptBankTransferPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_BANK_TRANSFER)
    }
    if (acceptMpesaPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_MPESA)
    }
    if (acceptUkPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_UK)
    }
    if (acceptUgMobileMoneyPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_UG_MOBILE_MONEY)
    }
    if (acceptZmMobileMoneyPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_ZM_MOBILE_MONEY)
    }
    if (acceptRwfMobileMoneyPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_RW_MOBILE_MONEY)
    }
    if (acceptAchPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_ACH)
    }
    if (acceptGHMobileMoneyPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_GH_MOBILE_MONEY)
    }
    if (acceptSaBankPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_SA_BANK_ACCOUNT)
    }
    if (acceptFrancMobileMoneyPayments){
        paymentMethods.add(RaveConstants.PAYMENT_TYPE_FRANCO_MOBILE_MONEY)
    }
    with(paymentRequest) {
        return RavePayInitializer(
            email,
            amount ?: 0.0,
            publicKey,
            encryptionKey,
            reference,
            narration,
            currency,
            country,
            "NG",
            firstName,
            lastName,
            theme,
            "",
            true,
            true,
            true,
            false,
            0,
            0,
            isStaging ?: true,
            stringifyMeta(metadata?.map { Meta(it.key, it.value.toString()) }),
            "",
            null,
            false,
            true,
            true,
            paymentMethods
        )
    }
}

The createRavePayParams() function takes the request, different payment methods(itemised in the function arguments) as well as theme. We start by creating a list of preferred payment methods then using the request instance, fill in the remaining values.

Handle the response

From official resources, a typical response for every transaction looks like this. The response has been mapped to a data class or Plain Old Java Object(POJO) named FlutterwaveTransactionResponse . This lets us view transaction status, perform transaction verification with your reference or access the meta and to display the response message to users. This would be added to flutterwave_utils.kt.

The response is like this:

data class FlutterwaveTransactionResponse(
    @SerializedName("status")
    val status: String? = null,

    @SerializedName("message")
    val message: String? = null,

    @SerializedName("data")
    val data: FlutterwaveTransactionData? = null
)


data class FlutterwaveTransactionData(
    @SerializedName("id") val id: Int? = null,
    @SerializedName("txRef") val txRef: String? = null,
    @SerializedName("flwRef") val flwRef: String? = null,
    @SerializedName("orderRef") val orderRef: String? = null,
    @SerializedName("redirectUrl") val redirectUrl: String? = null,
    @SerializedName("device_fingerprint") val deviceFingerprint: String? = null,
    @SerializedName("cycle") val cycle: String? = null,
    @SerializedName("amount") val amount: Double? = null,
    @SerializedName("charged_amount") val chargedAmount: Double? = null,
    @SerializedName("appfee") val appFee: Double? = null,
    @SerializedName("merchantfee") val merchantFee: Double? = null,
    @SerializedName("merchantbearsfee") val merchantBearsFee: Int? = null,
    @SerializedName("chargeResponseCode") val chargeResponseCode: String? = null,
    @SerializedName("raveRef") val raveRef: String? = null,
    @SerializedName("chargeResponseMessage") val chargeResponseMessage: String? = null,
    @SerializedName("currency") val currency: String? = null,
    @SerializedName("IP") val ip: String? = null,
    @SerializedName("narration") val narration: String? = null,
    @SerializedName("status") val status: String? = null,
    @SerializedName("modalauditid") val modalAuditId: String? = null,
    @SerializedName("chargeRequestData") val chargeRequestData: String? = null,
    @SerializedName("chargeResponseData") val chargeResponseData: String? = null,
    @SerializedName("retry_attempt") val retryAttempt: String? = null,
    @SerializedName("getpaidBatchId") val getPaidBatchId: String? = null,
    @SerializedName("createdAt") val createdAt: String? = null,
    @SerializedName("updatedAt") val updatedAt: String? = null,
    @SerializedName("deletedAt") val deletedAt: String? = null,
    @SerializedName("customerId") val customerId: Int? = null,
    @SerializedName("AccountId") val accountId: Int? = null,
    @SerializedName("customer.id") val customerIdAlt: Int? = null,
    @SerializedName("customer.phone") val customerPhone: String? = null,
    @SerializedName("customer.fullName") val customerFullName: String? = null,
    @SerializedName("customer.customertoken") val customerToken: String? = null,
    @SerializedName("customer.email") val customerEmail: String? = null,
    @SerializedName("customer.createdAt") val customerCreatedAt: String? = null,
    @SerializedName("customer.updatedAt") val customerUpdatedAt: String? = null,
    @SerializedName("customer.deletedAt") val customerDeletedAt: String? = null,
    @SerializedName("customer.AccountId") val customerAccountId: Int? = null,
    @SerializedName("meta") val meta: List<Any>? = null,
    @SerializedName("flwMeta") val flwMeta: FlwMeta? = null
)

data class FlwMeta(
    @SerializedName("chargeResponse") val chargeResponse: String? = null,
    @SerializedName("chargeResponseMessage") val chargeResponseMessage: String? = null
)

Launch the intent

Now create a rememberLauncherForActivityResult in MainActivity to handle results without using the deprecated onActivityResult().

        // Launcher to start RavePayActivity and get result
        val paymentLauncher = rememberLauncherForActivityResult(
            contract = ActivityResultContracts.StartActivityForResult()
        ) { result ->
            val response = result.data?.getStringExtra("response")
            val gson = Gson()
            val jsonType = object : TypeToken<FlutterwaveTransactionResponse>() {}.type
            val transactionResponse = if (!response.isNullOrEmpty()) gson.fromJson<FlutterwaveTransactionResponse>(
                response,
                jsonType
            ) else null
            when (result.resultCode) {
                RavePayActivity.RESULT_SUCCESS -> {
                    if (transactionResponse != null) {
                        Toast.makeText(
                            activity,
                            transactionResponse.data?.chargeResponseMessage ?: "Success",
                            Toast.LENGTH_SHORT
                        ).show()
                    } else {
                        Toast.makeText(activity, "SUCCESS", Toast.LENGTH_SHORT).show()
                    }
                }

                RavePayActivity.RESULT_ERROR -> {
                    if (transactionResponse != null) {
                        Toast.makeText(
                            activity,
                            transactionResponse.data?.chargeResponseMessage ?: "Error",
                            Toast.LENGTH_SHORT
                        ).show()
                    } else {
                        Toast.makeText(activity, "ERROR", Toast.LENGTH_SHORT).show()
                    }
                }

                RavePayActivity.RESULT_CANCELLED -> {
                    Toast.makeText(activity, "CANCELLED", Toast.LENGTH_SHORT).show()
                }
            }
        }

Putting it all together now, the Button click can now launch the intent

val intent = Intent(activity, RavePayActivity::class.java)
intent.putExtra(
    RaveConstants.RAVE_PARAMS,
    Parcels.wrap(createRavePayParams(paymentRequest))
)
paymentLauncher.launch(intent)

Transaction confirmation

Important

Please please please do NOT test the card payment with a REAL CARD unless you are ready to lodge a reversal complaint at the bank.

Conclusion

I really hope you find this useful. Do let me know in the comment section if you would like a tutorial on doing this without the default UI, or help customise the style.

For more information, check out the official documentation here The project can be viewed on GitHub here

11
Subscribe to my newsletter

Read articles from Abdullahi Musa directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abdullahi Musa
Abdullahi Musa