Skip to content

ronnyabuto/daraja

Repository files navigation

daraja

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;
  }
});

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

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.

3. Configure and use

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.

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:

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

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:

# 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:

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().

Usage

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

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.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):

flutter test

Integration 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>

About

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.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors