{"id":48448148,"url":"https://github.com/ronnyabuto/daraja","last_synced_at":"2026-04-06T19:00:40.507Z","repository":{"id":348263291,"uuid":"1197113571","full_name":"ronnyabuto/daraja","owner":"ronnyabuto","description":"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.","archived":false,"fork":false,"pushed_at":"2026-03-31T12:14:03.000Z","size":57,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-31T14:14:53.298Z","etag":null,"topics":["appwrite","daraja","dart","east-africa","fintech","flutter","flutter-package","kenya","mobile-payments","mpesa","payments","pub-dev","realtime","safaricom","stk-push"],"latest_commit_sha":null,"homepage":"","language":"Dart","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ronnyabuto.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":".github/CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-31T10:38:31.000Z","updated_at":"2026-03-31T12:14:07.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ronnyabuto/daraja","commit_stats":null,"previous_names":["ronnyabuto/daraja"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/ronnyabuto/daraja","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fdaraja","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fdaraja/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fdaraja/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fdaraja/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ronnyabuto","download_url":"https://codeload.github.com/ronnyabuto/daraja/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ronnyabuto%2Fdaraja/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31485516,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-06T17:22:55.647Z","status":"ssl_error","status_checked_at":"2026-04-06T17:22:54.741Z","response_time":112,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["appwrite","daraja","dart","east-africa","fintech","flutter","flutter-package","kenya","mobile-payments","mpesa","payments","pub-dev","realtime","safaricom","stk-push"],"created_at":"2026-04-06T19:00:21.585Z","updated_at":"2026-04-06T19:00:40.499Z","avatar_url":"https://github.com/ronnyabuto.png","language":"Dart","readme":"# daraja\n\nM-Pesa STK Push for Flutter, backed by Appwrite. One package, one deployed function, eight lines of app code.\n\n```dart\nfinal stream = await daraja.stkPush(\n  phone: '0712345678',\n  amount: 1000,\n  reference: 'ORDER-001',\n  description: 'Payment',\n  userId: currentUser.id,\n);\n\nstream.listen((state) {\n  switch (state) {\n    case PaymentSuccess(:final receiptNumber, :final amount):\n      showReceipt('KES $amount — $receiptNumber');\n    case PaymentFailed(:final message):\n      showError(message);\n    case PaymentCancelled():\n      showCancelled();\n    case PaymentTimeout():\n      // Money may have moved. Do not say \"payment failed.\"\n      showNeutralTimeout();\n    case PaymentPending():\n      showWaitingForPin();\n    case PaymentInitiating():\n      showSpinner();\n    case PaymentError(:final message):\n      showTechnicalError(message);\n    case PaymentIdle():\n      break;\n  }\n});\n```\n\n## What this solves\n\nThe standard M-Pesa integration problem is not initiating the STK Push — that part is one HTTP call. The problem is everything after it:\n\n- The payment result arrives at a server you have to build yourself\n- The customer backgrounds your app to enter their PIN in the M-Pesa app, and you miss the update\n- Your tunnel dies mid-payment and the transaction is orphaned with no recovery path\n- You build a polling loop and it produces 10–39 seconds of lag on every payment\n\nThis 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.\n\n## How it works\n\nTwo pieces that work together:\n\n**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.\n\n**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.\n\n## Setup\n\n### 1. Appwrite\n\nCreate a database and collection with this schema:\n\n| Attribute | Type | Required |\n|---|---|---|\n| `checkoutRequestId` | String (255) | Yes |\n| `status` | String (20) | Yes |\n| `resultCode` | Integer | No |\n| `receipt` | String (20) | No |\n| `amount` | Integer | No |\n| `failureReason` | String (255) | No |\n| `mpesaTimestamp` | String (50) | No |\n| `settledAt` | String (50) | Yes |\n\n### 2. Deploy the function\n\n```bash\ncd function\nappwrite functions createDeployment \\\n  --functionId=\u003cyour-function-id\u003e \\\n  --entrypoint=\"lib/main.dart\" \\\n  --code=.\n```\n\nIn the Appwrite console, set Execute access to **Any** — Safaricom doesn't send auth headers. Add these environment variables:\n\n```\nDARAJA_DATABASE_ID    = \u003cyour-database-id\u003e\nDARAJA_COLLECTION_ID  = \u003cyour-stk-collection-id\u003e\nDARAJA_B2C_COLLECTION_ID = \u003cyour-b2c-collection-id\u003e   # only needed for B2C\n```\n\nCopy the generated function domain.\n\n### 3. Configure and use\n\n```dart\nimport 'package:daraja/daraja.dart';\n\nfinal daraja = Daraja(\n  config: DarajaConfig(\n    consumerKey: 'xxx',\n    consumerSecret: 'xxx',\n    passkey: 'xxx',\n    shortcode: '174379',\n    environment: DarajaEnvironment.sandbox,\n    appwriteEndpoint: 'https://cloud.appwrite.io/v1',\n    appwriteProjectId: 'xxx',\n    appwriteDatabaseId: 'payments',\n    appwriteCollectionId: 'transactions',\n    callbackDomain: 'https://64d4d22db370ae41a32e.fra.appwrite.run',\n  ),\n);\n\n// Subscribe to the global payment stream before restoring — states emitted\n// by restorePendingPayment() arrive on this same stream.\ndaraja.stream.listen((state) { /* update UI */ });\n\n// On app startup — restore any payment pending from a previous session\nawait daraja.restorePendingPayment();\n\n// Initiate a payment\nfinal stream = await daraja.stkPush(\n  phone: '0712345678',\n  amount: 1000,\n  reference: 'ORDER-001',  // max 12 characters\n  description: 'Payment',  // max 13 characters\n  userId: currentUser.id,\n);\n// stream is the same broadcast stream as daraja.stream\n```\n\n## Phone number formats\n\nAll standard Kenyan formats are accepted and normalised to `2547XXXXXXXX` / `2541XXXXXXXX`:\n\n- `0712345678`\n- `712345678`\n- `+254712345678`\n- `254712345678`\n\nAnything else throws a `FormatException` before the API call.\n\n## Reconciliation and phone masking\n\nAs 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.\n\ndaraja 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`:\n\n| Field | Source | Use |\n|---|---|---|\n| `receiptNumber` | `MpesaReceiptNumber` in callback | Primary transaction anchor — matches M-Pesa transaction history |\n| `mpesaTimestamp` | `TransactionDate` in callback (UTC) | Safaricom-stamped time the transaction completed |\n| `settledAt` | Set by the Appwrite Function | When the callback arrived and was written to the database |\n\n`mpesaTimestamp` is nullable — Safaricom occasionally omits `TransactionDate` on partial callbacks. Always have a fallback to `settledAt`.\n\n```dart\ncase PaymentSuccess(:final receiptNumber, :final mpesaTimestamp, :final settledAt):\n  final txTime = mpesaTimestamp ?? settledAt;\n  saveToLedger(receipt: receiptNumber, completedAt: txTime);\n```\n\n## Error handling\n\nErrors thrown before the payment stream starts (initiation failures) are typed:\n\n```dart\ntry {\n  final stream = await daraja.stkPush(\n    phone: '0712345678',\n    amount: 1000,\n    reference: 'ORDER-001',\n    description: 'Payment',\n    userId: currentUser.id,\n  );\n  // ...\n} on DarajaAuthError catch (e) {\n  // consumerKey/consumerSecret wrong, or app not enabled for this API\n  showError('Configuration error: ${e.message}');\n} on StkPushRejectedError catch (e) {\n  // Safaricom accepted the HTTP call but rejected the push before it reached\n  // the customer's phone. ResponseCode is non-zero.\n  if (e.responseCode == '1025') {\n    showError('Another payment is already in progress. Please wait.');\n  } else {\n    showError('Payment could not be initiated: ${e.message}');\n  }\n} on DarajaException catch (e) {\n  // Generic fallback — HTTP errors, network issues\n  showError(e.message);\n}\n```\n\nFor `PaymentFailed` states inside the stream, convenience getters map the Safaricom result codes you'll actually see:\n\n```dart\ncase PaymentFailed(:final message, :final isInsufficientFunds,\n    :final isWrongPin, :final isSubscriberLocked):\n  if (isInsufficientFunds) {\n    showError('Insufficient M-Pesa balance.');\n  } else if (isWrongPin) {\n    showError('Wrong PIN entered. Please try again.');\n  } else if (isSubscriberLocked) {\n    showError('Account temporarily locked. Try again in a moment.');\n  } else {\n    showError(message);\n  }\n```\n\n| Getter | Safaricom resultCode | Meaning |\n|---|---|---|\n| `isInsufficientFunds` | 1 | Customer balance too low |\n| `isWrongPin` | 2001 | Wrong M-Pesa PIN entered |\n| `isSubscriberLocked` | 1001 | Too many wrong PINs or transaction in progress |\n\n## PaymentTimeout\n\n`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.\n\n## B2C disbursements\n\nSend funds from your M-Pesa shortcode to a customer's phone.\n\n### Appwrite B2C collection schema\n\nCreate a second collection (referenced by `b2cCollectionId` in your config):\n\n| Attribute | Type | Required |\n|---|---|---|\n| `originatorConversationId` | String (36) | Yes |\n| `conversationId` | String (50) | No |\n| `status` | String (20) | Yes |\n| `resultCode` | Integer | No |\n| `receipt` | String (20) | No |\n| `amount` | Integer | No |\n| `receiverName` | String (255) | No |\n| `failureReason` | String (255) | No |\n| `mpesaTimestamp` | String (50) | No |\n| `settledAt` | String (50) | Yes |\n\n### SecurityCredential\n\nB2C requires your `InitiatorPassword` encrypted with Safaricom's RSA public key. Extract the public key from the certificate Safaricom provides on the developer portal:\n\n```bash\n# Sandbox\nopenssl x509 -in SandboxCertificate.cer -inform DER -pubkey -noout \u003e sandbox_pubkey.pem\n\n# Production\nopenssl x509 -in ProductionCertificate.cer -inform DER -pubkey -noout \u003e prod_pubkey.pem\n```\n\nThen generate the credential at runtime:\n\n```dart\nimport 'package:daraja/daraja.dart';\n\nconst sandboxCert = '''\n-----BEGIN PUBLIC KEY-----\n\u003cpaste PEM content here\u003e\n-----END PUBLIC KEY-----''';\n\nfinal credential = SecurityCredential.generate(\n  initiatorPassword: 'YourInitiatorPassword',\n  certificate: sandboxCert,\n);\n```\n\n`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()`.\n\n### Usage\n\n```dart\nfinal daraja = Daraja(\n  config: DarajaConfig(\n    // ... existing STK Push fields ...\n    b2cCollectionId: 'disbursements',  // new\n  ),\n);\n\ntry {\n  final stream = await daraja.b2cPush(\n    phone: '0712345678',\n    amount: 500,\n    initiatorName: 'YourInitiatorName',\n    securityCredential: credential,  // from SecurityCredential.generate()\n    remarks: 'March commission',\n    commandId: B2cCommandId.businessPayment,  // default\n    userId: currentUser.id,\n  );\n\n  stream.listen((state) {\n    switch (state) {\n      case DisbursementSuccess(:final receiptNumber, :final amount, :final receiverName):\n        showSuccess('Sent KES $amount to $receiverName — $receiptNumber');\n      case DisbursementFailed(:final message):\n        showError('Disbursement failed: $message');\n      case DisbursementTimeout(:final originatorConversationId):\n        showNeutral('Check dashboard — funds may have been sent ($originatorConversationId)');\n      case DisbursementPending():\n        showSpinner();\n      case DisbursementInitiating():\n        showSpinner();\n    }\n  });\n} on DarajaAuthError catch (e) {\n  showError('Configuration error: ${e.message}');\n} on B2cRejectedError catch (e) {\n  if (e.responseCode == '2001') {\n    showError('Wrong initiator credentials. Check SecurityCredential.');\n  } else {\n    showError('B2C rejected: ${e.message}');\n  }\n} on DarajaException catch (e) {\n  showError(e.message);\n}\n```\n\n### DisbursementTimeout\n\nLike `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.\n\n## Free tier note\n\nAppwrite 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.\n\n## Running the demo\n\n```bash\ncd example/chama\nflutter run \\\n  --dart-define=DARAJA_CONSUMER_KEY=\u003ckey\u003e \\\n  --dart-define=DARAJA_CONSUMER_SECRET=\u003csecret\u003e \\\n  --dart-define=DARAJA_PASSKEY=\u003cpasskey\u003e \\\n  --dart-define=APPWRITE_PROJECT_ID=\u003cid\u003e \\\n  --dart-define=APPWRITE_DATABASE_ID=payments \\\n  --dart-define=APPWRITE_COLLECTION_ID=transactions \\\n  --dart-define=CALLBACK_DOMAIN=https://\u003cfn-domain\u003e.appwrite.run\n```\n\nThe 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.\n\n## Running the tests\n\nUnit tests (no external dependencies):\n\n```bash\nflutter test\n```\n\nIntegration tests (requires [Pesa Playground](https://github.com/OmentaElvis/pesa-playground) running locally):\n\n```bash\npesa-playground --port 3000\n\nflutter test test/integration/ --tags integration \\\n  --dart-define=DARAJA_CONSUMER_KEY=\u003ckey\u003e \\\n  --dart-define=DARAJA_CONSUMER_SECRET=\u003csecret\u003e \\\n  --dart-define=DARAJA_PASSKEY=\u003cpasskey\u003e \\\n  --dart-define=APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 \\\n  --dart-define=APPWRITE_PROJECT_ID=\u003cid\u003e \\\n  --dart-define=APPWRITE_DATABASE_ID=\u003cdb\u003e \\\n  --dart-define=APPWRITE_COLLECTION_ID=\u003ccol\u003e \\\n  --dart-define=CALLBACK_DOMAIN=\u003cdomain\u003e \\\n  --dart-define=APPWRITE_USER_ID=\u003cuid\u003e\n```\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fronnyabuto%2Fdaraja","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fronnyabuto%2Fdaraja","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fronnyabuto%2Fdaraja/lists"}