https://github.com/ronnyabuto/daraja
M-Pesa STK Push lifecycle for Flutter — initiation, real-time callback delivery, timeout handling, and killed-app recovery, without a separately managed server. Backed by Appwrite. One package, one deployed function.
https://github.com/ronnyabuto/daraja
appwrite daraja dart east-africa fintech flutter flutter-package kenya mobile-payments mpesa payments pub-dev realtime safaricom stk-push
Last synced: 2 days ago
JSON representation
M-Pesa STK Push lifecycle for Flutter — initiation, real-time callback delivery, timeout handling, and killed-app recovery, without a separately managed server. Backed by Appwrite. One package, one deployed function.
- Host: GitHub
- URL: https://github.com/ronnyabuto/daraja
- Owner: ronnyabuto
- License: mit
- Created: 2026-03-31T10:38:31.000Z (8 days ago)
- Default Branch: main
- Last Pushed: 2026-03-31T12:14:03.000Z (8 days ago)
- Last Synced: 2026-03-31T14:14:53.298Z (8 days ago)
- Topics: appwrite, daraja, dart, east-africa, fintech, flutter, flutter-package, kenya, mobile-payments, mpesa, payments, pub-dev, realtime, safaricom, stk-push
- Language: Dart
- Homepage:
- Size: 55.7 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: .github/CONTRIBUTING.md
- License: LICENSE
Awesome Lists containing this project
README
# daraja
M-Pesa STK Push for Flutter, backed by Appwrite. One package, one deployed function, eight lines of app code.
```dart
final stream = await daraja.stkPush(
phone: '0712345678',
amount: 1000,
reference: 'ORDER-001',
description: 'Payment',
userId: currentUser.id,
);
stream.listen((state) {
switch (state) {
case PaymentSuccess(:final receiptNumber, :final amount):
showReceipt('KES $amount — $receiptNumber');
case PaymentFailed(:final message):
showError(message);
case PaymentCancelled():
showCancelled();
case PaymentTimeout():
// Money may have moved. Do not say "payment failed."
showNeutralTimeout();
case PaymentPending():
showWaitingForPin();
case PaymentInitiating():
showSpinner();
case PaymentError(:final message):
showTechnicalError(message);
case PaymentIdle():
break;
}
});
```
## What this solves
The standard M-Pesa integration problem is not initiating the STK Push — that part is one HTTP call. The problem is everything after it:
- The payment result arrives at a server you have to build yourself
- The customer backgrounds your app to enter their PIN in the M-Pesa app, and you miss the update
- Your tunnel dies mid-payment and the transaction is orphaned with no recovery path
- You build a polling loop and it produces 10–39 seconds of lag on every payment
This package handles all of it. The result arrives over Appwrite Realtime (WebSocket, sub-second delivery). App backgrounding is handled via `WidgetsBindingObserver`. Killed-app recovery runs on next launch from `SharedPreferences`. Polling is a fallback, not the primary path.
## How it works
Two pieces that work together:
**An Appwrite Function** (`function/`) deployed once to your Appwrite project. Its public domain becomes the `CallbackURL` for every STK Push. When Safaricom posts the payment result to it, the function writes a document to your Appwrite database — which automatically fires a Realtime event.
**The Flutter package** (`lib/`) initiates the STK Push directly from the device to Safaricom (no proxy), opens a Realtime subscription for that specific payment document, manages the timeout cascade, registers the lifecycle observer, and exposes everything as a single typed stream that closes when the payment reaches a terminal state.
## Setup
### 1. Appwrite
Create a database and collection with this schema:
| Attribute | Type | Required |
|---|---|---|
| `checkoutRequestId` | String (255) | Yes |
| `status` | String (20) | Yes |
| `resultCode` | Integer | No |
| `receipt` | String (20) | No |
| `amount` | Integer | No |
| `failureReason` | String (255) | No |
| `mpesaTimestamp` | String (50) | No |
| `settledAt` | String (50) | Yes |
### 2. Deploy the function
```bash
cd function
appwrite functions createDeployment \
--functionId= \
--entrypoint="lib/main.dart" \
--code=.
```
In the Appwrite console, set Execute access to **Any** — Safaricom doesn't send auth headers. Add these environment variables:
```
DARAJA_DATABASE_ID =
DARAJA_COLLECTION_ID =
DARAJA_B2C_COLLECTION_ID = # only needed for B2C
```
Copy the generated function domain.
### 3. Configure and use
```dart
import 'package:daraja/daraja.dart';
final daraja = Daraja(
config: DarajaConfig(
consumerKey: 'xxx',
consumerSecret: 'xxx',
passkey: 'xxx',
shortcode: '174379',
environment: DarajaEnvironment.sandbox,
appwriteEndpoint: 'https://cloud.appwrite.io/v1',
appwriteProjectId: 'xxx',
appwriteDatabaseId: 'payments',
appwriteCollectionId: 'transactions',
callbackDomain: 'https://64d4d22db370ae41a32e.fra.appwrite.run',
),
);
// Subscribe to the global payment stream before restoring — states emitted
// by restorePendingPayment() arrive on this same stream.
daraja.stream.listen((state) { /* update UI */ });
// On app startup — restore any payment pending from a previous session
await daraja.restorePendingPayment();
// Initiate a payment
final stream = await daraja.stkPush(
phone: '0712345678',
amount: 1000,
reference: 'ORDER-001', // max 12 characters
description: 'Payment', // max 13 characters
userId: currentUser.id,
);
// stream is the same broadcast stream as daraja.stream
```
## Phone number formats
All standard Kenyan formats are accepted and normalised to `2547XXXXXXXX` / `2541XXXXXXXX`:
- `0712345678`
- `712345678`
- `+254712345678`
- `254712345678`
Anything else throws a `FormatException` before the API call.
## Reconciliation and phone masking
As of March 2026, Safaricom masks the `PhoneNumber` field in all STK Push callbacks — it returns `0722000***` instead of the real number. Any integration that uses phone number as a database key or for user lookup will silently break.
daraja never captures phone number. User identity is tied to the `userId` you pass into `stkPush()`, which is forwarded to the Appwrite Function via the callback URL. Transaction identity is anchored on two fields from `PaymentSuccess`:
| Field | Source | Use |
|---|---|---|
| `receiptNumber` | `MpesaReceiptNumber` in callback | Primary transaction anchor — matches M-Pesa transaction history |
| `mpesaTimestamp` | `TransactionDate` in callback (UTC) | Safaricom-stamped time the transaction completed |
| `settledAt` | Set by the Appwrite Function | When the callback arrived and was written to the database |
`mpesaTimestamp` is nullable — Safaricom occasionally omits `TransactionDate` on partial callbacks. Always have a fallback to `settledAt`.
```dart
case PaymentSuccess(:final receiptNumber, :final mpesaTimestamp, :final settledAt):
final txTime = mpesaTimestamp ?? settledAt;
saveToLedger(receipt: receiptNumber, completedAt: txTime);
```
## Error handling
Errors thrown before the payment stream starts (initiation failures) are typed:
```dart
try {
final stream = await daraja.stkPush(
phone: '0712345678',
amount: 1000,
reference: 'ORDER-001',
description: 'Payment',
userId: currentUser.id,
);
// ...
} on DarajaAuthError catch (e) {
// consumerKey/consumerSecret wrong, or app not enabled for this API
showError('Configuration error: ${e.message}');
} on StkPushRejectedError catch (e) {
// Safaricom accepted the HTTP call but rejected the push before it reached
// the customer's phone. ResponseCode is non-zero.
if (e.responseCode == '1025') {
showError('Another payment is already in progress. Please wait.');
} else {
showError('Payment could not be initiated: ${e.message}');
}
} on DarajaException catch (e) {
// Generic fallback — HTTP errors, network issues
showError(e.message);
}
```
For `PaymentFailed` states inside the stream, convenience getters map the Safaricom result codes you'll actually see:
```dart
case PaymentFailed(:final message, :final isInsufficientFunds,
:final isWrongPin, :final isSubscriberLocked):
if (isInsufficientFunds) {
showError('Insufficient M-Pesa balance.');
} else if (isWrongPin) {
showError('Wrong PIN entered. Please try again.');
} else if (isSubscriberLocked) {
showError('Account temporarily locked. Try again in a moment.');
} else {
showError(message);
}
```
| Getter | Safaricom resultCode | Meaning |
|---|---|---|
| `isInsufficientFunds` | 1 | Customer balance too low |
| `isWrongPin` | 2001 | Wrong M-Pesa PIN entered |
| `isSubscriberLocked` | 1001 | Too many wrong PINs or transaction in progress |
## PaymentTimeout
`PaymentTimeout` is not `PaymentFailed`. It means the 90-second wait elapsed with no callback. Money may have been deducted. The receipt may exist on Safaricom's ledger. Do not tell the customer their payment failed — show neutral status and a support path.
## B2C disbursements
Send funds from your M-Pesa shortcode to a customer's phone.
### Appwrite B2C collection schema
Create a second collection (referenced by `b2cCollectionId` in your config):
| Attribute | Type | Required |
|---|---|---|
| `originatorConversationId` | String (36) | Yes |
| `conversationId` | String (50) | No |
| `status` | String (20) | Yes |
| `resultCode` | Integer | No |
| `receipt` | String (20) | No |
| `amount` | Integer | No |
| `receiverName` | String (255) | No |
| `failureReason` | String (255) | No |
| `mpesaTimestamp` | String (50) | No |
| `settledAt` | String (50) | Yes |
### SecurityCredential
B2C requires your `InitiatorPassword` encrypted with Safaricom's RSA public key. Extract the public key from the certificate Safaricom provides on the developer portal:
```bash
# Sandbox
openssl x509 -in SandboxCertificate.cer -inform DER -pubkey -noout > sandbox_pubkey.pem
# Production
openssl x509 -in ProductionCertificate.cer -inform DER -pubkey -noout > prod_pubkey.pem
```
Then generate the credential at runtime:
```dart
import 'package:daraja/daraja.dart';
const sandboxCert = '''
-----BEGIN PUBLIC KEY-----
-----END PUBLIC KEY-----''';
final credential = SecurityCredential.generate(
initiatorPassword: 'YourInitiatorPassword',
certificate: sandboxCert,
);
```
`SecurityCredential.generate()` uses RSA PKCS#1 v1.5 encryption as required by Safaricom. Do not hardcode `initiatorPassword` in client-side code — generate the credential on your backend and pass it to `b2cPush()`.
### Usage
```dart
final daraja = Daraja(
config: DarajaConfig(
// ... existing STK Push fields ...
b2cCollectionId: 'disbursements', // new
),
);
try {
final stream = await daraja.b2cPush(
phone: '0712345678',
amount: 500,
initiatorName: 'YourInitiatorName',
securityCredential: credential, // from SecurityCredential.generate()
remarks: 'March commission',
commandId: B2cCommandId.businessPayment, // default
userId: currentUser.id,
);
stream.listen((state) {
switch (state) {
case DisbursementSuccess(:final receiptNumber, :final amount, :final receiverName):
showSuccess('Sent KES $amount to $receiverName — $receiptNumber');
case DisbursementFailed(:final message):
showError('Disbursement failed: $message');
case DisbursementTimeout(:final originatorConversationId):
showNeutral('Check dashboard — funds may have been sent ($originatorConversationId)');
case DisbursementPending():
showSpinner();
case DisbursementInitiating():
showSpinner();
}
});
} on DarajaAuthError catch (e) {
showError('Configuration error: ${e.message}');
} on B2cRejectedError catch (e) {
if (e.responseCode == '2001') {
showError('Wrong initiator credentials. Check SecurityCredential.');
} else {
showError('B2C rejected: ${e.message}');
}
} on DarajaException catch (e) {
showError(e.message);
}
```
### DisbursementTimeout
Like `PaymentTimeout` for STK Push — a timeout means the request expired in Safaricom's queue, not that the funds were definitely not sent. Always check the Safaricom dashboard and verify against `originatorConversationId` before marking a disbursement as failed.
## Free tier note
Appwrite pauses free-tier projects after one week of inactivity. A payment app that goes quiet will silently drop Safaricom callbacks. Either set up a weekly keep-warm ping or use a paid tier for any production workload.
## Running the demo
```bash
cd example/chama
flutter run \
--dart-define=DARAJA_CONSUMER_KEY= \
--dart-define=DARAJA_CONSUMER_SECRET= \
--dart-define=DARAJA_PASSKEY= \
--dart-define=APPWRITE_PROJECT_ID= \
--dart-define=APPWRITE_DATABASE_ID=payments \
--dart-define=APPWRITE_COLLECTION_ID=transactions \
--dart-define=CALLBACK_DOMAIN=https://.appwrite.run
```
The demo is a chama split-bill app — three members each paying their share via concurrent STK Pushes, with a live shared pot that updates as payments land.
## Running the tests
Unit tests (no external dependencies):
```bash
flutter test
```
Integration tests (requires [Pesa Playground](https://github.com/OmentaElvis/pesa-playground) running locally):
```bash
pesa-playground --port 3000
flutter test test/integration/ --tags integration \
--dart-define=DARAJA_CONSUMER_KEY= \
--dart-define=DARAJA_CONSUMER_SECRET= \
--dart-define=DARAJA_PASSKEY= \
--dart-define=APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 \
--dart-define=APPWRITE_PROJECT_ID= \
--dart-define=APPWRITE_DATABASE_ID= \
--dart-define=APPWRITE_COLLECTION_ID= \
--dart-define=CALLBACK_DOMAIN= \
--dart-define=APPWRITE_USER_ID=
```