<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ronny Nyabuto</title>
    <description>The latest articles on DEV Community by Ronny Nyabuto (@ronnyabuto).</description>
    <link>https://dev.to/ronnyabuto</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3674535%2F6d5fa311-537e-457f-81d3-4c85b549cf24.jpg</url>
      <title>DEV Community: Ronny Nyabuto</title>
      <link>https://dev.to/ronnyabuto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ronnyabuto"/>
    <language>en</language>
    <item>
      <title>Building eTIMS for Concurrent POS Traffic</title>
      <dc:creator>Ronny Nyabuto</dc:creator>
      <pubDate>Tue, 21 Apr 2026 10:12:05 +0000</pubDate>
      <link>https://dev.to/ronnyabuto/building-etims-for-concurrent-pos-traffic-22nh</link>
      <guid>https://dev.to/ronnyabuto/building-etims-for-concurrent-pos-traffic-22nh</guid>
      <description>&lt;p&gt;Building an eTIMS integration that actually handles concurrent POS traffic taught me something I didn't expect to find in a government tax spec.&lt;br&gt;
The scenario: a POS terminal submits an invoice, the network hiccups, the terminal retries. Both requests hit your signing service within milliseconds of each other. Standard idempotency pattern is: SELECT to check if this invoice key exists, proceed if absent. Under concurrent retry storms, both requests pass that check before either writes the result. Both invoke the VSCU JAR. Both get signed.&lt;br&gt;
You now have two KRA fiscal receipt numbers for one commercial transaction.&lt;br&gt;
This is not a deduplication problem you can fix in a database. KRA receipt numbers are issued by the JAR and registered upstream. The VSCU Specification v2.0 §4.4 has explicit sequence integrity requirements — gaps AND duplicates in rcptNo surface during KRA audits. You cannot unissue a receipt number. The defect is permanent.&lt;br&gt;
Most engineers I've talked to immediately reach for Redis or a distributed lock manager. I get it — the instinct makes sense. But the database you're already running has atomic COMMIT semantics. That's your mutex.&lt;br&gt;
The pattern that actually works: don't SELECT then INSERT. Just INSERT. Attempt the write immediately against a tenant-scoped key. Let the PRIMARY KEY constraint be the gate. INSERT succeeds → you're the winner, sign the invoice, commit the response. INSERT throws DataIntegrityViolationException → you're the loser, another thread owns this key. Poll for the winner's committed result and replay it verbatim. Winner crashes mid-flight → delete the placeholder, next attempt re-enters as a fresh winner.&lt;br&gt;
Exactly-once fiscal receipt generation. No Redis. No Zookeeper. No distributed lock service. Just PostgreSQL doing what PostgreSQL has always done.&lt;br&gt;
The deeper lesson building TaxID — our middleware layer that abstracts the VSCU JAR for ERP and POS integrations — is that you have to engineer to the cost of failure, not the probability. A 1-in-10,000 duplicate in a shopping cart is a recoverable annoyance. The same race condition in a government-mandated fiscal system is an audit defect with legal consequences under the Income Tax Act §16(1)(c). The probability is the same. The irreversibility is not.&lt;br&gt;
One more §2.2 Policy 4 fact that surprises people: the VSCU JAR stops issuing receipt numbers after 24 continuous hours without a successful KRA sync. Not degrades. Stops signing entirely. If your offline queue architecture assumes unlimited buffering, it's wrong. The ceiling is documented, enforced by the JAR, and non-negotiable. At hour 24, the platform enters SUSPENDED state regardless of how much local queue capacity you have.&lt;br&gt;
Read the spec before you build the queue.&lt;/p&gt;

</description>
      <category>etims</category>
      <category>kra</category>
    </item>
    <item>
      <title>What Daraja 3.0 actually changed for developers — and what it did not</title>
      <dc:creator>Ronny Nyabuto</dc:creator>
      <pubDate>Thu, 16 Apr 2026 07:29:39 +0000</pubDate>
      <link>https://dev.to/ronnyabuto/what-daraja-30-actually-changed-for-developers-and-what-it-did-not-3ek4</link>
      <guid>https://dev.to/ronnyabuto/what-daraja-30-actually-changed-for-developers-and-what-it-did-not-3ek4</guid>
      <description>&lt;p&gt;&lt;strong&gt;What Daraja 3.0 actually changed for developers — and what it did not.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Safaricom launched Daraja 3.0 on November 25, 2025, at the M-Pesa Integrators Forum in Nairobi. The press release mentioned cloud-native architecture, Security APIs, Mini App support, and a self-service onboarding model replacing the old paper-based process. 105,000 registered developers. The biggest M-Pesa API update since Daraja 2.0 launched in 2019.&lt;/p&gt;

