An Android application for initiating M-Pesa STK Push payments using the Safaricom Daraja API. Built with clean MVVM architecture, Hilt dependency injection, Jetpack Compose UI, and Kotlin Coroutines.
- Overview
- Architecture
- Tech Stack
- Project Structure
- Getting Started
- How the Payment Flow Works
- Sandbox Testing
- Security
- Contributing
Epesa integrates with the Safaricom Daraja API to trigger M-Pesa STK Push requests directly from an Android app. The user enters a phone number and amount, the app requests an OAuth token from Safaricom, constructs a signed STK Push request, and sends it to the Daraja API. Safaricom then pushes a payment prompt to the user's phone.
This project is currently in sandbox (test) mode. No real money is processed.
Epesa is built on MVVM (Model-View-ViewModel) with a clean layered structure separating concerns into three layers:
presentation → domain ← data
presentation— Jetpack Compose UI, ViewModel, UiState. Knows only the domain layer.domain— Pure Kotlin. Repository interface and domain models. No Android or network imports.data— Retrofit API service, DTOs, and the concrete repository implementation. Knows the domain interface and implements it.
This means the ViewModel never touches Retrofit or OkHttp directly. It calls the MpesaRepository interface. Hilt injects the concrete MpesaRepositoryImpl at runtime. The UI layer has zero knowledge of the network layer.
| Library | Version | Purpose |
|---|---|---|
| Kotlin | 2.x | Primary language |
| Jetpack Compose | BOM | Declarative UI |
| Hilt | 2.x | Dependency injection |
| KSP | Latest | Annotation processing (replaces kapt) |
| Retrofit | 2.x | HTTP client abstraction |
| OkHttp | 4.x | HTTP engine + logging interceptor |
| Gson | 2.x | JSON serialisation / deserialisation |
| Kotlin Coroutines | 1.x | Asynchronous execution |
| StateFlow | — | Reactive UI state (replaces LiveData) |
| Timber | 5.x | Debug logging (no-op in release) |
app/src/main/java/dev/korryr/epesa/
│
├── HiltApp.kt # Application class — Hilt init + Timber setup
├── MainActivity.kt # Single Activity — Compose host
│
├── core/
│ └── di/
│ ├── NetworkModule.kt # Provides OkHttpClient and Retrofit
│ └── MpesaModule.kt # Provides MpesaApiService and MpesaRepository
│
└── feature/
└── payment/
├── data/
│ ├── remote/
│ │ ├── MpesaApiService.kt # Retrofit interface — HTTP endpoint declarations
│ │ └── dto/
│ │ ├── AccessTokenResponse.kt # OAuth token response DTO
│ │ ├── StkPushRequest.kt # STK Push request body DTO
│ │ └── StkPushResponse.kt # STK Push response DTO
│ └── repository/
│ └── MpesaRepositoryImpl.kt # Concrete implementation — network + mapping
│
├── domain/
│ ├── model/
│ │ └── PaymentResult.kt # Clean domain model (no API or Android deps)
│ └── repository/
│ └── MpesaRepository.kt # Repository contract (interface)
│
└── presentation/
├── PaymentUiState.kt # Sealed interface — all possible screen states
├── PaymentViewModel.kt # State manager — validation + coroutine launch
├── PaymentScreen.kt # Root composable screen
└── components/
├── StkPushDialog.kt # Payment input dialog (phone + amount)
└── PaymentResultDialog.kt # Payment result dialog (success or error)
- Android Studio Panda2 or later
- JDK 11
- Android device or emulator running API 24+
- A Safaricom Daraja developer account — register here
- Log in to the Safaricom Developer Portal
- Create a new app or open an existing one
- Under Keys & Secrets, copy your Consumer Key and Consumer Secret
- Under Test Credentials → Lipa Na Mpesa Online, copy the Passkey
- The sandbox Business Short Code is
174379by default
This project uses a keys.properties file at the project root to keep credentials out of source code. This file is listed in .gitignore and is never committed.
- Copy the example file:
cp keys.properties.example keys.properties- Open
keys.propertiesand fill in your values:
mpesa.consumerKey=YOUR_CONSUMER_KEY
mpesa.consumerSecret=YOUR_CONSUMER_SECRET
mpesa.passkey=YOUR_PASSKEY
mpesa.businessShortCode=174379
mpesa.callbackUrl=https://yourdomain.com/mpesa/callback
callbackUrlmust be a publicly reachable HTTPS URL. Safaricom's servers POST the final payment result to this URL after the user completes or cancels the STK prompt. This is a server-side concern — the Android app does not receive the callback directly.
- Sync Gradle — the values are injected into
BuildConfigat compile time viabuildConfigFieldinapp/build.gradle.kts.
./gradlew assembleDebugOr run directly from Android Studio with Run → Run 'app'.
The payment involves two separate HTTP calls in sequence:
Step 1 — OAuth token request (app → Safaricom)
GET https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials
Authorization: Basic Base64(consumerKey:consumerSecret)
Returns a short-lived Bearer token used to authenticate the STK Push request.
Step 2 — STK Push request (app → Safaricom)
POST https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest
Authorization: Bearer {access_token}
Body: { BusinessShortCode, Password, Timestamp, Amount, PhoneNumber, ... }
The Password field is derived as:
Password = Base64( BusinessShortCode + Passkey + Timestamp )
Where Timestamp is the current time in yyyyMMddHHmmss format.
A ResponseCode: "0" means Safaricom accepted the request and has pushed a payment prompt to the phone. It does not mean the user has paid yet.
Step 3 — Payment callback (Safaricom → your server)
After the user enters their M-Pesa PIN or cancels the prompt, Safaricom sends a POST request to your callbackUrl with the final result. This is handled by your backend, not the Android app.
UiState flow:
Idle → Loading → Success("Check your phone") or Error("message")
↓
resetState()
↓
Idle
Use these official Safaricom sandbox test values:
Note: The Safaricom sandbox can be slow or intermittently unavailable. If you receive timeout errors, wait a few minutes and retry.
keys.propertiesis never committed. It is listed in.gitignore. Your Consumer Key, Consumer Secret, and Passkey stay on your machine only.keys.properties.exampleis committed. It contains no real values and documents the required credential schema for contributors.- Credentials are injected at compile time via
BuildConfig. They are baked into the APK binary and never exist in source code or version control. - Timber logs nothing in release builds.
Timber.plant(Timber.DebugTree())is called only whenBuildConfig.DEBUGistrue. In release builds, allTimber.*calls are no-ops. - Basic Auth is computed once using a
lazydelegate inMpesaRepositoryImpl. The Base64-encoded credential string is built on first use and cached — it is never recomputed on subsequent calls.
- Fork the repository
- Create a feature branch:
git checkout -b feature/your-feature-name - Copy
keys.properties.exampletokeys.propertiesand add your sandbox credentials - Make your changes
- Commit:
git commit -m "feat: describe your change" - Push:
git push origin feature/your-feature-name - Open a Pull Request
Please follow the existing architecture conventions — new features go inside feature/ with the same data / domain / presentation layering.
MIT License
Copyright (c) 2025 DevKorrir
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so.