M-Pesa STK Push for Flutter, backed by Appwrite. One package, one deployed function, eight lines of app code.
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;
}
});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.
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.
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 |
cd function
appwrite functions createDeployment \
--functionId=<your-function-id> \
--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 = <your-database-id>
DARAJA_COLLECTION_ID = <your-stk-collection-id>
DARAJA_B2C_COLLECTION_ID = <your-b2c-collection-id> # only needed for B2C
Copy the generated function domain.
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.streamAll standard Kenyan formats are accepted and normalised to 2547XXXXXXXX / 2541XXXXXXXX:
0712345678712345678+254712345678254712345678
Anything else throws a FormatException before the API call.
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.
case PaymentSuccess(:final receiptNumber, :final mpesaTimestamp, :final settledAt):
final txTime = mpesaTimestamp ?? settledAt;
saveToLedger(receipt: receiptNumber, completedAt: txTime);Errors thrown before the payment stream starts (initiation failures) are typed:
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:
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 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.
Send funds from your M-Pesa shortcode to a customer's phone.
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 |
B2C requires your InitiatorPassword encrypted with Safaricom's RSA public key. Extract the public key from the certificate Safaricom provides on the developer portal:
# 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.pemThen generate the credential at runtime:
import 'package:daraja/daraja.dart';
const sandboxCert = '''
-----BEGIN PUBLIC KEY-----
<paste PEM content here>
-----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().
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);
}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.
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.
cd example/chama
flutter run \
--dart-define=DARAJA_CONSUMER_KEY=<key> \
--dart-define=DARAJA_CONSUMER_SECRET=<secret> \
--dart-define=DARAJA_PASSKEY=<passkey> \
--dart-define=APPWRITE_PROJECT_ID=<id> \
--dart-define=APPWRITE_DATABASE_ID=payments \
--dart-define=APPWRITE_COLLECTION_ID=transactions \
--dart-define=CALLBACK_DOMAIN=https://<fn-domain>.appwrite.runThe 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.
Unit tests (no external dependencies):
flutter testIntegration tests (requires Pesa Playground running locally):
pesa-playground --port 3000
flutter test test/integration/ --tags integration \
--dart-define=DARAJA_CONSUMER_KEY=<key> \
--dart-define=DARAJA_CONSUMER_SECRET=<secret> \
--dart-define=DARAJA_PASSKEY=<passkey> \
--dart-define=APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 \
--dart-define=APPWRITE_PROJECT_ID=<id> \
--dart-define=APPWRITE_DATABASE_ID=<db> \
--dart-define=APPWRITE_COLLECTION_ID=<col> \
--dart-define=CALLBACK_DOMAIN=<domain> \
--dart-define=APPWRITE_USER_ID=<uid>