&lt;p&gt;Most of the coverage repeated the press release. This post does not.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What actually changed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The platform underneath changed. Daraja 3.0 moved to a cloud-native, microservices-based architecture. Safaricom claims capacity for up to 12,000 transactions per second — a significant ceiling lift over the previous architecture. The developer portal was redesigned. Self-service onboarding is now available, meaning you can go live without the old manual approval process that required back-and-forth with Safaricom's integration team.&lt;/p&gt;

&lt;p&gt;New API categories were added:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ratiba&lt;/em&gt; — scheduled and recurring payments. Daily, weekly, monthly, yearly billing cycles. This is new. There was no recurring payment API in Daraja 2.0.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Security APIs&lt;/em&gt; — fraud detection, prevention, identity verification. Limited public documentation available.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;IoT APIs&lt;/em&gt; — payments for connected devices. Limited public documentation available.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mini App platform&lt;/em&gt; — build lightweight apps that run inside the M-Pesa Super App. Built on Ant Group's Mini Program framework, the same technology that powers Alipay mini-apps. A separate IDE, a JavaScript-based SDK, a submission and approval process. This is a different ecosystem from anything that existed before.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What did not change&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The STK Push endpoint path is the same: &lt;code&gt;/mpesa/stkpush/v1/processrequest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The OAuth token endpoint is the same: &lt;code&gt;/oauth/v1/generate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The callback payload structure is the same: &lt;code&gt;Body.stkCallback&lt;/code&gt;, &lt;code&gt;MerchantRequestID&lt;/code&gt;, &lt;code&gt;CheckoutRequestID&lt;/code&gt;, &lt;code&gt;ResultCode&lt;/code&gt;, &lt;code&gt;CallbackMetadata.Item&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The base URLs are the same: &lt;code&gt;sandbox.safaricom.co.ke&lt;/code&gt; for sandbox, &lt;code&gt;api.safaricom.co.ke&lt;/code&gt; for production.&lt;/p&gt;

&lt;p&gt;The authentication model is the same: base64-encoded Consumer Key and Consumer Secret, standard OAuth2 client credentials flow.&lt;/p&gt;

&lt;p&gt;Existing Daraja 2.0 integrations do not break. If your code hits the STK Push endpoint and handles callbacks correctly, it continues to work on Daraja 3.0 infrastructure without modification. Safaricom was deliberate about backward compatibility on the core payment flows.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What changed at the developer portal level&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mandatory 2FA to access documentation. This sounds minor. It is not minor when you are trying to quickly look up a parameter while debugging at 11 p.m. and your authenticator app is on a different device.&lt;/p&gt;

&lt;p&gt;Self-service onboarding. Previously, going live required manual review by Safaricom's team. The timeline was unpredictable. Self-service removes that bottleneck entirely.&lt;/p&gt;

&lt;p&gt;The AI support chatbot. Community feedback is mixed. It answers common questions but struggles with edge cases and often redirects to the same documentation pages that didn't answer the question in the first place.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The sandbox problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Daraja 3.0 sandbox is unstable for failure-state testing. Connections drop. The environment runs almost exclusively in success mode — STK Pushes succeed, callbacks arrive, ResultCode is 0.&lt;/p&gt;

&lt;p&gt;What you cannot test in the official sandbox: insufficient funds (ResultCode 1), wrong PIN exhaustion (ResultCode 2001), USSD timeout (ResultCode 1037), cancelled by user (ResultCode 1032), request in progress (ResultCode 1025).&lt;/p&gt;

&lt;p&gt;Developers who only test against the official sandbox ship code that has never encountered a real failure mode. Production is where they find out what ResultCode 1032 looks like. Production is not the right place to find that out.&lt;/p&gt;

&lt;p&gt;Pesa Playground, released December 2025, exists specifically to fix this. It runs offline, simulates the full mini-economy with persistent balances, and supports every failure state the official sandbox cannot. It is community-built, actively maintained, and the closest thing to a reliable local development environment the Daraja ecosystem has. If you are building on Daraja and not using Pesa Playground for failure-state testing, you are testing with one hand behind your back.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Mini App platform — a separate conversation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Mini App platform deserves separate treatment because it is not an extension of the existing Daraja API. It is a different product.&lt;/p&gt;

