An open API service indexing awesome lists of open source software.

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.

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=
```