daraja

M-Pesa STK Push and B2C for Flutter, with the callback half built for you. Initiate a payment, get the result in the UI sub-second over a WebSocket, and survive app-kill and timeouts — without running a server.

final stream = await daraja.stkPush(
  phone: '0712345678',
  amount: 1000,
  reference: 'ORDER-001',   // max 12 chars
  description: 'Payment',    // max 13 chars
);

stream.listen((state) => switch (state) {
  PaymentSuccess(:final receiptNumber) => showReceipt(receiptNumber),
  PaymentFailed(:final message)        => showError(message),
  PaymentTimeout()                     => showNeutral(), // money may have moved
  _                                    => showProgress(),
});

Why

Initiating an STK Push is one HTTP call. The hard part is everything after it: the result arrives asynchronously at a public URL you have to host, the customer backgrounds your app to enter their PIN, your tunnel dies mid-payment, and a polling loop adds 10–40s of lag to every payment.

daraja puts that URL in an Appwrite Function and streams the result back over Appwrite Realtime (sub-second). Polling is a fallback, not the path. App-kill recovery runs on next launch via restorePendingPayment(). You deploy one function, create one collection, and write about eight lines of app code.

Setup

Deploy the function in function/ and create one collection — the runbook is function/README.md. It takes five minutes, but three things silently break every payment if you skip them:

  • Grant the function documents.write scope, then redeploy — scope changes don't take effect until you do.
  • Set the function's Execute access to Any. Safaricom sends no auth headers.
  • There is no APPWRITE_API_KEY env var on Appwrite 1.7+; the function reads the dynamic key from the x-appwrite-key header.

Then point daraja at your project:

final daraja = Daraja(
  config: DarajaConfig(
    consumerKey: '...', consumerSecret: '...', passkey: '...',
    shortcode: '174379', environment: DarajaEnvironment.sandbox,
    appwriteEndpoint: 'https://<region>.cloud.appwrite.io/v1',
    appwriteProjectId: '...', appwriteDatabaseId: 'payments',
    appwriteCollectionId: 'transactions',
    callbackDomain: 'https://<your-fn>.appwrite.run',
  ),
);

Pick a mode

Appwrite enforces read permissions on Realtime and polling, so a document is only delivered to a client allowed to read it. That forces one choice:

  • PublicDaraja(config: ...), no userId. Documents are read("any"), keyed on the unguessable transaction id. Zero auth, fine for prototypes.
  • AuthenticatedDaraja(config: ..., appwriteClient: yourSignedInClient) plus a userId. Each user reads only their own payments. Requires an Appwrite session before you initiate.

Passing a userId without an authenticated client throws — that combination silently never resolves, so daraja rejects it up front.

Two things that aren't obvious

PaymentTimeout is not PaymentFailed. Failed means Safaricom confirmed the money did not move. Timeout means 90s elapsed with no callback — the money may have moved, the receipt may exist. Show "status unknown" and a support path; never "payment failed," or customers pay twice.

Phone numbers are masked. Safaricom masks PhoneNumber in callbacks (0722000***), so daraja never reads it. Reconcile on receiptNumber (the M-Pesa receipt) and the userId you passed in — not the phone number.

PaymentFailed carries resultCode plus isInsufficientFunds / isWrongPin / isSubscriberLocked for the cases you'll actually branch on.

B2C disbursements

Same lifecycle, stream, and timeout semantics via b2cPush(). Two differences: it needs a SecurityCredential (your initiator password RSA-encrypted with Safaricom's certificate, via SecurityCredential.generate) and a second collection. See ARCHITECTURE.md for the wiring and why B2C keys on Safaricom's OriginatorConversationID.

More

  • ARCHITECTURE.md — why it's built this way: the trade-offs, the race window, the failure modes.
  • function/README.md — the deploy runbook.
  • flutter test runs the unit suite; test/integration/ holds the live tests (Realtime delivery, full lifecycle) gated behind --dart-define credentials.

Libraries

daraja