&lt;p&gt;Mini Apps are JavaScript-based. They run inside the M-Pesa Super App container. The SDK is from Ant Group's Mini Program framework — the same technology powering Alipay's mini-app ecosystem. Development happens in a proprietary IDE called Mini Program Studio. The submission and approval process mirrors the WeChat/Alipay model.&lt;/p&gt;

&lt;p&gt;If you are a Flutter developer expecting to build a Mini App in Dart, the answer is no. The two ecosystems do not intersect. Mini App development is JavaScript. Flutter is not involved.&lt;/p&gt;

&lt;p&gt;There are already 80+ Mini Apps live in the M-Pesa Super App across Kenya, Lesotho, Ethiopia, and Mozambique. The platform is real and active. It is also completely separate from anything discussed in the Daraja API documentation, and Safaricom does not make this distinction prominently in their Daraja 3.0 marketing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The honest summary&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Daraja 3.0 is a platform upgrade, not an API overhaul. The developer experience has improved meaningfully — self-service onboarding is genuinely better, the capacity improvements are real, Ratiba is a net-new capability that was missing for years.&lt;/p&gt;

&lt;p&gt;The core STK Push flow, the callback architecture, the asynchronous delivery model, the sandbox limitations — these are unchanged. The fundamental integration challenges that make M-Pesa difficult to build on correctly are the same in Daraja 3.0 as they were in Daraja 2.0.&lt;/p&gt;

&lt;p&gt;There are no community SDKs updated for Daraja 3.0. There are no Flutter packages targeting the new endpoints. The only Daraja 3.0 SDK in existence is a C# library published March 2026.&lt;/p&gt;

&lt;p&gt;The gap between what Daraja 3.0 makes possible and what the tooling ecosystem currently supports is wide. It will not close on its own.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Research conducted April 2026. Sources: Safaricom press release Nov 25 2025, TechCabal, TechArena, Techweez, developer.safaricom.co.ke, mpesaminiapps.safaricom.co.ke, github.com/OmentaElvis/pesa-playground. Daraja portal requires authenticated login for full API catalog.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;mpesa&lt;/code&gt; &lt;code&gt;flutter&lt;/code&gt; &lt;code&gt;dart&lt;/code&gt; &lt;code&gt;webdev&lt;/code&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>microservices</category>
      <category>news</category>
    </item>
    <item>
      <title>Safaricom's sandbox STK Query API returns FAILED for successful payments. Here's what's happening.</title>
      <dc:creator>Ronny Nyabuto</dc:creator>
      <pubDate>Mon, 30 Mar 2026 16:17:02 +0000</pubDate>
      <link>https://dev.to/ronnyabuto/safaricoms-sandbox-stk-query-api-returns-failed-for-successful-payments-heres-whats-happening-dio</link>
      <guid>https://dev.to/ronnyabuto/safaricoms-sandbox-stk-query-api-returns-failed-for-successful-payments-heres-whats-happening-dio</guid>
      <description>&lt;p&gt;Running reconciliation against the Daraja sandbox last week, I got this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"matched"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"skipped"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"mismatches"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"checkoutRequestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ws_CO_26032026133641276708729173"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"storedStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"PENDING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"mpesaStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"FAILED"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"checkoutRequestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ws_CO_26032026111016899708729173"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"storedStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"SUCCESS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"mpesaStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"FAILED"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"checkoutRequestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ws_CO_26032026113146397708729173"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"storedStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"SUCCESS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"mpesaStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"FAILED"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last two entries are the problem. Both have confirmed M-Pesa receipts in the database — &lt;code&gt;UCQ5UAQ403&lt;/code&gt; and &lt;code&gt;UCQ5UAPYRY&lt;/code&gt; — with confirmed deductions on the test account. The STK callback delivered &lt;code&gt;ResultCode: 0&lt;/code&gt; for both. Money moved. Safaricom's own callback said so.&lt;/p&gt;

&lt;p&gt;The STK Query API disagrees. It says both payments failed.&lt;/p&gt;

&lt;p&gt;I searched Stack Overflow, the Safaricom GitHub repos, every community integration I could find. No prior documentation of this. Not a single issue or comment. It appears to be unreported.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's actually happening&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Safaricom's sandbox doesn't fully simulate the USSD network layer. This is documented behavior — it's why Pesa Playground exists. The sandbox can't reliably generate failure states. What's less documented is the inverse: the sandbox STK Query endpoint apparently cannot reliably confirm success states either. It defaults to FAILED when it can't definitively resolve a transaction, regardless of what the callback already told you.&lt;/p&gt;

