daraja 0.2.0
daraja: ^0.2.0 copied to clipboard
M-Pesa STK Push and B2C disbursements for Flutter — initiation, real-time callback delivery, timeout handling, and killed-app recovery. Backed by Appwrite.
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.