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.writescope, 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_KEYenv var on Appwrite 1.7+; the function reads the dynamic key from thex-appwrite-keyheader.
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:
- Public —
Daraja(config: ...), nouserId. Documents areread("any"), keyed on the unguessable transaction id. Zero auth, fine for prototypes. - Authenticated —
Daraja(config: ..., appwriteClient: yourSignedInClient)plus auserId. 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 testruns the unit suite;test/integration/holds the live tests (Realtime delivery, full lifecycle) gated behind--dart-definecredentials.