&lt;p&gt;The sandbox callback and the sandbox STK Query are not reading from the same source of truth.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;How &lt;a href="https://www.npmjs.com/package/mpesa-stk" rel="noopener noreferrer"&gt;mpesa-stk@0.1.1&lt;/a&gt; handled it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The library refused to act on the contradiction. &lt;code&gt;matched:0&lt;/code&gt; — it checked the payments, found that the STK Query response conflicted with an authoritative stored SUCCESS, and did not overwrite. The PENDING record from the orphaned payment stayed PENDING rather than being incorrectly resolved to FAILED.&lt;/p&gt;

&lt;p&gt;That is the correct behavior. A reconciliation system that overwrites &lt;code&gt;SUCCESS&lt;/code&gt; with a contradictory query response would be worse than one that does nothing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What this means for your reconciliation implementation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two things need to be true in how you handle STK Query responses:&lt;/p&gt;

&lt;p&gt;Never overwrite a terminal &lt;code&gt;SUCCESS&lt;/code&gt; or confirmed &lt;code&gt;FAILED&lt;/code&gt; record based on a query response alone. The callback is the authoritative source. The query is a fallback for records that never received a callback — &lt;code&gt;PENDING&lt;/code&gt; only.&lt;/p&gt;

&lt;p&gt;Don't trust sandbox reconciliation results. The sandbox STK Query is not a reliable test surface for this code path. Test your reconciliation logic against a production environment, or accept that sandbox results for this specific path are noise.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The production question&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I haven't run this against a live production environment. Safaricom's documentation implies the production STK Query returns accurate results — the sandbox is the broken environment, not production. If you've tested reconciliation in production and can confirm the query API behaves correctly there, I'd like to know. Leave a comment or find me on the Daraja Discord.&lt;/p&gt;

&lt;p&gt;The finding stands regardless: if you're building reconciliation, your implementation needs to handle contradictory query responses. The sandbox will generate them. Production might too, in edge cases nobody has documented yet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tested on 2026-03-26, Daraja sandbox, &lt;a href="https://www.npmjs.com/package/mpesa-stk" rel="noopener noreferrer"&gt;mpesa-stk@0.1.1&lt;/a&gt;. Full test log in the &lt;a href="https://github.com/ronnyabuto/flutter-daraja-raw" rel="noopener noreferrer"&gt;flutter-daraja-raw repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>mpesa</category>
      <category>node</category>
      <category>javascript</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I measured M-Pesa STK Push polling lag on a real device. The variance will ruin your UX.</title>
      <dc:creator>Ronny Nyabuto</dc:creator>
      <pubDate>Thu, 26 Mar 2026 11:49:01 +0000</pubDate>
      <link>https://dev.to/ronnyabuto/i-measured-m-pesa-stk-push-polling-lag-on-a-real-device-the-variance-will-ruin-your-ux-38j1</link>
      <guid>https://dev.to/ronnyabuto/i-measured-m-pesa-stk-push-polling-lag-on-a-real-device-the-variance-will-ruin-your-ux-38j1</guid>
      <description>&lt;p&gt;Same code. Same device. Same network. Same shortcode.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Test 1: 39 seconds from PIN entry to UI update.&lt;br&gt;
Test 2: 3 seconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;13x variance. Not a bug. Not a fluke. Just the math of a fixed polling schedule colliding with a non-deterministic callback.&lt;br&gt;
When you fire an STK Push, Safaricom returns a &lt;code&gt;CheckoutRequestID&lt;/code&gt; and &lt;code&gt;ResponseCode: 0&lt;/code&gt; almost immediately. Most developers celebrate this. It means nothing. It means Safaricom received your request. The customer hasn't seen a prompt yet.&lt;/p&gt;

&lt;p&gt;The actual payment outcome arrives later — via a POST to your &lt;code&gt;CallBackURL&lt;/code&gt;. That callback takes 5 seconds or it takes 45. Safaricom doesn't tell you when it's coming. And if your server isn't reachable when it arrives, Safaricom does not retry. The delivery attempt is fire-and-forget.&lt;/p&gt;

&lt;p&gt;So the typical Flutter developer does what makes sense: they poll. Every 10 or 30 seconds, ask the server if anything happened. This works until it doesn't.&lt;/p&gt;



&lt;p&gt;My polling schedule fired at T+10s, T+30s, and T+70s. In Test 1, the callback landed at T+45s — squarely between the T+30 and T+70 windows. The next poll was 25 seconds away. Safaricom completed the payment in 14 seconds. The user waited 39.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Test 1:
  PIN entered:        11:10:48
  Callback processed: 11:11:02  (14s — Safaricom's side)
  UI updated:         11:11:27  (39s — polling lag)

  Polls: T+10 → PENDING, T+30 → PENDING, T+70 → SUCCESS

Test 2:
  PIN entered:        11:31:55
  Callback processed: 11:31:59
  UI updated:         11:31:58  (3s)

  T+10 poll and callback arrived within 1 second of each other.
  Lucky timing. Not better code.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same polling schedule. The only variable was when Safaricom's callback landed relative to the poll windows.&lt;/p&gt;




&lt;p&gt;There is one optimisation that actually moves the number.&lt;/p&gt;

&lt;p&gt;The real-world flow for most users: tap "Pay," get the USSD prompt, press home, open M-Pesa to confirm the request or check their balance, enter PIN, return to your app. The app was backgrounded the entire time. The callback arrived and was processed server-side while the user was in a different app. Without &lt;code&gt;WidgetsBindingObserver&lt;/code&gt;, they come back to a spinner and wait for the next scheduled poll.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nd"&gt;@override&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;didChangeAppLifecycleState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppLifecycleState&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;AppLifecycleState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resumed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paymentProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;checkStatusOnResume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment they return to your app, you poll immediately. My Test 7 result: 1–2 seconds from return to PaymentSuccess.&lt;/p&gt;

&lt;p&gt;That is not a polling win. That is knowing when to trigger the poll. Most Flutter M-Pesa implementations do not have this. The USSD flow almost guarantees the user will background the app. The one scenario you should optimize for is the one most developers leave unhandled.&lt;/p&gt;




&lt;p&gt;The failure mode nobody documents is worse.&lt;/p&gt;

&lt;p&gt;Test 3: I killed the ngrok tunnel after the STK Push was sent but before the customer entered their PIN. Customer paid. Balance reduced. Server never received the callback. Safaricom made one delivery attempt, got no response, and moved on.&lt;/p&gt;

&lt;p&gt;DB state after 90 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;               &lt;span class="s"&gt;PENDING&lt;/span&gt;
&lt;span class="na"&gt;result_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;          &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="na"&gt;failure_reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="na"&gt;mpesa_receipt_number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app timed out and displayed: &lt;em&gt;"Status unknown. We did not receive a confirmation within the expected window."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That copy is deliberate. Telling a user their payment failed when money has already left their account is not a UX problem. It is a trust problem. The distinction matters more than most developers realize until a customer calls.&lt;/p&gt;

&lt;p&gt;This is not a contrived scenario. It happens when your server restarts, when your laptop sleeps during a demo, when a deployment takes thirty seconds at the wrong moment. Safaricom does not retry. The only recovery is reconciliation — query the STK Push Query endpoint on a schedule and resolve orphaned PENDING records.&lt;/p&gt;

&lt;p&gt;One caveat: Safaricom's sandbox STK Query API returned FAILED for confirmed SUCCESS payments during testing. That is a known sandbox limitation. Production behaves correctly.&lt;/p&gt;




&lt;p&gt;The baseline from this session:&lt;/p&gt;

&lt;p&gt;Polling lag: 3–39 seconds, non-deterministic.&lt;br&gt;
Callback delivery: 100% when the server is reachable. 0% when it isn't.&lt;br&gt;
Lifecycle optimisation: 1–2 seconds on resume, which covers the most common real-world flow.&lt;/p&gt;

&lt;p&gt;Every Flutter developer building on M-Pesa either lives with these numbers, reinvents the solution from scratch, or doesn't know the problem exists until a production incident surfaces it.&lt;/p&gt;

&lt;p&gt;No maintained Flutter package handles the full lifecycle — callback receipt, persistence, polling fallback, lifecycle recovery — without requiring a separately managed backend. That is the gap.&lt;/p&gt;

&lt;p&gt;The next post will show what happens when you replace the polling cascade with Appwrite Realtime. The numbers are not subtle.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tested on Google Pixel 9, Android 15. Daraja sandbox, Flutter 3.41. All timings are from real device logs. Test harness: &lt;a href="https://github.com/ronnyabuto/flutter-daraja-raw" rel="noopener noreferrer"&gt;flutter-daraja-raw.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mpesa</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
