[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"i-lucide:sun":3,"i-lucide:menu":8,"i-lucide:mail":10,"i-lucide:phone":12,"i-lucide:calendar":14,"i-lucide:map-pin":16,"post-rescue-or-rebuild-a-legacy-system":18,"post-html-rescue-or-rebuild-a-legacy-system":33,"post-siblings-rescue-or-rebuild-a-legacy-system":59,"i-lucide:arrow-left":139,"i-lucide:linkedin":141,"i-lucide:twitter":144,"i-lucide:link":146,"i-lucide:chevron-down":148},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,24,false,"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"4\"\u002F>\u003Cpath d=\"M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 5h16M4 12h16M4 19h16\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"m22 7l-8.991 5.727a2 2 0 0 1-2.009 0L2 7\"\u002F>\u003Crect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233a14 14 0 0 0 6.392 6.384\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M8 2v3m8-3v3\"\u002F>\u003Crect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"\u002F>\u003Cpath d=\"M3 9h18\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":17},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0\"\u002F>\u003Ccircle cx=\"12\" cy=\"10\" r=\"3\"\u002F>\u003C\u002Fg>",{"slug":19,"title":20,"excerpt":21,"body":22,"cover":23,"tags":24,"publishedAt":28,"updatedAt":29,"readingTime":30,"seoTitle":31,"seoDescription":32},"rescue-or-rebuild-a-legacy-system","Rescue or rebuild: deciding what to do with a legacy system","When a codebase is worth stabilising and when it is not, what an assessment should tell you before you choose, the risks in each direction, and how to phase a migration so it can pause.","# Rescue or rebuild: deciding what to do with a legacy system\n\nThe system still runs the business. Invoices go out, customers log in, money comes in. It is also the system nobody wants to touch: every change breaks something unrelated, the person who understood it has left, and the hosting bill quietly includes a server nobody can explain. Somebody has said the word \"rewrite\", and somebody else has said it will take twice as long as anyone thinks.\n\nBoth of them may be right. This post is the framework we use to decide: when a codebase is worth stabilising, when it is not, what an assessment should tell you before you choose, what goes wrong in each direction, and how to phase a migration so that it can stop halfway and still leave you better off.\n\n\u003C!-- TODO(david): if a rescue from the founder's track record can be described without naming the client, one short real example belongs in the opening, before the framework. It must be his account, not a composite. -->\n\n## Start with what the system knows\n\nA system that has run a business for years holds knowledge that exists nowhere else. Not in the documentation, if there is any, and often not in anybody's head. It is in the code: the rule that a customer on a particular plan is billed on the first working day rather than the first, the discount that applies only when two conditions are true, the report that excludes a category because an accountant asked for that four years ago.\n\nThat knowledge is the strongest argument against a rebuild, and the most underestimated. A rewrite starts from what people remember the system does, and people remember the main paths. The edge cases come back one at a time, as complaints, after launch.\n\nWe hit a small version of this in our own product. In rentiQ, an invoice can carry arrears brought forward from earlier months, so adding up invoice balances counts the same debt twice. The correct figure is what was charged minus what was paid, and that rule now sits in the code and in the reports built on it. A rebuild that missed it would produce arrears reports that were confidently wrong, and nothing about a single invoice would look out of place. Every legacy system has rules like that. The question is whether you can find them before you replace the code that holds them.\n\n## What an assessment should tell you\n\nBefore anyone chooses, somebody should read the system with these questions in mind. Each one has an answer you can check rather than an opinion you can argue about.\n\n**Can it be built and deployed from scratch, on a clean machine, from what is in the repository?** If not, nothing else can be measured until it can, and this alone is often the first week of a rescue.\n\n**Do the tests protect anything?** A test suite's existence proves little. We have found tests in our own code that passed against behaviour they were never reaching: a guard that only ran in one environment, tested in another where it never ran; a copy button whose test checked that it was visible while it copied nothing at all. The useful question is whether breaking the important behaviour makes a test fail. Try it, on purpose, on the three things that matter most.\n\n**Does it do what its configuration says it does?** Systems can look healthy while a whole layer does nothing. On our own site, a move between hosting platforms turned a whole set of caching rules into rules the new platform does not read. Nothing failed. Had it shipped, every page would have rendered correctly while being rebuilt from scratch on every request, and only a check of the behaviour, not of the configuration, would have shown it. Configuration is a claim. The assessment checks the behaviour.\n\n**How far behind are the framework and its dependencies?** A version still inside its support window is routine maintenance. One that has fallen out of it stops receiving security fixes, and every year it stays there makes the eventual upgrade bigger.\n\n**Where does correctness live?** Money, stock, entitlements, anything a customer or an auditor will check. Find the code that decides those numbers and judge it separately from the rest, because it is the part where a mistake is expensive.\n\n**Is the data sound?** Orphaned records, duplicated customers, balances that do not match their transactions. Bad data survives any rewrite, and a migration is the moment it gets noticed, usually by a customer.\n\n**Who can change it today, and how long does a small change take?** The cost of the system is not its hosting bill. It is the cost of every change the business needs and cannot get.\n\n**What has broken in the last year, and why?** Incidents show where the real weaknesses are faster than any code review.\n\nThe assessment's output should be short: the risks ranked, what each would cost to address, and a recommendation with the reasoning written down. If it cannot fit on a few pages, it has not been finished.\n\n## When to stabilise it\n\nRescue is usually right when the core of the system is sound and its problems are around it: missing tests, no deployment process, outdated dependencies, no monitoring, nobody who knows it. Those are fixable without touching what the business relies on, and each fix makes the next one safer.\n\nSigns that point that way:\n\n- The data model fits the business. The tables describe the real things, even if the code around them is untidy.\n- The behaviour that matters is correct, even if it is hard to change.\n- The framework is old but still has a supported upgrade path.\n- Most of the pain is in how changes are made, not in what the system is.\n\nA rescue usually begins with the unglamorous work: make it build and deploy repeatably, add monitoring so failures are seen, write tests around the behaviour that matters before changing it, upgrade dependencies in small steps. Only then does it change anything a user would notice.\n\n## When to replace it\n\nRebuild, or more often replace in parts, when the problem is the core rather than the surroundings:\n\n- The data model contradicts the business, so every new feature is a workaround.\n- The platform has no supported path forward and no security fixes.\n- Correctness problems are structural: the same number computed in five places, five different ways.\n- The system cannot meet a requirement that is no longer optional, such as a regulatory change it was never designed for.\n\nEven then, \"rebuild\" rarely means switching everything off one weekend and switching the new system on the next. That approach has the highest risk and the latest feedback.\n\n## The risks in each direction\n\n**Rescue risks.** Sunk cost: continuing to invest in something that cannot reach where the business is going. A ceiling: some limits are in the architecture and no amount of tidying lifts them. And drift: a rescue without a clear end point becomes permanent maintenance with a more optimistic name.\n\n**Rebuild risks.** The forgotten rules described above. A long period in which the business gets nothing new, because everything goes into reaching parity. Two systems running at once, each needing support. A data migration that has to be right the first time it meets real data. And the second-system temptation: adding everything the old system lacked, which turns a replacement into a larger project than the original.\n\nNeither list is a reason not to proceed. Both are lists of things to plan for.\n\n## Phase it so it can pause\n\nWhichever direction you choose, structure the work so that stopping after any phase leaves the business better off than before it started. Budgets change and priorities move, and a migration that only pays off when it is fully finished is a bet that neither will.\n\n**Phase one is the same either way: make the system safe to change.** Repeatable builds, deployment, monitoring, backups that have been restored, and tests around the behaviour that matters. This is worth doing even if nothing else ever happens, and it is the foundation for both a rescue and a replacement.\n\n**Then move one part at a time, behind the old system's front door.** Put a routing layer in front of the old system, move one feature or one workflow to new code, and route only that traffic to it. The old system keeps doing everything else. If the new part misbehaves, routing sends traffic back. Each step is small enough to reverse.\n\n**Choose the order by risk and value, not by convenience.** Early moves should be parts that are valuable to improve and low risk to get wrong, so the approach is proven before it touches the parts where mistakes are expensive. Money handling moves late, after the team has done the routine moves and the reconciliation checks are in place.\n\n**Migrate data with a way back.** Move it in a form you can verify, run both systems against it for a period where that is possible, and compare the outputs. A migration you cannot reverse is a migration you have to get right on the first attempt.\n\n**Stop when the remaining parts are not worth moving.** Some parts of an old system are stable, rarely changed and doing their job. Leaving them where they are, behind a clean interface, is a legitimate ending.\n\n## What to do this month\n\nIf you are weighing this decision now, three things are worth doing before anyone writes code. Get the system building and deploying from its repository, because every later step depends on it. Write down the five numbers the business relies on and find the code that produces each one. And ask for an assessment that ends in a recommendation and its reasons, not a list of everything that is wrong.\n\nThat assessment, and the stabilise-first work that follows it, is what our [modernization and rescue](\u002Fservices#modernization-and-rescue) work covers.\n",null,[25,26,27],"maintenance","architecture","modernization","2026-10-08T08:00:00+03:00","2026-10-15T13:05:58+03:00",9,"Rescue or rebuild a legacy system: a decision framework","When to stabilise a legacy system and when to replace it, what an assessment should establish first, the risks either way, and a migration you can pause.",{"html":34,"toc":35,"css":58},"\u003Cp>The system still runs the business. Invoices go out, customers log in, money comes in. It is also the system nobody wants to touch: every change breaks something unrelated, the person who understood it has left, and the hosting bill quietly includes a server nobody can explain. Somebody has said the word “rewrite”, and somebody else has said it will take twice as long as anyone thinks.\u003C\u002Fp>\n\u003Cp>Both of them may be right. This post is the framework we use to decide: when a codebase is worth stabilising, when it is not, what an assessment should tell you before you choose, what goes wrong in each direction, and how to phase a migration so that it can stop halfway and still leave you better off.\u003C\u002Fp>\n\u003Ch2 id=\"start-with-what-the-system-knows\">Start with what the system knows\u003C\u002Fh2>\n\u003Cp>A system that has run a business for years holds knowledge that exists nowhere else. Not in the documentation, if there is any, and often not in anybody’s head. It is in the code: the rule that a customer on a particular plan is billed on the first working day rather than the first, the discount that applies only when two conditions are true, the report that excludes a category because an accountant asked for that four years ago.\u003C\u002Fp>\n\u003Cp>That knowledge is the strongest argument against a rebuild, and the most underestimated. A rewrite starts from what people remember the system does, and people remember the main paths. The edge cases come back one at a time, as complaints, after launch.\u003C\u002Fp>\n\u003Cp>We hit a small version of this in our own product. In rentiQ, an invoice can carry arrears brought forward from earlier months, so adding up invoice balances counts the same debt twice. The correct figure is what was charged minus what was paid, and that rule now sits in the code and in the reports built on it. A rebuild that missed it would produce arrears reports that were confidently wrong, and nothing about a single invoice would look out of place. Every legacy system has rules like that. The question is whether you can find them before you replace the code that holds them.\u003C\u002Fp>\n\u003Ch2 id=\"what-an-assessment-should-tell-you\">What an assessment should tell you\u003C\u002Fh2>\n\u003Cp>Before anyone chooses, somebody should read the system with these questions in mind. Each one has an answer you can check rather than an opinion you can argue about.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Can it be built and deployed from scratch, on a clean machine, from what is in the repository?\u003C\u002Fstrong> If not, nothing else can be measured until it can, and this alone is often the first week of a rescue.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Do the tests protect anything?\u003C\u002Fstrong> A test suite’s existence proves little. We have found tests in our own code that passed against behaviour they were never reaching: a guard that only ran in one environment, tested in another where it never ran; a copy button whose test checked that it was visible while it copied nothing at all. The useful question is whether breaking the important behaviour makes a test fail. Try it, on purpose, on the three things that matter most.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Does it do what its configuration says it does?\u003C\u002Fstrong> Systems can look healthy while a whole layer does nothing. On our own site, a move between hosting platforms turned a whole set of caching rules into rules the new platform does not read. Nothing failed. Had it shipped, every page would have rendered correctly while being rebuilt from scratch on every request, and only a check of the behaviour, not of the configuration, would have shown it. Configuration is a claim. The assessment checks the behaviour.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>How far behind are the framework and its dependencies?\u003C\u002Fstrong> A version still inside its support window is routine maintenance. One that has fallen out of it stops receiving security fixes, and every year it stays there makes the eventual upgrade bigger.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Where does correctness live?\u003C\u002Fstrong> Money, stock, entitlements, anything a customer or an auditor will check. Find the code that decides those numbers and judge it separately from the rest, because it is the part where a mistake is expensive.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Is the data sound?\u003C\u002Fstrong> Orphaned records, duplicated customers, balances that do not match their transactions. Bad data survives any rewrite, and a migration is the moment it gets noticed, usually by a customer.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Who can change it today, and how long does a small change take?\u003C\u002Fstrong> The cost of the system is not its hosting bill. It is the cost of every change the business needs and cannot get.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>What has broken in the last year, and why?\u003C\u002Fstrong> Incidents show where the real weaknesses are faster than any code review.\u003C\u002Fp>\n\u003Cp>The assessment’s output should be short: the risks ranked, what each would cost to address, and a recommendation with the reasoning written down. If it cannot fit on a few pages, it has not been finished.\u003C\u002Fp>\n\u003Ch2 id=\"when-to-stabilise-it\">When to stabilise it\u003C\u002Fh2>\n\u003Cp>Rescue is usually right when the core of the system is sound and its problems are around it: missing tests, no deployment process, outdated dependencies, no monitoring, nobody who knows it. Those are fixable without touching what the business relies on, and each fix makes the next one safer.\u003C\u002Fp>\n\u003Cp>Signs that point that way:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>The data model fits the business. The tables describe the real things, even if the code around them is untidy.\u003C\u002Fli>\n\u003Cli>The behaviour that matters is correct, even if it is hard to change.\u003C\u002Fli>\n\u003Cli>The framework is old but still has a supported upgrade path.\u003C\u002Fli>\n\u003Cli>Most of the pain is in how changes are made, not in what the system is.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A rescue usually begins with the unglamorous work: make it build and deploy repeatably, add monitoring so failures are seen, write tests around the behaviour that matters before changing it, upgrade dependencies in small steps. Only then does it change anything a user would notice.\u003C\u002Fp>\n\u003Ch2 id=\"when-to-replace-it\">When to replace it\u003C\u002Fh2>\n\u003Cp>Rebuild, or more often replace in parts, when the problem is the core rather than the surroundings:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>The data model contradicts the business, so every new feature is a workaround.\u003C\u002Fli>\n\u003Cli>The platform has no supported path forward and no security fixes.\u003C\u002Fli>\n\u003Cli>Correctness problems are structural: the same number computed in five places, five different ways.\u003C\u002Fli>\n\u003Cli>The system cannot meet a requirement that is no longer optional, such as a regulatory change it was never designed for.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Even then, “rebuild” rarely means switching everything off one weekend and switching the new system on the next. That approach has the highest risk and the latest feedback.\u003C\u002Fp>\n\u003Ch2 id=\"the-risks-in-each-direction\">The risks in each direction\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>Rescue risks.\u003C\u002Fstrong> Sunk cost: continuing to invest in something that cannot reach where the business is going. A ceiling: some limits are in the architecture and no amount of tidying lifts them. And drift: a rescue without a clear end point becomes permanent maintenance with a more optimistic name.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Rebuild risks.\u003C\u002Fstrong> The forgotten rules described above. A long period in which the business gets nothing new, because everything goes into reaching parity. Two systems running at once, each needing support. A data migration that has to be right the first time it meets real data. And the second-system temptation: adding everything the old system lacked, which turns a replacement into a larger project than the original.\u003C\u002Fp>\n\u003Cp>Neither list is a reason not to proceed. Both are lists of things to plan for.\u003C\u002Fp>\n\u003Ch2 id=\"phase-it-so-it-can-pause\">Phase it so it can pause\u003C\u002Fh2>\n\u003Cp>Whichever direction you choose, structure the work so that stopping after any phase leaves the business better off than before it started. Budgets change and priorities move, and a migration that only pays off when it is fully finished is a bet that neither will.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Phase one is the same either way: make the system safe to change.\u003C\u002Fstrong> Repeatable builds, deployment, monitoring, backups that have been restored, and tests around the behaviour that matters. This is worth doing even if nothing else ever happens, and it is the foundation for both a rescue and a replacement.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Then move one part at a time, behind the old system’s front door.\u003C\u002Fstrong> Put a routing layer in front of the old system, move one feature or one workflow to new code, and route only that traffic to it. The old system keeps doing everything else. If the new part misbehaves, routing sends traffic back. Each step is small enough to reverse.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Choose the order by risk and value, not by convenience.\u003C\u002Fstrong> Early moves should be parts that are valuable to improve and low risk to get wrong, so the approach is proven before it touches the parts where mistakes are expensive. Money handling moves late, after the team has done the routine moves and the reconciliation checks are in place.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Migrate data with a way back.\u003C\u002Fstrong> Move it in a form you can verify, run both systems against it for a period where that is possible, and compare the outputs. A migration you cannot reverse is a migration you have to get right on the first attempt.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Stop when the remaining parts are not worth moving.\u003C\u002Fstrong> Some parts of an old system are stable, rarely changed and doing their job. Leaving them where they are, behind a clean interface, is a legitimate ending.\u003C\u002Fp>\n\u003Ch2 id=\"what-to-do-this-month\">What to do this month\u003C\u002Fh2>\n\u003Cp>If you are weighing this decision now, three things are worth doing before anyone writes code. Get the system building and deploying from its repository, because every later step depends on it. Write down the five numbers the business relies on and find the code that produces each one. And ask for an assessment that ends in a recommendation and its reasons, not a list of everything that is wrong.\u003C\u002Fp>\n\u003Cp>That assessment, and the stabilise-first work that follows it, is what our \u003Ca href=\"\u002Fservices#modernization-and-rescue\">modernization and rescue\u003C\u002Fa> work covers.\u003C\u002Fp>\n",[36,40,43,46,49,52,55],{"id":37,"text":38,"level":39},"start-with-what-the-system-knows","Start with what the system knows",2,{"id":41,"text":42,"level":39},"what-an-assessment-should-tell-you","What an assessment should tell you",{"id":44,"text":45,"level":39},"when-to-stabilise-it","When to stabilise it",{"id":47,"text":48,"level":39},"when-to-replace-it","When to replace it",{"id":50,"text":51,"level":39},"the-risks-in-each-direction","The risks in each direction",{"id":53,"text":54,"level":39},"phase-it-so-it-can-pause","Phase it so it can pause",{"id":56,"text":57,"level":39},"what-to-do-this-month","What to do this month","",[60,74,84,96,109,111,122,131],{"slug":61,"title":62,"excerpt":63,"body":64,"cover":23,"tags":65,"publishedAt":70,"updatedAt":29,"readingTime":71,"seoTitle":72,"seoDescription":73},"mpesa-daraja-integration-guide","M-Pesa Daraja in production: a practical integration guide","What the Daraja documentation leaves to you: choosing STK push or C2B, callbacks you cannot trust, duplicate receipts, the payment that is taken but never confirmed, go-live, and reconciling against the statement.","# M-Pesa Daraja in production: a practical integration guide\n\nSafaricom's Daraja documentation tells you what to send and what comes back. It tells you much less about what to do when the answer never arrives, arrives twice, or describes money you cannot place. Those cases are where an integration earns or loses the trust of whoever reads the books, so this guide is mostly about them.\n\nEvery Daraja behaviour described here links to Safaricom's own documentation, read on 8 October 2026. Where the documentation is silent we say so, and we design around the gap rather than guess at it. The code is Laravel, written for Laravel 11 and later on PHP 8.3, and each piece below ran against Safaricom's documented sample payloads in a test suite before it went into this post.\n\n## Choose the flow before you write code\n\nDaraja offers two ways to take a customer payment, and they answer different questions.\n\n### STK push: you ask, the customer approves\n\n[M-Pesa Express](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FMpesaExpressSimulate), better known as STK push, is initiated by you. You send the amount, your shortcode and a reference, a prompt appears on the customer's phone, and they enter their PIN. You know what the payment is for before any money moves, because you said so in the request.\n\nThe constraints that matter in practice, all from Safaricom's page for the API:\n\n- `AccountReference` is shown to the customer and is limited to 12 characters. `TransactionDesc` is limited to 13.\n- The response to your request is an acknowledgement, not a payment. The result comes later, to your callback URL.\n- A customer can only have one prompt in progress. A second request while one is open fails with \"Unable to lock subscriber\", and the documented advice is to wait at least a minute.\n- Result code 17 means you sent the same amount to the same customer within two minutes, and the request was refused.\n\nSTK push suits anything with a checkout: an online order, an invoice paid from a link, a subscription renewal the customer starts.\n\n### C2B: the customer pays, you are told afterwards\n\n[Customer to Business](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FCustomerToBusiness) covers payments the customer starts themselves, from the M-Pesa menu, the app or USSD, to your Paybill or Till. You register a confirmation URL once, and M-Pesa posts each completed payment to it.\n\nYou do not know a C2B payment is coming. You find out after the money has moved, and what you learn is what the customer typed. For a Paybill, the `BillRefNumber` is the account number the customer entered, up to 20 characters. For a Till, there is no account number at all.\n\nC2B suits recurring obligations people pay on their own schedule: rent, school fees, invoices, utility bills, anything where the payer never visits your website.\n\n### Most real businesses need both\n\n| | STK push | C2B |\n|---|---|---|\n| Who starts it | You | The customer |\n| You know the purpose | Before payment | Only from what they typed |\n| Customer types | Their PIN | Paybill, account number, amount, PIN |\n| Fits | Checkouts, payment links | Rent, fees, invoices paid in their own time |\n\nWhichever doors you open, the payment's identity is the same: the STK callback calls it `MpesaReceiptNumber` and the C2B confirmation calls it `TransID`, and both are documented as the unique M-Pesa transaction ID, the same code the customer receives by SMS. Key everything on it. A payment that reaches you through two doors is still one payment.\n\n\u003C!-- TODO(david): confirm with Safaricom API support whether an STK push to a Paybill that also has C2B URLs registered fires the confirmation URL as well. The design above is correct either way, because both paths key on the receipt, but the post could then state it plainly. -->\n\n## The callback contract, and why you cannot trust delivery\n\nHere is what the documentation promises. After an STK push is processed, the result is posted to the `CallBackURL` you supplied. After a C2B payment completes, M-Pesa posts it to your registered confirmation URL.\n\nHere is what it does not promise: that a callback arrives exactly once, that it arrives at all, or that it arrives before something else tells you about the same payment. Safaricom's own guidance for failed notifications is to fall back to the Pull Transaction API or the M-Pesa organisation portal, which, in the words of the [Register URL documentation](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FCustomerToBusinessRegisterURL), \"has all the payments ever made available, whether you received the callback or not.\"\n\nRead that as the design brief. A callback is a notification. The statement is the record. Four rules follow from it:\n\n1. **Store the raw callback before doing anything else with it.** If processing fails, you still have what Safaricom sent.\n2. **Answer quickly and do the work in a queue.** Your HTTP handler should write a row and return.\n3. **Make processing idempotent.** The same callback processed twice must change nothing the second time.\n4. **Keep a second path to the truth.** For STK push, that is the status query. For everything, it is the statement.\n\n### Callbacks carry no signature\n\nThe documentation describes no signature, token or other authentication on callbacks. Anyone who learns your callback URL can post to it. Three things reduce that risk:\n\n- **A secret in the path.** Generate a long random string, put it in the URL you register, and return 404 for anything else. It is only as secret as your logs, so keep request paths out of anything shared.\n- **An allowlist of Safaricom's source addresses**, at the firewall or the reverse proxy. We found no list in the public API documentation, so ask Safaricom's API support for the current one rather than copying a list from a forum.\n- **Callbacks only ever complete something you started.** An STK result is applied only to a request your system created, for the amount it asked for. A forged callback for a checkout ID you never issued changes nothing.\n\n\u003C!-- TODO(david): if Safaricom API support supplies the callback source IP list, link to wherever they publish it, or say it is provided on request. -->\n\n### The URL rules that fail registration\n\nFrom the same Register URL page: production URLs must be HTTPS, public URL testers such as ngrok are not allowed and are usually blocked, and URLs must avoid the words M-PESA, Safaricom, exe, exec, cmd, SQL and query, in any case. That last list catches more than it looks like it should. `\u002Fpayments\u002Fquery` fails, and so does any path containing `mpesa`.\n\nThese are the routes the rest of this guide uses, in `routes\u002Fapi.php` so Laravel's CSRF protection does not apply:\n\n```php\nuse App\\Payments\\Mpesa\\Http\\DarajaCallbackController;\n\nRoute::post('\u002Fhooks\u002Fdaraja\u002F{secret}\u002Fstk', [DarajaCallbackController::class, 'stk']);\nRoute::post('\u002Fhooks\u002Fdaraja\u002F{secret}\u002Fconfirm', [DarajaCallbackController::class, 'confirm']);\nRoute::post('\u002Fhooks\u002Fdaraja\u002F{secret}\u002Fvalidate', [DarajaCallbackController::class, 'validate']);\n```\n\nAnd the controller behind them. It stores, dispatches and answers, and nothing else:\n\n`app\u002FPayments\u002FMpesa\u002FHttp\u002FDarajaCallbackController.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa\\Http;\n\nuse App\\Payments\\Mpesa\\Jobs\\ProcessC2bConfirmation;\nuse App\\Payments\\Mpesa\\Jobs\\ProcessStkResult;\nuse App\\Payments\\Mpesa\\MpesaCallback;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\n\nfinal class DarajaCallbackController\n{\n    public function stk(Request $request, string $secret): JsonResponse\n    {\n        $this->guard($secret);\n\n        \u002F\u002F Store first, process later. A crash after this line loses nothing.\n        $callback = MpesaCallback::create(['kind' => 'stk', 'payload' => $request->all()]);\n        ProcessStkResult::dispatch($callback->id);\n\n        return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);\n    }\n\n    public function confirm(Request $request, string $secret): JsonResponse\n    {\n        $this->guard($secret);\n\n        $callback = MpesaCallback::create(['kind' => 'confirmation', 'payload' => $request->all()]);\n        ProcessC2bConfirmation::dispatch($callback->id);\n\n        return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Success']);\n    }\n\n    public function validate(Request $request, string $secret): JsonResponse\n    {\n        $this->guard($secret);\n\n        MpesaCallback::create(['kind' => 'validation', 'payload' => $request->all()]);\n\n        \u002F\u002F Answer in well under the ~8 seconds M-Pesa waits. Reject only what\n        \u002F\u002F can never be right; anything merely unrecognised is accepted and\n        \u002F\u002F goes to the suspense queue after confirmation.\n        $amount = (string) $request->input('TransAmount');\n        if (! preg_match('\u002F^\\d+(\\.0{1,2})?$\u002F', $amount) || (int) $amount \u003C 1) {\n            return response()->json(['ResultCode' => 'C2B00013', 'ResultDesc' => 'Rejected']);\n        }\n\n        return response()->json(['ResultCode' => '0', 'ResultDesc' => 'Accepted']);\n    }\n\n    private function guard(string $secret): void\n    {\n        abort_unless(hash_equals((string) config('mpesa.callback_secret'), $secret), 404);\n    }\n}\n```\n\nThe tables behind it are deliberately plain. `mpesa_callbacks` is an inbox that is written once per delivery. `payment_requests` holds one row per STK push you started. `mpesa_transactions` holds one row per M-Pesa receipt, however you learned about it, and the unique index on `receipt` is the most important line in the schema.\n\n`database\u002Fmigrations\u002Fcreate_mpesa_tables.php`\n\n```php\n\u003C?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        \u002F\u002F Every callback, exactly as it arrived. Written before anything else\n        \u002F\u002F happens, never updated except to mark it processed.\n        Schema::create('mpesa_callbacks', function (Blueprint $table) {\n            $table->id();\n            $table->string('kind');                 \u002F\u002F stk, validation, confirmation\n            $table->json('payload');\n            $table->timestamp('processed_at')->nullable();\n            $table->string('note')->nullable();\n            $table->timestamps();\n        });\n\n        \u002F\u002F One row per STK push we started.\n        Schema::create('payment_requests', function (Blueprint $table) {\n            $table->id();\n            $table->string('checkout_request_id')->nullable()->unique();\n            $table->string('merchant_request_id')->nullable();\n            $table->string('phone');\n            $table->unsignedBigInteger('amount_cents');\n            $table->string('account_reference', 12);\n            $table->string('status')->default('pending');\n            $table->string('result_code')->nullable();\n            $table->string('mpesa_receipt')->nullable();\n            $table->unsignedSmallInteger('status_checks')->default(0);\n            $table->timestamps();\n        });\n\n        \u002F\u002F One row per M-Pesa receipt, however we learned about it.\n        Schema::create('mpesa_transactions', function (Blueprint $table) {\n            $table->id();\n            $table->string('receipt')->unique();\n            $table->unsignedBigInteger('amount_cents');\n            $table->timestamp('paid_at')->nullable();\n            $table->string('bill_ref')->nullable();\n            $table->string('payer_name')->nullable();\n            $table->string('msisdn')->nullable();\n            $table->string('source');               \u002F\u002F stk, c2b, statement\n            $table->foreignId('account_id')->nullable();\n            $table->string('match_status')->default('unmatched');\n            $table->timestamps();\n        });\n    }\n};\n```\n\n## Authenticating, and the token that invalidates itself\n\nEvery call needs a bearer token from the [Authorization API](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FAuthorization): a GET to `\u002Foauth\u002Fv1\u002Fgenerate?grant_type=client_credentials` with your consumer key and secret as Basic auth. The token lasts an hour.\n\nOne line in that page's FAQ changes how you build this: you can generate multiple tokens, \"but each request invalidates the previous token.\" Run two queue workers that each fetch their own token and they will keep breaking each other's requests with \"Invalid Access Token\" errors that look random. So the token is minted under a lock and shared through the cache:\n\n`app\u002FPayments\u002FMpesa\u002FDarajaClient.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa;\n\nuse Illuminate\\Http\\Client\\PendingRequest;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\n\nfinal class DarajaClient\n{\n    public function token(): string\n    {\n        \u002F\u002F Minting a token invalidates the previous one, so two workers minting\n        \u002F\u002F at once would each break the other's. One mints; the rest wait.\n        return Cache::get('mpesa.token')\n            ?? Cache::lock('mpesa.token.mint', 15)->block(10, fn () => Cache::get('mpesa.token') ?? $this->mint());\n    }\n\n    public function forgetToken(): void\n    {\n        Cache::forget('mpesa.token');\n    }\n\n    private function mint(): string\n    {\n        $response = Http::withBasicAuth(config('mpesa.consumer_key'), config('mpesa.consumer_secret'))\n            ->timeout(10)\n            ->get(config('mpesa.base_url').'\u002Foauth\u002Fv1\u002Fgenerate', ['grant_type' => 'client_credentials'])\n            ->throw()\n            ->json();\n\n        \u002F\u002F Refresh well before expiry rather than discovering it mid-request.\n        Cache::put('mpesa.token', $response['access_token'], max(60, (int) $response['expires_in'] - 300));\n\n        return $response['access_token'];\n    }\n\n    public function http(): PendingRequest\n    {\n        return Http::baseUrl(config('mpesa.base_url'))\n            ->withToken($this->token())\n            ->acceptJson()\n            ->timeout(30);\n    }\n\n    \u002F**\n     * Ask for an STK push. Returns the acknowledgement, which says the request\n     * was accepted, not that anybody paid.\n     *\u002F\n    public function stkPush(string $phone, int $amountShillings, string $reference, string $description): array\n    {\n        $shortcode = config('mpesa.shortcode');\n        $timestamp = now('Africa\u002FNairobi')->format('YmdHis');\n\n        return $this->http()->post('\u002Fmpesa\u002Fstkpush\u002Fv1\u002Fprocessrequest', [\n            'BusinessShortCode' => $shortcode,\n            \u002F\u002F The same timestamp goes in the password and in the body, or the\n            \u002F\u002F request fails with \"Wrong credentials\".\n            'Password' => base64_encode($shortcode.config('mpesa.passkey').$timestamp),\n            'Timestamp' => $timestamp,\n            'TransactionType' => 'CustomerPayBillOnline',\n            'Amount' => $amountShillings,\n            'PartyA' => $phone,\n            'PartyB' => $shortcode,\n            'PhoneNumber' => $phone,\n            'CallBackURL' => url('\u002Fapi\u002Fhooks\u002Fdaraja\u002F'.config('mpesa.callback_secret').'\u002Fstk'),\n            'AccountReference' => mb_substr($reference, 0, 12),\n            'TransactionDesc' => mb_substr($description, 0, 13),\n        ])->throw()->json();\n    }\n\n    \u002F** The documented status check for one STK push. *\u002F\n    public function stkStatus(string $checkoutRequestId): array\n    {\n        $shortcode = config('mpesa.shortcode');\n        $timestamp = now('Africa\u002FNairobi')->format('YmdHis');\n\n        return $this->http()->post('\u002Fmpesa\u002Fstkpushquery\u002Fv1\u002Fquery', [\n            'BusinessShortCode' => $shortcode,\n            'Password' => base64_encode($shortcode.config('mpesa.passkey').$timestamp),\n            'Timestamp' => $timestamp,\n            'CheckoutRequestID' => $checkoutRequestId,\n        ])->throw()->json();\n    }\n\n    public function registerC2bUrls(string $responseType = 'Completed'): array\n    {\n        $base = url('\u002Fapi\u002Fhooks\u002Fdaraja\u002F'.config('mpesa.callback_secret'));\n\n        return $this->http()->post('\u002Fmpesa\u002Fc2b\u002Fv2\u002Fregisterurl', [\n            'ShortCode' => config('mpesa.shortcode'),\n            'ResponseType' => $responseType,   \u002F\u002F exactly \"Completed\" or \"Cancelled\"\n            'ConfirmationURL' => $base.'\u002Fconfirm',\n            'ValidationURL' => $base.'\u002Fvalidate',\n        ])->throw()->json();\n    }\n}\n```\n\nThe configuration it reads:\n\n`config\u002Fmpesa.php`\n\n```php\n\u003C?php\n\nreturn [\n    'base_url' => env('MPESA_ENV', 'sandbox') === 'production'\n        ? 'https:\u002F\u002Fapi.safaricom.co.ke'\n        : 'https:\u002F\u002Fsandbox.safaricom.co.ke',\n\n    'consumer_key' => env('MPESA_CONSUMER_KEY'),\n    'consumer_secret' => env('MPESA_CONSUMER_SECRET'),\n    'shortcode' => env('MPESA_SHORTCODE'),\n    'passkey' => env('MPESA_PASSKEY'),\n\n    \u002F\u002F A long random string. It goes in the callback path, so only Safaricom,\n    \u002F\u002F who received it at registration, knows the URL.\n    'callback_secret' => env('MPESA_CALLBACK_SECRET'),\n];\n```\n\nTwo details in `stkPush()` come straight from the documented error table. The `Password` is `base64(Shortcode + Passkey + Timestamp)`, and the timestamp inside it must be the same one sent in the body, or the request fails with \"Wrong credentials\". And for a Till rather than a Paybill, `TransactionType` is `CustomerBuyGoodsOnline`, `BusinessShortCode` is the shortcode used at go-live and `PartyB` is the Till number. Getting that pairing wrong returns result code 2028, \"not permitted according to product assignment\".\n\nThe timestamp is generated in East Africa Time here. The documentation gives the format, `YYYYMMDDHHmmss`, and not the time zone.\n\n\u003C!-- TODO(david): confirm with Safaricom, or with a sandbox request at a known UTC time, whether Timestamp, TransTime and TransactionDate are East Africa Time. Every public example we found assumes so; the documentation does not say. -->\n\n## Idempotency: the receipt number is the key\n\nDuplicates have more sources than Safaricom. A reverse proxy retries a request your app was slow to answer. A queue worker crashes after committing but before acknowledging the job, and the job runs again. A payment reaches you through an STK callback and again through the statement. Each of these delivers the same fact twice, and none of them can be prevented. They can only be absorbed.\n\nThree mechanisms absorb them here:\n\n- **A unique index on the receipt**, with `createOrFirst()`, which inserts or, if another process got there first, returns the existing row. Two jobs racing on the same receipt produce one transaction.\n- **A row lock on the payment request** while its status changes, so a callback and a status check cannot both settle it.\n- **A one-way state machine.** Only a request that is still open (`pending`, `unknown`, or paid but without a receipt yet) can change. A second callback for a settled request is recorded and ignored.\n\n`app\u002FPayments\u002FMpesa\u002FStkOutcome.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa;\n\nfinal class StkOutcome\n{\n    \u002F** Documented result codes worth telling apart. Any other non-zero code is \"failed\". *\u002F\n    private const NAMED = [\n        0 => 'paid',\n        1 => 'insufficient_funds',\n        1019 => 'expired',\n        1032 => 'cancelled',\n        1037 => 'unreachable',\n        2001 => 'wrong_pin',\n    ];\n\n    public static function fromCode(int $code): string\n    {\n        return self::NAMED[$code] ?? 'failed';\n    }\n\n    \u002F** States a later answer is still allowed to change. *\u002F\n    public const OPEN = ['pending', 'unknown', 'paid_unconfirmed'];\n}\n```\n\n`app\u002FPayments\u002FMpesa\u002FJobs\u002FProcessStkResult.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa\\Jobs;\n\nuse App\\Payments\\Mpesa\\Money;\nuse App\\Payments\\Mpesa\\MpesaCallback;\nuse App\\Payments\\Mpesa\\MpesaTransaction;\nuse App\\Payments\\Mpesa\\PaymentRequest;\nuse App\\Payments\\Mpesa\\StkOutcome;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\DB;\n\nfinal class ProcessStkResult implements ShouldQueue\n{\n    use Queueable;\n\n    public function __construct(public int $callbackId) {}\n\n    public function handle(): void\n    {\n        $callback = MpesaCallback::findOrFail($this->callbackId);\n        $result = data_get($callback->payload, 'Body.stkCallback');\n\n        if (! is_array($result) || ! isset($result['CheckoutRequestID'], $result['ResultCode'])) {\n            $callback->update(['processed_at' => now(), 'note' => 'unrecognised shape']);\n\n            return;\n        }\n\n        DB::transaction(function () use ($callback, $result) {\n            $request = PaymentRequest::where('checkout_request_id', $result['CheckoutRequestID'])\n                ->lockForUpdate()\n                ->first();\n\n            if (! $request) {\n                \u002F\u002F Not ours, or ours from before the request row was saved.\n                \u002F\u002F Keep it; the sweeper and reconciliation will look again.\n                $callback->update(['note' => 'unknown checkout request']);\n\n                return;\n            }\n\n            if (! in_array($request->status, StkOutcome::OPEN, true)) {\n                \u002F\u002F A repeat, or a late callback for a request already settled.\n                \u002F\u002F The first final answer stands.\n                $callback->update(['processed_at' => now(), 'note' => 'already settled']);\n\n                return;\n            }\n\n            $code = (int) $result['ResultCode'];\n\n            if ($code !== 0 && $request->status === 'paid_unconfirmed') {\n                \u002F\u002F The status check said paid; this says not. A person decides.\n                $callback->update(['note' => 'conflicts with status check']);\n\n                return;\n            }\n\n            if ($code !== 0) {\n                $request->update(['status' => StkOutcome::fromCode($code), 'result_code' => (string) $code]);\n                $callback->update(['processed_at' => now()]);\n\n                return;\n            }\n\n            \u002F\u002F CallbackMetadata exists only on success, and an Item may carry no Value.\n            $meta = collect(data_get($result, 'CallbackMetadata.Item', []))->pluck('Value', 'Name');\n            $receipt = (string) $meta->get('MpesaReceiptNumber');\n            $paid = Money::toCents($meta->get('Amount', 0));\n\n            MpesaTransaction::createOrFirst(['receipt' => $receipt], [\n                'amount_cents' => $paid,\n                'paid_at' => $meta->get('TransactionDate')\n                    ? Carbon::createFromFormat('YmdHis', (string) $meta->get('TransactionDate'), 'Africa\u002FNairobi')->utc()\n                    : null,\n                'msisdn' => (string) $meta->get('PhoneNumber'),\n                'bill_ref' => $request->account_reference,\n                'source' => 'stk',\n            ]);\n\n            $request->update([\n                'status' => $paid === (int) $request->amount_cents ? 'paid' : 'paid_amount_mismatch',\n                'result_code' => '0',\n                'mpesa_receipt' => $receipt,\n            ]);\n            $callback->update(['processed_at' => now()]);\n        });\n    }\n}\n```\n\n### Parse the payload loosely, because the documentation does\n\nSafaricom's own samples disagree about shapes. The STK callback sample sends `Amount` as the number `1.00` and `PhoneNumber` as a number. One C2B sample sends `TransAmount` as `\"10\"` and the masked `MSISDN` as `\"25470****149\"`; another sends `\"5.00\"` and `\"2547 ***** 126\"`. Every item in `CallbackMetadata` is marked optional, and a failed STK result has no `CallbackMetadata` at all, so code that reads the receipt before checking `ResultCode` throws on every cancelled payment.\n\nSo amounts are parsed from their text, into integer cents, and never through a float:\n\n`app\u002FPayments\u002FMpesa\u002FMoney.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa;\n\nfinal class Money\n{\n    \u002F**\n     * Daraja's amounts arrive as \"10\", \"5.00\", 1.00 or 10500 depending on the\n     * API and the sample you read. Parse the string, never a float.\n     *\u002F\n    public static function toCents(string|int|float $amount): int\n    {\n        $text = is_float($amount) ? number_format($amount, 2, '.', '') : trim((string) $amount);\n\n        if (! preg_match('\u002F^(\\d+)(?:\\.(\\d{1,2}))?$\u002F', $text, $m)) {\n            throw new \\InvalidArgumentException(\"Not an amount: {$text}\");\n        }\n\n        return (int) $m[1] * 100 + (int) str_pad($m[2] ?? '0', 2, '0');\n    }\n}\n```\n\n### Store times as UTC\n\nLaravel's `datetime` cast writes a Carbon's wall-clock reading, not the instant it represents. A `TransTime` parsed in Nairobi time and saved as is lands three hours off in a UTC database, and every report that groups payments by day quietly moves the late-evening ones into tomorrow. Convert with `->utc()` before saving, as the jobs here do, and convert query windows the same way.\n\n## Timeouts: the payment that is taken but never confirmed\n\nThe hardest STK case is silence. You sent the push, the acknowledgement said the request was accepted, and no callback has come. The customer may have paid, cancelled, ignored the prompt, or never received it.\n\nThe documented result codes tell you what the possible endings are, when you get one:\n\n| Result code | Meaning, per Safaricom |\n|---|---|\n| 0 | Processed successfully |\n| 1 | Insufficient balance |\n| 17 | Same amount to the same customer within two minutes |\n| 1019 | Transaction expired before it was processed |\n| 1032 | Cancelled by the user |\n| 1037 | The customer's phone could not be reached |\n| 2001 | Wrong PIN |\n\nWhen the callback does not come, ask. The [STK status query](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FMpesaExpressQuery) takes the `CheckoutRequestID` and returns a `ResultCode` and `ResultDesc`. Notice what its documented response does not contain: the receipt number. A query can tell you the customer paid without telling you which M-Pesa transaction it was. That state needs a name of its own, `paid_unconfirmed` here, and the receipt is filled in later by the callback if it turns up, or from the statement if it does not.\n\n`app\u002FPayments\u002FMpesa\u002FCheckPendingStkPushes.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa;\n\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Http\\Client\\RequestException;\nuse Illuminate\\Support\\Facades\\DB;\n\n\u002F**\n * Asks about every STK push that has waited too long for its callback.\n * Scheduled every minute: Schedule::command('mpesa:check-pending')->everyMinute()\n *\u002F\nfinal class CheckPendingStkPushes extends Command\n{\n    protected $signature = 'mpesa:check-pending';\n\n    protected $description = 'Check the status of STK pushes whose callback has not arrived';\n\n    public function handle(DarajaClient $daraja): void\n    {\n        PaymentRequest::where('status', 'pending')\n            ->whereNotNull('checkout_request_id')\n            ->where('updated_at', '\u003C', now()->subMinutes(2))\n            ->each(function (PaymentRequest $request) use ($daraja) {\n                try {\n                    $answer = $daraja->stkStatus($request->checkout_request_id);\n                } catch (RequestException) {\n                    \u002F\u002F An error from the status check says nothing about the\n                    \u002F\u002F payment. Ask again next run; after ten tries, stop\n                    \u002F\u002F guessing and leave it for reconciliation.\n                    $request->increment('status_checks');\n                    if ($request->status_checks >= 10) {\n                        $request->update(['status' => 'unknown']);\n                    }\n\n                    return;\n                }\n\n                if (! isset($answer['ResultCode'])) {\n                    $request->increment('status_checks');\n\n                    return;\n                }\n\n                DB::transaction(function () use ($request, $answer) {\n                    $fresh = PaymentRequest::lockForUpdate()->find($request->id);\n                    if ($fresh->status !== 'pending') {\n                        return;   \u002F\u002F the callback won the race\n                    }\n\n                    $code = (int) $answer['ResultCode'];\n\n                    \u002F\u002F Paid, but the documented response carries no receipt\n                    \u002F\u002F number. The callback or the statement will supply it.\n                    $fresh->update([\n                        'status' => $code === 0 ? 'paid_unconfirmed' : StkOutcome::fromCode($code),\n                        'result_code' => (string) $code,\n                    ]);\n                });\n            });\n    }\n}\n```\n\nRegister it in `routes\u002Fconsole.php` with `Schedule::command('mpesa:check-pending')->everyMinute();` and run the scheduler and a queue worker in production. Without a worker, every callback is stored and none is processed, which looks exactly like Safaricom not sending them.\n\nThree rules sit around this code:\n\n- **An error from the status check is not an answer.** A 500 from the query endpoint says nothing about the payment. Keep the request pending and ask again.\n- **Never tell the customer a timed-out payment failed.** Tell them you are checking. \"Failed\" followed by an SMS from M-Pesa saying they paid is how support tickets start.\n- **Never send a second push automatically while one is pending.** It will be refused while the first prompt holds the subscriber, refused again by the two-minute rule, and if it does get through you may take the money twice. Let the customer retry, and only after the first request has a final status.\n\nFor a receipt you already hold, the [Transaction Status API](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FTransactionStatus) answers asynchronously to a result URL and needs an initiator name and an encrypted `SecurityCredential`, a different credential set from the STK passkey. Plan for it at go-live rather than discovering it during an incident.\n\n## Validation and confirmation URLs\n\nC2B URLs are registered once, with the request in `registerC2bUrls()` above. Safaricom's current C2B page documents the `\u002Fmpesa\u002Fc2b\u002Fv2\u002Fregisterurl` endpoint, and the older Register URL page still shows `v1`.\n\n\u003C!-- TODO(david): confirm which register URL version the production app was provisioned for, and whether v1 and v2 differ in anything but the path. -->\n\n`ResponseType` decides what happens when M-Pesa cannot reach your validation URL in time: `Completed` takes the payment anyway, `Cancelled` refuses it. It must be spelled exactly that way, in sentence case. Both choices have a cost. `Cancelled` means your outage stops customers paying. `Completed` means some payments arrive that your validation never saw, so the confirmation path has to cope with anything. We choose `Completed`, because it does have to cope with anything regardless.\n\nThe failure modes worth knowing before you go live:\n\n- **Validation is off unless you ask for it.** External validation must be enabled by emailing Safaricom (apisupport@safaricom.co.ke or M-pesabusiness@safaricom.co.ke). Until then your validation URL is never called, and validation code that has never received a request has never been tested either.\n- **Registration in production is one-time.** In the sandbox you can register as often as you like. In production, changing a URL means deleting it under URL management in self-service, or asking API support, and registering again.\n- **The validation answer has a deadline.** The Register URL page puts it at usually under eight seconds from the moment the request leaves M-Pesa. A validation handler that queries a slow database, calls another API, or waits on a lock will miss it, and then `ResponseType` decides for you.\n\nA validation response accepts with `ResultCode` `0`, or rejects with one of `C2B00011` to `C2B00016`, which determine the message the customer sees. Use validation only to reject what can never be right for your business. The controller above has a single defensive rule, on the amount, since the documentation says only whole shillings are supported. An account number you do not recognise does not belong in that category. It may be a typo for a real account, and bouncing it costs the customer a failed payment and a confused retry. Accept it and resolve it after confirmation, which is what `validate()` in the controller does.\n\n## The shape of real payment data\n\nWhat a C2B confirmation actually tells you, per the documented fields:\n\n- **`BillRefNumber` is whatever the customer typed**, up to 20 characters, on Paybill payments only. Till payments have none.\n- **`MSISDN` is masked.** The documentation describes it as \"a masked mobile number\", and both samples show it with digits replaced by asterisks. You cannot match a payment to a customer by phone number from the callback.\n- **`FirstName`, `MiddleName` and `LastName` are the M-Pesa registration names**, and each can be empty.\n- **`TransAmount` is whole shillings**, delivered as text.\n\nDesign for the cases that follow from those fields rather than hoping they are rare. A reference typed with spaces, dashes or lower case. A reference that is wrong by one character. No reference at all. A payment from someone other than the account holder, with a different name and a masked number. An amount that is part of what is owed, or more than it. Two payments that together cover one invoice.\n\nThe first step is cheap and safe: normalise the reference before comparing it, by removing everything but letters and digits and upper-casing what remains. Then allocate only on an exact match, and send everything else to a suspense queue that a person resolves:\n\n`app\u002FPayments\u002FMpesa\u002FJobs\u002FProcessC2bConfirmation.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa\\Jobs;\n\nuse App\\Payments\\Mpesa\\Money;\nuse App\\Payments\\Mpesa\\MpesaCallback;\nuse App\\Payments\\Mpesa\\MpesaTransaction;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Support\\Carbon;\n\nfinal class ProcessC2bConfirmation implements ShouldQueue\n{\n    use Queueable;\n\n    public function __construct(public int $callbackId) {}\n\n    public function handle(): void\n    {\n        $callback = MpesaCallback::findOrFail($this->callbackId);\n        $p = $callback->payload;\n\n        $transaction = MpesaTransaction::createOrFirst(['receipt' => (string) $p['TransID']], [\n            'amount_cents' => Money::toCents($p['TransAmount']),\n            \u002F\u002F Parsed as Nairobi time, stored as UTC: the datetime cast writes\n            \u002F\u002F the wall-clock reading, so a Nairobi Carbon lands three hours off.\n            'paid_at' => Carbon::createFromFormat('YmdHis', (string) $p['TransTime'], 'Africa\u002FNairobi')->utc(),\n            'bill_ref' => self::normaliseReference($p['BillRefNumber'] ?? ''),\n            'payer_name' => trim(implode(' ', array_filter([\n                $p['FirstName'] ?? null, $p['MiddleName'] ?? null, $p['LastName'] ?? null,\n            ]))) ?: null,\n            'msisdn' => $p['MSISDN'] ?? null,   \u002F\u002F masked: for display, never for matching\n            'source' => 'c2b',\n        ]);\n\n        if ($transaction->wasRecentlyCreated) {\n            AllocateMpesaTransaction::dispatch($transaction->id);\n        }\n\n        $callback->update(['processed_at' => now()]);\n    }\n\n    \u002F** What the customer typed, made comparable. Never treated as trusted. *\u002F\n    public static function normaliseReference(?string $reference): ?string\n    {\n        $clean = strtoupper(preg_replace('\u002F[^A-Za-z0-9]\u002F', '', (string) $reference));\n\n        return $clean === '' ? null : $clean;\n    }\n}\n```\n\n`app\u002FPayments\u002FMpesa\u002FJobs\u002FAllocateMpesaTransaction.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa\\Jobs;\n\nuse App\\Payments\\Mpesa\\MpesaTransaction;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Support\\Facades\\DB;\n\nfinal class AllocateMpesaTransaction implements ShouldQueue\n{\n    use Queueable;\n\n    public function __construct(public int $transactionId) {}\n\n    public function handle(): void\n    {\n        DB::transaction(function () {\n            $transaction = MpesaTransaction::lockForUpdate()->findOrFail($this->transactionId);\n\n            if ($transaction->match_status !== 'unmatched') {\n                return;   \u002F\u002F already handled\n            }\n\n            $accountId = $transaction->bill_ref\n                ? DB::table('accounts')->where('reference', $transaction->bill_ref)->value('id')\n                : null;\n\n            \u002F\u002F An exact match is allocated. Anything else waits for a person.\n            $transaction->update($accountId\n                ? ['account_id' => $accountId, 'match_status' => 'matched']\n                : ['match_status' => 'suspense']);\n        });\n    }\n}\n```\n\nIt is tempting to make that matcher clever: fuzzy references, name similarity, amount heuristics. Every guess it makes is a payment credited to somebody, and a wrong credit is worse than a slow one, because it hides two errors behind one confident-looking record. Exact matching handles the clean cases. The suspense queue is the product for the rest. We wrote about that design separately in [reconciliation failure modes](\u002Fblog\u002Freconciliation-failure-modes).\n\n## Reconciling against the Paybill statement\n\nCallbacks tell you what Safaricom managed to deliver. The statement in the M-Pesa organisation portal tells you what happened. Reconciliation is the daily comparison of the two, and it has exactly three outcomes:\n\n- **On the statement, not in your records.** A lost callback. Record it from the statement and allocate it like any other payment.\n- **In both, with different amounts.** Should never happen. Stop and look.\n- **In your records, not on the statement.** Also should never happen, and the most worrying of the three, because it may be a forged callback. Never fix it automatically.\n\n`app\u002FPayments\u002FMpesa\u002FStatementReconciler.php`\n\n```php\n\u003C?php\n\nnamespace App\\Payments\\Mpesa;\n\nuse App\\Payments\\Mpesa\\Jobs\\AllocateMpesaTransaction;\nuse DateTimeInterface;\nuse Illuminate\\Support\\Carbon;\n\n\u002F**\n * Compares the M-Pesa statement with what the callbacks told us.\n *\n * The statement is the source of truth: it lists every payment to the\n * shortcode whether or not a callback reached us. Rows arrive here already\n * mapped from the org portal export into one shape.\n *\u002F\nfinal class StatementReconciler\n{\n    \u002F**\n     * @param  iterable\u003Carray{receipt: string, amount_cents: int, completed_at: DateTimeInterface}>  $statement\n     * @return array{recovered: list\u003Cstring>, mismatched: list\u003Cstring>, unexplained: list\u003Cstring>}\n     *\u002F\n    public function reconcile(iterable $statement, DateTimeInterface $from, DateTimeInterface $to): array\n    {\n        $report = ['recovered' => [], 'mismatched' => [], 'unexplained' => []];\n        $seen = [];\n\n        foreach ($statement as $row) {\n            $seen[$row['receipt']] = true;\n            $ours = MpesaTransaction::firstWhere('receipt', $row['receipt']);\n\n            if (! $ours) {\n                \u002F\u002F Money arrived and no callback told us. Record it from the\n                \u002F\u002F statement and send it through allocation like any other.\n                $created = MpesaTransaction::createOrFirst(['receipt' => $row['receipt']], [\n                    'amount_cents' => $row['amount_cents'],\n                    'paid_at' => Carbon::instance($row['completed_at'])->utc(),\n                    'source' => 'statement',\n                ]);\n                AllocateMpesaTransaction::dispatch($created->id);\n                $report['recovered'][] = $row['receipt'];\n            } elseif ((int) $ours->amount_cents !== $row['amount_cents']) {\n                $report['mismatched'][] = $row['receipt'];\n            }\n        }\n\n        \u002F\u002F Ours, in the window, and not on the statement: never auto-fixed.\n        \u002F\u002F Bindings format the wall clock too, so the window goes in as UTC.\n        $window = [Carbon::instance($from)->utc(), Carbon::instance($to)->utc()];\n        MpesaTransaction::whereBetween('paid_at', $window)\n            ->pluck('receipt')\n            ->reject(fn (string $receipt) => isset($seen[$receipt]))\n            ->each(function (string $receipt) use (&$report) {\n                $report['unexplained'][] = $receipt;\n            });\n\n        return $report;\n    }\n}\n```\n\nThe reconciler takes rows already mapped into one shape: receipt, amount in cents, completion time. Mapping the portal's export into that shape is the only format-specific code, so keep it in one small class you can change when the export changes.\n\n\u003C!-- TODO(david): check the column names and date format of the M-Pesa org portal statement export against a real export before publishing a mapping example. Also confirm whether the Pull Transaction API is available on the production app; if it is, it replaces the manual export. -->\n\nGetting the statement at all needs portal access. Per Safaricom's go-live notes, the portal requires a Business Administrator created by Safaricom for your shortcode, who then creates the users who can view statements, such as a Business Manager. Request that access at the same time as you apply to go live, not after the first missing payment.\n\nAny `paid_unconfirmed` STK request still waiting for a receipt is matched by a person against the recovered rows, by amount and time. It is the one place in this design where a human compares two records by eye, and it should be rare enough to stay that way.\n\n## Sandbox to production: what changes on go-live\n\nThe sandbox is for learning request shapes. Production differs in ways that are all documented, and all easy to miss:\n\n- **New credentials.** Going live moves your app to production with new consumer keys, and the production passkey arrives by email after go-live. Nothing from the sandbox carries over, including the test shortcode 174379.\n- **Go-live needs a person with the right role.** The form asks for the username of an operator with the Business Administrator or Business Manager role on the M-Pesa organisation portal, case-sensitive, and sends a one-time code to that user's phone, which must be a Safaricom line.\n- **No simulation.** The C2B simulate endpoint does not exist in production. Real payments only.\n- **URL registration stops being repeatable**, as above.\n- **HTTPS only**, as above.\n- **Rate limits are real.** The documented errors include \"Spike Arrest Violation\" and \"Quota Violation\". The limits themselves are not in the pages we read, so build your bulk operations to back off rather than to a number.\n\n## Testing when the sandbox will not misbehave for you\n\nThe sandbox will happily return a successful callback. There is no documented way to make it send one twice, send one late, lose one, or send one for a payment you did not request, which are exactly the cases that matter. So those cases are tested in your own suite, with Safaricom's documented samples as fixtures and the HTTP client faked.\n\nEach behaviour in this guide has a test. A few of them, where `stkSuccess()` returns Safaricom's documented success sample and `pendingRequest()` creates the request it answers:\n\n```php\nit('records a successful STK result once, however many times it arrives', function () {\n    pendingRequest();\n\n    $this->postJson('\u002Fapi\u002Fhooks\u002Fdaraja\u002Fa-long-random-string\u002Fstk', stkSuccess())->assertOk();\n    $this->postJson('\u002Fapi\u002Fhooks\u002Fdaraja\u002Fa-long-random-string\u002Fstk', stkSuccess())->assertOk();\n\n    expect(MpesaTransaction::count())->toBe(1)\n        ->and(PaymentRequest::first()->status)->toBe('paid')\n        ->and(MpesaCallback::count())->toBe(2);\n});\n\nit('settles a push whose callback never came, then takes the receipt from a late callback', function () {\n    Http::fake([\n        '*\u002Foauth\u002Fv1\u002Fgenerate*' => Http::response(['access_token' => 'tok', 'expires_in' => '3599']),\n        '*\u002Fstkpushquery\u002Fv1\u002Fquery' => Http::response([\n            'ResponseCode' => '0', 'CheckoutRequestID' => 'ws_CO_191220191020363925',\n            'ResultCode' => '0', 'ResultDesc' => 'The service request is processed successfully.',\n        ]),\n    ]);\n    $request = pendingRequest();\n    $request->forceFill(['updated_at' => now()->subMinutes(5)])->saveQuietly();\n\n    app(CheckPendingStkPushes::class)->handle(app(DarajaClient::class));\n    expect($request->fresh()->status)->toBe('paid_unconfirmed');\n\n    $this->postJson('\u002Fapi\u002Fhooks\u002Fdaraja\u002Fa-long-random-string\u002Fstk', stkSuccess())->assertOk();\n    expect($request->fresh()->status)->toBe('paid')\n        ->and($request->fresh()->mpesa_receipt)->toBe('NLJ7RT61SV');\n});\n\nit('treats an error from the status check as no answer at all', function () {\n    Http::fake([\n        '*\u002Foauth\u002Fv1\u002Fgenerate*' => Http::response(['access_token' => 'tok', 'expires_in' => '3599']),\n        '*\u002Fstkpushquery\u002Fv1\u002Fquery' => Http::response(['errorCode' => '500.001.1001'], 500),\n    ]);\n    $request = pendingRequest();\n    $request->forceFill(['updated_at' => now()->subMinutes(5)])->saveQuietly();\n\n    app(CheckPendingStkPushes::class)->handle(app(DarajaClient::class));\n\n    expect($request->fresh()->status)->toBe('pending')\n        ->and($request->fresh()->status_checks)->toBe(1);\n});\n```\n\nThe rest cover a failed result with no metadata, a paid amount that differs from the request, a callback for a checkout ID nobody issued, a wrong secret, a masked C2B confirmation delivered twice, a reference typed in lower case with spaces, the validation answers, all three reconciliation outcomes, a reconciliation window that would have been three hours off, and every amount format the documentation shows.\n\nThen, once live, a short smoke test with real money. The smallest C2B amount is one shilling, per the documented result codes. Pay once by STK push and once from the menu to each shortcode you support, cancel one prompt on purpose to see result code 1032 arrive, and check the next day's statement against your records. That last step is also how you settle the time zone question above with evidence rather than assumption.\n\n> [!NOTE]\n> Keep the raw callbacks. Months from now, when a payment is disputed, the exact payload Safaricom sent is the evidence, and a growing library of real payloads is the best fixture set you will ever have.\n\n## Before you go live\n\n- Production credentials and passkey in the environment, sandbox URLs gone from the code.\n- Callback URLs on HTTPS, free of the banned words, with a secret in the path.\n- Callback source addresses allowlisted, from Safaricom's current list.\n- A queue worker and the scheduler running, and an alert if the callback inbox stops emptying.\n- A unique index on the receipt number, and a test that posts every callback twice.\n- The status-check sweeper scheduled, and a customer message for \"we are checking\".\n- Validation enabled with Safaricom if you rely on it, and answering in well under eight seconds.\n- Portal access for statements, and a daily reconciliation with someone who reads its report.\n\n## Sources\n\nSafaricom Daraja documentation, read on 8 October 2026: [Authorization](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FAuthorization), [M-Pesa Express](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FMpesaExpressSimulate), [M-Pesa Express Query](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FMpesaExpressQuery), [Customer to Business](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FCustomerToBusiness), [Customer to Business Register URL](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FCustomerToBusinessRegisterURL) and [Transaction Status](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FTransactionStatus).\n\nIf you would rather have this built and run for you, it is the kind of work our [SaaS product development](\u002Fservices#saas-product-development) covers.\n",[66,67,68,69],"Laravel","m-pesa","payments","reconciliation","2026-10-08T10:00:00+03:00",6,"M-Pesa Daraja integration guide for production","STK push or C2B, callbacks you cannot trust, duplicates, timeouts, go-live and statement reconciliation. Laravel code tested against Safaricom's own samples.",{"slug":75,"title":76,"excerpt":77,"body":78,"cover":23,"tags":79,"publishedAt":80,"updatedAt":29,"readingTime":81,"seoTitle":82,"seoDescription":83},"reconciliation-failure-modes","Reconciliation failure modes: the exact matches are the easy part","Exact matching places the clean payments. The rest is the product: a suspense queue built on purpose, allocations that are never edited, and an audit trail that can reproduce last month.","# Reconciliation failure modes: the exact matches are the easy part\n\nEvery system that matches money to what is owed starts with the same rule. A payment arrives carrying a reference, the reference names exactly one account, the amount fits, and the money is applied. That rule is deterministic, it is easy to test, and on a good day it handles most of what comes in.\n\nIt is also the part that needs the least engineering. The reason reconciliation software exists is everything that rule cannot place, and the way a system treats those payments decides whether anybody can trust its numbers. This post is about that remainder: what it looks like, why guessing at it is the expensive mistake, and the three design choices that make it manageable. The Daraja-specific mechanics of getting payments in are in our [M-Pesa Daraja integration guide](\u002Fblog\u002Fmpesa-daraja-integration-guide); this is what happens after they arrive.\n\n## What exact matching cannot place\n\nThese are not edge cases to be handled one day. Each is a normal event in any system that takes money from people, and each needs a defined behaviour before the first payment arrives.\n\n**No reference.** The payer left the account field empty, or paid a Till that has none. There is nothing to match on.\n\n**A wrong reference that resembles a right one.** One character off, digits transposed, an old account number. This is the dangerous one, because a matcher that is a little too helpful will find a real account and credit it.\n\n**A payer who is not the account holder.** A relative, an employer, a company paying for several people. The name and the number on the payment belong to somebody else, and with M-Pesa C2B the payer's number arrives masked anyway.\n\n**Part of what is owed.** The payment is real and the reference is right, but it covers half the bill.\n\n**More than what is owed.** Two months at once, a rounded-up amount, a payment for something not yet invoiced.\n\n**One payment for several obligations, or several payments for one.** Rent plus water in a single transfer. A school fee paid in three instalments from two phones.\n\n**The same payment, reported twice.** A repeated notification, a retried job, or the same money seen once through a callback and again on the statement.\n\n**A payment nobody told you about.** It is on the provider's statement and nowhere in your system, because a notification was lost.\n\n**A correction.** Somebody allocated a payment to the wrong account last week, and it has to move without making last week's reports lie.\n\n## The suspense queue is a design decision, not a failure\n\nWhen exact matching fails, a system has two choices. It can guess, or it can hold the payment somewhere named, visible and owned until a person decides.\n\nGuessing looks efficient, and it is the expensive option. A fuzzy match on a reference, a nearest-amount match, a match on the payer's name: each guess, when it is wrong, credits one customer with another customer's money. Worse, from inside the system a wrong allocation looks exactly like a right one. Nobody sees it until the person whose payment went astray asks why they are in arrears, by which time two accounts are wrong and one of them looks fine.\n\nSo the second choice is the correct one, and it deserves to be built properly rather than as an error state. A suspense queue that works has:\n\n- **A name and an owner.** Somebody's job includes emptying it, and everybody knows whose.\n- **Age on every item.** A payment in suspense for an hour is routine. One in suspense for a month is a customer who believes they paid and is about to be chased for arrears.\n- **Actions that are themselves recorded.** Allocate to an account, split across several, hold as credit, refund. Each resolution writes who did it, when, and why, in the same ledger as everything else.\n- **A size you watch.** The count and the oldest age are the health check for the whole payment path. A queue that grows week on week means something upstream changed: a new payment channel, a reference format customers misunderstand, a matching rule that broke.\n\nThe queue's job is to make the cost of an unmatched payment visible and small, rather than invisible and large.\n\n## Append-only allocation, so every number can be explained\n\nThe second decision is about how allocations are stored. The tempting design keeps a balance column on each invoice and updates it as payments arrive. It is simple, and it destroys the history the moment anything is corrected, because the corrected figure overwrites the one that was there.\n\nThe design that holds up stores each allocation as a row that is never updated or deleted. A payment is placed on a charge by inserting a row. A mistake is undone by inserting a row with the opposite amount that points at the one it reverses. The balance is never stored; it is derived, every time it is read, from what was charged minus what was allocated.\n\n```sql\nCREATE TABLE charges (\n    id            INTEGER PRIMARY KEY,\n    account_id    INTEGER NOT NULL,\n    period_start  DATE    NOT NULL,\n    amount_cents  BIGINT  NOT NULL\n);\n\nCREATE TABLE payments (\n    id            INTEGER PRIMARY KEY,\n    receipt       VARCHAR(32) NOT NULL UNIQUE,\n    amount_cents  BIGINT  NOT NULL,\n    received_at   TIMESTAMP NOT NULL\n);\n\n-- Append-only. Rows are inserted and never updated or deleted.\nCREATE TABLE allocations (\n    id            INTEGER PRIMARY KEY,\n    payment_id    INTEGER NOT NULL REFERENCES payments (id),\n    charge_id     INTEGER REFERENCES charges (id),   -- NULL: held as credit\n    amount_cents  BIGINT  NOT NULL,                  -- negative to reverse\n    reverses_id   INTEGER REFERENCES allocations (id),\n    reason        VARCHAR(255) NOT NULL,\n    created_by    INTEGER NOT NULL,\n    created_at    TIMESTAMP NOT NULL\n);\n```\n\nA worked example: two monthly charges of KES 25,000, one payment of KES 30,000 allocated oldest first, and a later correction when a person finds the September part was placed on the wrong charge.\n\n```sql\nINSERT INTO charges VALUES (1, 7, '2026-08-01', 2500000), (2, 7, '2026-09-01', 2500000);\nINSERT INTO payments VALUES (1, 'TJ81AB2C3D', 3000000, '2026-09-04 10:12:00');\nINSERT INTO allocations VALUES\n  (1, 1, 1, 2500000, NULL, 'arrears first', 1, '2026-09-04 10:12:05'),\n  (2, 1, 2,  500000, NULL, 'arrears first', 1, '2026-09-04 10:12:05'),\n  -- a person finds the September part was placed on the wrong charge:\n  (3, 1, 2, -500000, 2,    'wrong lease, see ticket 41', 2, '2026-09-10 09:00:00');\n```\n\nThree questions, each answered by a query rather than by a stored figure:\n\n```sql\n-- What is owed on each charge, derived every time it is read.\nSELECT c.id, c.amount_cents - COALESCE(SUM(a.amount_cents), 0) AS outstanding_cents\nFROM charges c\nLEFT JOIN allocations a ON a.charge_id = c.id\nGROUP BY c.id, c.amount_cents;\n\n-- The same question as it would have been answered on 5 September.\nSELECT c.id, c.amount_cents - COALESCE(SUM(a.amount_cents), 0) AS outstanding_cents\nFROM charges c\nLEFT JOIN allocations a ON a.charge_id = c.id AND a.created_at \u003C '2026-09-06'\nGROUP BY c.id, c.amount_cents;\n\n-- Money received and not yet placed on any charge.\nSELECT p.receipt, p.amount_cents - COALESCE(SUM(CASE WHEN a.charge_id IS NOT NULL THEN a.amount_cents END), 0) AS unplaced_cents\nFROM payments p\nLEFT JOIN allocations a ON a.payment_id = p.id\nGROUP BY p.id, p.receipt, p.amount_cents;\n```\n\nThe first says charge 2 is fully outstanding again. The second says that on 5 September it showed KES 20,000 outstanding, which is what the statement sent that day said, so the old statement is still explainable. The third shows KES 5,000 received and not yet placed, which is the suspense queue, computed rather than maintained.\n\nThat second query is the reason for the whole design. When a customer disputes a statement from last month, the system can reproduce exactly what that statement said and show every row that has changed the answer since, with who made each change and why.\n\n### The rules we hold rentiQ to\n\n[rentiQ](\u002Fwork), the property management platform we build and run, allocates this way, and a few of its rules generalise to any system that takes money against obligations:\n\n- **Arrears first, oldest period first.** A payment without instructions settles the oldest unpaid charge before the newest. Allocation runs inside a transaction with the affected rows locked, so two payments arriving together cannot both settle the same charge.\n- **A payment may target a charge,** and the remainder still flows oldest first. A customer paying a specific invoice gets that invoice settled.\n- **Overpayment becomes credit, not a discount.** It sits in a ledger of its own and is applied when the next charge is issued, with an entry recording the application. Folding it into the next invoice as a reduced amount would make it impossible to tell later whether the customer overpaid or was undercharged.\n- **Corrections are offsetting records.** A reversed payment keeps its row and gains a negative twin, its allocations are reversed the same way, and any credit it created is unwound.\n- **Receipt numbers have no gaps.** They are drawn from a counter locked inside the same transaction as the payment, so a rolled-back payment releases its number rather than leaving a hole an auditor has to ask about.\n- **Never sum invoice balances.** An invoice that carries arrears brought forward restates debt that is already on an earlier invoice. Add up the balances and the debt is counted twice. True debt is what was charged minus what was paid, which is the query above.\n\nThat last rule is the one that most often goes wrong quietly, because each invoice is individually correct and only the total is wrong.\n\nrentiQ records payments that staff enter today, so the references it sees were typed by people its users can ask. The suspense queue described above matters most once payments arrive on their own, through a Paybill integration. rentiQ's payment recording sits behind one interface for that reason, so an automatic recorder can replace the manual one without its callers changing.\n\n## What a correct audit trail answers\n\nAn audit trail is not a log file. It is the ability to answer specific questions from records, without anyone remembering anything. A payment system's trail should answer, on demand:\n\n- Which charges did this payment settle, and who or what decided that, and when?\n- What did this account's balance look like on any given date, and what has changed since?\n- Which money has been received and not placed, and for how long?\n- Who moved this payment from one account to another, and what reason did they record?\n- Does every line on the provider's statement correspond to a payment here, and every payment here to a line on the statement?\n\nThe last question is the one the rest depend on. It is answered by a daily comparison with the provider's statement, which has three possible findings: money on the statement you never recorded, amounts that disagree, and payments you recorded that the statement never shows. The first is a lost notification and is recovered. The second and third should never happen, and when they do, a person looks before anything changes.\n\nIf a system can answer all five without a developer running ad hoc queries, its numbers can be trusted by the people who have to sign them. If it cannot, it is a system that is right until the first dispute.\n\nOur [reconciliation checklist](\u002Fresources\u002Freconciliation-checklist) turns these into questions you can ask of a system you already run, and the [rentiQ case study](\u002Fwork) shows where these rules sit inside a whole product.\n",[68,69,26],"2026-10-08T09:30:00+03:00",10,"Payment reconciliation failure modes","Why exact matching is the easy part of reconciliation, how to build a suspense queue on purpose, append-only allocation, and what an audit trail must answer.",{"slug":85,"title":86,"excerpt":87,"body":88,"cover":23,"tags":89,"publishedAt":93,"updatedAt":29,"readingTime":30,"seoTitle":94,"seoDescription":95},"what-it-costs-to-build-saas-in-kenya","What it costs to build a SaaS product in Kenya in 2026","No invented market figure. What actually moves the price of a build, why the lowest quote so often costs the most, what scoping buys, build versus buy, and the running costs nobody budgets for.","# What it costs to build a SaaS product in Kenya in 2026\n\n\"How much will it cost?\" is the question everyone wants answered first, and the honest first answer is a range too wide to plan with. Two products that sound alike in one sentence can differ several times over in effort, because the sentence leaves out what actually takes the time.\n\nSo this post does not offer a market price. We have not found a reliable survey of what software costs to build in Kenya, and we would rather not invent one. Our own prices are on our [pricing page](\u002Fpricing), where they are kept current. What follows is the part we can say without knowing your project: what moves the number, why the lowest quote so often turns out to cost the most, what to buy before the build, and the costs that arrive after launch, which are the ones nobody budgets for.\n\n\u003C!-- TODO(david): if a market range is ever added to this post, it needs a named source. Until then the post stays on drivers and our own experience, and quotes no figure. -->\n\n## What actually drives the cost\n\n**How many kinds of user there are.** An administrator, staff with limited permissions, customers, perhaps agents or field workers. Each kind has its own screens, but the time goes into the rules between them: who can see which records, who can approve what, what a person sees when their access is withdrawn. A product with one kind of user and a product with four are different sizes of project, even when the feature lists look the same length.\n\n**Whether it touches money.** Taking payments is the easy part. Allocating them to what is owed, handling partial and duplicate payments, issuing receipts, reversing mistakes and reconciling against the provider's statement is where the work is. We wrote about [the M-Pesa side of that](\u002Fblog\u002Fmpesa-daraja-integration-guide) and about [what happens after a payment arrives](\u002Fblog\u002Freconciliation-failure-modes). If a product moves money, budget for that work explicitly rather than as a line called \"payments\".\n\n**Integrations with systems you do not control.** M-Pesa through Daraja, KRA's eTIMS, SMS gateways, banks. Each comes with its own documentation, its own sandbox, and its own go-live process run by somebody else. Daraja's go-live needs an operator with the right role on your M-Pesa organisation portal; a system-to-system eTIMS integration goes through KRA's own testing and certification. Those steps take calendar time that no amount of engineering shortens, so a quote that treats them as a day of coding has not looked.\n\n**Whether it serves many organisations.** A product sold to many businesses, each seeing only its own data, has to be designed that way from the first table. Retrofitting tenant isolation into a product built for one customer is one of the more expensive changes there is, because it touches every query.\n\n**The data you already have.** Most businesses arrive with years of spreadsheets. Importing them, with validation a non-technical person can understand and opening balances that make the first month's reports correct, is real work. When we built the import for rentiQ, our property management product, it covered properties, units, tenants and leases with opening balances, and it was a project in its own right.\n\n**The reports the business runs on.** Tax summaries, arrears, statements, management reports. They are often specified last and rewritten most, because nobody can say what they need until they see what they get. Asking for the three reports that matter most during scoping saves a rebuild later.\n\n**Compliance.** Kenya's Data Protection Act 2019 sets rules for collecting, keeping and sharing personal data, and some businesses must also register with the [Office of the Data Protection Commissioner](https:\u002F\u002Fwww.odpc.go.ke). Invoices are governed by eTIMS. Neither is optional, and both are cheaper designed in than bolted on.\n\n**The quality floor.** Automated tests, continuous integration, a staging environment, monitoring, backups that have been restored at least once. Each costs something up front. Each saves more than it costs over the life of the product, which is exactly why they are the first things cut from a low quote.\n\n**Who writes it.** A senior engineer costs more per hour and usually less per feature, because fewer of the hours are spent learning, undoing, and redoing. The rate on the quote is not the cost of the software.\n\n## Why the cheapest quote is usually the most expensive outcome\n\nQuotes for the same brief can differ widely, and the difference is rarely efficiency. It is almost always scope that one vendor included and another left out, often without saying so. The most common omissions are the parts nobody sees in a demo:\n\n- No tests, so every later change risks breaking something that worked.\n- No staging environment, so changes are tried for the first time in front of customers.\n- No backups, or backups nobody has restored.\n- No documentation and no handover, so only the original developer can change anything.\n- The code, the hosting account or the domain registered in the vendor's name rather than yours.\n- A fixed price on a vague scope, which becomes either a stream of change requests or a stream of corners cut.\n\nNone of these shows up until after you have paid, and each turns into a cost later: a rewrite, an outage, a vendor you cannot leave. When you compare quotes, compare these lines before you compare the totals. Who owns the repository and the hosting account? Is there a staging environment? Are tests included, and will you see them run? What does handover include? What does support cost after launch, and who answers at night?\n\nA vendor who has thought about those questions answers them quickly. One who has not will improvise, and the improvisation is the answer.\n\n## What a scoping engagement buys\n\nThe gap between a brief and a price is closed by understanding the problem properly, and that work has to be done by somebody before an honest fixed price can exist. A scoping engagement is that work, paid for and done first.\n\nWhat it should leave you with: a written specification of what will be built and what will not, a sequence of milestones, the integrations and their external lead times, a fixed price for the build, and an estimate of what the system will cost to run. The specification should be yours, so you can build from it with anyone, including a vendor other than the one who wrote it.\n\nIt is the cheapest point in the project to discover that the idea needs to change, and the most useful. We wrote separately about [what a scoping call is for](\u002Fblog\u002Fwhat-a-scoping-call-is-for), which is the free conversation that comes before it.\n\n## Build, buy, or both\n\nNot everything should be built. Buy when the process is standard and the product fits it: accounting, payroll, email, general CRM. Mature products in those categories have absorbed years of edge cases you would otherwise pay to rediscover.\n\nBuild when the process is the business, or when local requirements are not served by what exists. A rental business reconciling M-Pesa Paybill payments against leases, a lender with its own credit rules, an operator whose invoices must go through eTIMS from its own system: these are the cases where an off-the-shelf product either does not fit or fits by forcing the business to change how it works.\n\nMost real answers are both. Buy the commodity parts, build the part that is yours, and connect them. Whatever you buy, check three things before signing: whether you can export all of your data in a usable form, whether it supports the way your customers actually pay, and where it stores personal data, because the Data Protection Act has rules about personal data leaving Kenya.\n\n## Total cost of ownership: the bill after launch\n\nThe build has a scope, a date and an invoice. Running it has none of those, which is why it gets left out. We covered the operational side in [what running SaaS actually costs after launch](\u002Fblog\u002Fwhat-running-saas-actually-costs). The items people most often forget to budget for:\n\n- **Hosting, and the currency it is billed in.** If your hosting is billed in dollars and your revenue is in shillings, the exchange rate is part of your cost base.\n- **Per-transaction charges.** Payment providers charge per transaction. Safaricom's own [Daraja documentation](https:\u002F\u002Fdeveloper.safaricom.co.ke\u002Fapis\u002FMpesaExpressSimulate) notes that, depending on the tariff, an M-Pesa charge falls on the business or is split between the business and the customer. SMS is charged per message. At low volume these are noise; at scale they are a line in the budget.\n- **Upgrades.** Frameworks and libraries publish support windows. A version that falls outside its window stops receiving security fixes, and an application left alone for a year or two turns routine upgrades into a project.\n- **Monitoring, backups and the restore drill.** Someone has to know a queue has stalled before a customer does, and someone has to have restored a backup recently enough to be sure it works.\n- **Support and on-call.** When a payment integration fails at night, a person has to be reachable. Whether that is your own staff or a retainer, it is a cost.\n- **The person who owns it.** Somebody on your side has to make decisions about the product after launch. Their time is part of what it costs.\n\nA useful rule when budgeting: plan the build and the first year of running it as one number. If the build consumes the whole budget, the product launches into a year it cannot afford.\n\n## A way to plan the money\n\nStart with the smallest version that proves the product is worth more investment: the core workflow, the money handling done properly, and nothing else. Fix its price after scoping. Budget the first year of running alongside it. Keep a margin for the integrations whose timelines belong to somebody else. And settle, before any code is written, that the repository, the hosting and the domain are in your name.\n\nOur [pricing page](\u002Fpricing) shows how we price each stage, and every build we quote starts with scoping.\n",[90,91,92],"saas","scoping","pricing","2026-10-08T09:00:00+03:00","What a SaaS build costs in Kenya in 2026","The real cost drivers of a SaaS build in Kenya, why the cheapest quote costs most, what scoping buys, build versus buy, and the running costs after launch.",{"slug":97,"title":98,"excerpt":99,"body":100,"cover":23,"tags":101,"publishedAt":106,"updatedAt":29,"readingTime":30,"seoTitle":107,"seoDescription":108},"etims-for-software-companies","eTIMS for software companies: what an invoice needs, and what your system must do","What KRA requires of a business that invoices, what an eTIMS invoice contains field by field, what that means for software that generates invoices, and which integration fits which volume.","# eTIMS for software companies: what an invoice needs, and what your system must do\n\nIf your business issues invoices in Kenya, KRA's electronic Tax Invoice Management System, eTIMS, decides whether those invoices count. If your business builds or runs the software that issues them, eTIMS also decides part of how that software has to work.\n\nThis post covers both: what the regime requires of a business, what \"eTIMS-compliant invoice\" means in fields and control numbers, what that means for any system that generates invoices, and how the integration options differ by volume. It describes the rules as KRA publishes them. It is not tax advice, and your own accountant should confirm how they apply to you.\n\n## What the regime requires\n\nThe legal basis is section 23A of the Tax Procedures Act, added by the Finance Act 2023. In KRA's words, it \"requires every person in business to record each sale, issue and transmit invoices through an electronic system as prescribed by the Commissioner\" ([KRA onboarding guidelines](https:\u002F\u002Fwww.kra.go.ke\u002Fimages\u002Fpublications\u002FeTIMS-Taxpayer-Guidelines_2024.pdf)). eTIMS is that system.\n\nThree consequences matter most, all from KRA's [eTIMS FAQ](https:\u002F\u002Fwww.kra.go.ke\u002Fhelping-tax-payers\u002Ffaqs\u002Flearn-about-etims) unless noted:\n\n- **Everyone in business is in scope.** \"All persons engaged in business are required to on-board eTIMS and issue electronic tax invoices.\" This is not limited to VAT-registered businesses.\n- **Your invoice is your customer's deduction.** \"Any business claiming deductions for business expenses must support them with valid electronic tax invoices.\" An invoice that did not go through eTIMS can cost your client the deduction, which makes it a commercial problem for you as well as a tax one.\n- **Returns are checked against the data.** From 1 January 2026, KRA validates the income and expenses declared in income tax returns against eTIMS invoices, withholding tax data and customs import records, with exceptions set out in section 23A and the Tax Procedures (Electronic Tax Invoice) Regulations, 2024 ([EY](https:\u002F\u002Ftaxnews.ey.com\u002Fnews\u002F2025-2471-kenya-revenue-authority-to-validate-income-and-expenses-in-income-tax-returns), [BDO East Africa](https:\u002F\u002Fwww.bdo-ea.com\u002Fen-gb\u002Finsights\u002Fkra-to-validate-income-and-expenses-declared-in-tax-returns-effective-1-january-2026)).\n\nOne exception changes who does the invoicing. Where you buy from a small business whose annual turnover does not exceed five million shillings, KRA's FAQ says \"the purchaser shall issue a tax invoice on their behalf.\" If your system buys from small suppliers, it may need to raise invoices for them, not only for itself.\n\n## What an eTIMS invoice actually contains\n\nThe clearest statement of the fields is KRA's own [technical specification for invoicing systems](https:\u002F\u002Fwww.kra.go.ke\u002Fimages\u002Fpublications\u002FTIS-for-OSCU--VSCU-Technical-Specifications-v2.0.pdf) that connect to eTIMS (version 2.0, April 2023). It calls the invoicing software a Trader Invoicing System, and the eTIMS component it talks to a Sales Control Unit: online (OSCU) or virtual (VSCU).\n\nEvery receipt must show, as a minimum:\n\n- the taxpayer's name, PIN, and the address where the sale takes place\n- the buyer's PIN\n- the receipt type and transaction type\n- a serial number \"from an uninterrupted ascending number series per receipt type\"\n- each item or service with description, quantity and price\n- the total, the tax rates applied, and the VAT added\n- the means of payment\n- the date and time stamped by the invoicing system\n- the control unit's information, printed in a block of its own\n\nThat last block is what makes an invoice an eTIMS invoice. When your system sends the invoice data to the control unit, the control unit answers with its identity, its own timestamp, receipt counters and a signature, and the specification says how they must be printed:\n\n| Printed as | What it is |\n|---|---|\n| `SCU Information` | The heading of the block |\n| `Date` and `Time` | The control unit's timestamp, not your server's |\n| `CU ID: KRACU04XXXXXXXX` | The control unit's identification |\n| CU invoice number | The CU ID and a sequential receipt number, e.g. `KRACU04XXXXXXXX\u002F1` |\n| Receipt counter | In the form `A\u002FB` followed by the receipt label |\n| Internal data | Encrypted data about the receipt, printed with a dash after every fourth character |\n| Receipt signature | Printed the same way |\n| QR code | Invoice date, time, CU number, CU receipt number, internal data and receipt signature, joined by `#` |\n\nAnyone holding the invoice can check it. KRA's FAQ gives two ways: scan the QR code, or enter the invoice number in the invoice number checker on iTax.\n\n### Receipt types, corrections and credit notes\n\nThe specification also fixes how invoices relate to each other, which is where most invoicing software needs changing:\n\n- Every receipt is a combination of a receipt type (normal, copy, training, proforma) and a transaction type (sale, credit note, debit note), and carries a label for it: `NS` for a normal sale, `NC` for a normal credit note, `CS` for a copy, and so on.\n- Only one original is printed. A reprint carries a \"Copy\" watermark.\n- A transaction cannot be corrected without first cancelling the original, the cancellation must refer to the original receipt number, and an original can be cancelled only once.\n- A credit note contains only negative amounts and refers to the original invoice it corrects.\n\n## What this means for any system that generates invoices\n\nRead as engineering requirements, those rules come down to a handful of design decisions. They apply whether your system talks to eTIMS directly or a person registers each invoice by hand.\n\n**An issued invoice is immutable.** Once it has a control unit number it is a fiscal document. Corrections are new documents that point at it, never edits to it. If your invoices table allows an update to the amount of an issued invoice, that path has to go.\n\n**Numbering has no gaps, per type.** Invoices and credit notes each need their own uninterrupted sequence. A number drawn inside a transaction that rolls back must be released, not skipped, which means drawing it from a locked counter inside the same transaction as the invoice.\n\n**Credit notes are documents, not negative invoices.** They need their own type, their own sequence, a reference to the original, and the reason for the correction recorded.\n\n**The control unit's answer is stored with the invoice.** CU ID, CU invoice number, timestamp, internal data, signature, QR payload. Stored atomically with the invoice and never regenerated, because the printed copy must be reproducible years later exactly as it was issued.\n\n**The buyer's PIN is captured.** Your customers' PINs become required data on the customer record, so a missing or malformed PIN is caught when the customer is set up, not when the invoice is issued.\n\n**There are two timestamps.** Your system's and the control unit's. Keep both.\n\n**Copies say they are copies.** Re-downloading a PDF a week later must not produce a second original.\n\n## The integration options, by volume\n\nKRA's [onboarding guidelines](https:\u002F\u002Fwww.kra.go.ke\u002Fimages\u002Fpublications\u002FeTIMS-Taxpayer-Guidelines_2024.pdf) set out who each solution suits:\n\n| Solution | KRA's eligibility, in short |\n|---|---|\n| eTIMS Lite (web on eCitizen, or USSD on \\*222#) | Non-VAT taxpayers with no other active eTIMS solution; small and micro businesses |\n| eTIMS online portal | Services only, no goods, and no more than 10 invoices a month |\n| eTIMS Client (Windows or Android) | Businesses without an invoicing system |\n| OSCU or VSCU | Businesses with an existing invoicing system that needs integrating |\n\nBetween the two integration options, KRA's [system-to-system page](https:\u002F\u002Fwww.kra.go.ke\u002Fbusiness\u002Fetims-electronic-tax-invoice-management-system\u002Flearn-about-etims\u002Fetims-system-to-system-integration) describes OSCU as \"suitable for taxpayers whose invoicing is always online\" and VSCU as suited to \"bulk invoicing\" from a system that \"is not always online\". Either way the integration is done by self-integration, which goes through KRA's development, testing, vetting and certification process using its sandbox at etims-sbx.kra.go.ke, or through one of the KRA-verified third-party integrators KRA publishes.\n\nThe practical reading for a software company is that the right option changes as you grow. A small services business issuing a few invoices a month is inside the online portal's limits. A SaaS product invoicing hundreds of subscribers a month is not, and needs its billing system integrated through OSCU, or through VSCU if invoices are generated in batches.\n\n## How we handle it in our own invoicing\n\nKimcard invoices its clients from our own system, so we have made these choices for ourselves. We sell services only, at a volume inside the online portal's limits, so the system is built to register invoices through KRA's portal and record the result, not to talk to eTIMS directly.\n\nConcretely, it generates invoices with the fields above and a gapless number, and tracks each invoice's eTIMS status. Once an invoice is registered, it stores the CU invoice number and the QR payload, prints both on the PDF, and refuses to mark the invoice final for tax until that number exists. Until then the PDF says the registration is pending, plainly, and never claims compliance it does not have. The CU invoice number is unique across all invoices, enforced by the database, because two invoices carrying the same number means one was typed wrong. Credit notes are raised in KRA's solution against the original and referenced here.\n\nThe registration step sits behind a single interface with one implementation today: a person records what KRA's portal returned. When volume justifies a direct OSCU integration, it becomes a second implementation of that interface, and no invoice code has to change. That is the shape we would recommend to any business whose eTIMS route is likely to change as it grows: design the invoice for the strictest option from the start, and keep the transport replaceable.\n\n\u003C!-- TODO(david): confirm Kimcard's own eTIMS onboarding status before this post goes live. The paragraphs above describe what the CMS does, which is accurate whether or not onboarding is complete; do not add a sentence claiming registration until it is done. -->\n\n## Questions for your accountant\n\nBecause the rules have consequences for deductions and returns, a few questions are worth putting to your own accountant before you design anything:\n\n- Which eTIMS solution fits our turnover, our VAT status and our invoice volume today, and in a year?\n- Which of our purchases fall under the exceptions, and which suppliers will need us to invoice on their behalf?\n- How should credit notes and cancellations be handled in our records, and by whom?\n- What happens to invoices issued before we onboarded?\n\nIf your invoicing system needs eTIMS designed in, that is the kind of work our [SaaS product development](\u002Fservices#saas-product-development) covers.\n",[102,103,104,105],"etims","kra","invoicing","compliance","2026-10-08T08:30:00+03:00","eTIMS for software companies and invoicing systems","What KRA eTIMS requires, the fields and control unit data on a compliant invoice, what it means for invoicing software, and OSCU, VSCU or the portal by volume.",{"slug":19,"title":20,"excerpt":21,"body":22,"cover":23,"tags":110,"publishedAt":28,"updatedAt":29,"readingTime":30,"seoTitle":31,"seoDescription":32},[25,26,27],{"slug":112,"title":113,"excerpt":114,"body":115,"cover":23,"tags":116,"publishedAt":118,"updatedAt":119,"readingTime":120,"seoTitle":113,"seoDescription":121},"the-launch-checklist-nobody-sends-you","The launch checklist nobody sends you","Most of what goes wrong at launch does not throw an error. The site loads, the pages render, and something underneath quietly is not doing its job.","## Launches fail quietly\n\nMost of what goes wrong at launch does not throw an error. The site loads, the pages render, and something underneath is not doing its job. Nobody notices for weeks, and by then the traffic that mattered has been and gone.\n\nHere is what we check before we call a site live, and why each one earned its place.\n\n## Mail authentication, before anything sends\n\nA domain that sends mail needs three DNS records: SPF, DKIM, and DMARC. Without them, transactional mail from your own domain lands in spam or nowhere.\n\nThe one that catches people is the interaction between them. Point your MX records at one provider and your SPF record at another and you have a domain that receives mail perfectly well and cannot send it credibly. We check that SPF names the service that is actually sending today, not the one that was sending last quarter.\n\n## Backups, with a restore you have actually done\n\nAn automated backup nobody has restored from is a hypothesis. The check is not whether backups run. It is whether somebody has taken one, restored it somewhere else, and looked at the data.\n\nThis gets more important the moment a system holds invoices, payments, or client history rather than marketing copy.\n\n## Security headers on the response, not in the config\n\nA Content Security Policy in a config file is a plan. A Content Security Policy on the response is a policy. We fetch the deployed site and read the headers back, because build-time configuration reaches production through several layers and any of them can drop it.\n\n## Analytics that are actually recording\n\nThis one is quiet in an unusual way. An analytics script can load, define its global, log nothing you would notice, and record not a single page view, because the beacon goes to a different host from the script and the policy above allows only one of the two.\n\nThe check is not that the script tag is present. It is that a page view arrives in the dashboard.\n\n## The sitemap a crawler actually receives\n\nAsk for the URL your robots.txt advertises, and read what comes back. A sitemap route that redirects, or returns HTML, or splits into an index for a site with one language, is a sitemap a crawler has to work to use.\n\n## Every outbound link you published\n\nSites link to products, subdomains, and partner pages that were live when the copy was written. We fetch all of them. A link to a subdomain with no DNS is worse than no link, especially on the page asking somebody to buy something.\n\n## The form, end to end, with a real message\n\nNot a unit test. Fill in the form on the deployed site, send it, and confirm it arrives where a human will read it.\n\nA contact form that shows a success message and drops the enquiry is the most expensive bug a marketing site can have, because the sender believes they have reached you and stops trying. You never find out. It is the one failure that costs revenue without producing a single error log.\n\n## Why we automate most of it\n\nEverything above except the backup restore and the real enquiry runs as a script against the deployed URL. Around fifty checks, a couple of minutes, and it runs after every deploy rather than once on launch day.\n\nA checklist in a document is a thing somebody remembers to read. A checklist the deploy runs is a thing that gets read on the day everybody is in a hurry, which is the day it matters.",[90,117],"devops","2026-07-31T08:00:00+03:00","2026-07-31T15:44:06+03:00",3,"Mail authentication, backups you have restored, headers on the response, analytics that record, and a form tested end to end. What we check before calling a site live.",{"slug":123,"title":124,"excerpt":125,"body":126,"cover":23,"tags":127,"publishedAt":128,"updatedAt":129,"readingTime":120,"seoTitle":124,"seoDescription":130},"what-a-scoping-call-is-for","What a scoping call is for","A scoping call is not a sales call with a nicer name. It exists to find out whether the thing you want built is the thing you need, and whether we are the ones to build it.","# What a scoping call is for\n\nWe ask every enquiry to start with a scoping call, and people reasonably assume that is a sales meeting wearing a polite hat. It is not. It is the cheapest hour in the whole engagement, and it is where most of the money is either saved or lost.\n\n## Three questions, in order\n\n**What breaks if this does not get built?** Not what the software does. What happens to the business without it. If the honest answer is \"not much yet\", that is worth finding out before anyone writes a proposal. Some of the most useful calls we have had ended with us saying the problem was a spreadsheet and a process change, not a product.\n\n**Where is it now?** Greenfield, half-built, or inherited. Each has a completely different first month. Inherited systems in particular need a read of the code before anyone can quote honestly, because the gap between \"it mostly works\" and what is actually in the repository is where fixed-price projects go to die.\n\n**Who owns it after we hand it over?** This changes the architecture. A team with a Laravel developer gets a different handover from a founder who will be the only person with the keys. Building the second one as though it were the first is how a client ends up locked into their own vendor.\n\n## What we produce afterwards\n\nWithin a day or two you get back a written summary: what we understood the problem to be, what we would build first, what we would deliberately leave out of phase one, and the shape of the commercial arrangement. Whether it is a fixed scope or a rate depends on how much of the above is actually known.\n\nIf the answer is that the work is not a fit for us, you get that in writing too, with a reason. An engagement that should not have started is more expensive than one that never did.\n\n## Why we do not quote from a brief alone\n\nA written brief describes a solution somebody has already chosen. Quoting against it means pricing that choice without ever testing it, and if the choice is wrong, the estimate is precise and useless.\n\nWe would rather spend an hour understanding the problem and be slower to send a number.\n\n## What to bring\n\nNothing formal. If you have a document, send it. If you have a spreadsheet holding the business together, that is more useful than a document. Screens of the existing system, a rough sense of budget, and the date something has to be live by are enough to make the hour count.\n",[90,91],"2026-07-27T08:00:00+03:00","2026-07-30T17:50:00+03:00","A scoping call is not a sales call. It exists to test whether the thing you want built is the thing you need, and to produce a written summary you can act on.",{"slug":132,"title":133,"excerpt":134,"body":135,"cover":23,"tags":136,"publishedAt":137,"updatedAt":129,"readingTime":39,"seoTitle":133,"seoDescription":138},"what-running-saas-actually-costs","What running SaaS actually costs after launch","Most SaaS budgets stop at launch. The bill that follows is patching, backups, monitoring, and the on-call hour nobody planned for.","# What running SaaS actually costs after launch\n\nLaunch is the cheap part. It has a scope, a date, and an invoice. What follows has none of those, which is why it gets left out of the budget.\n\n## The costs that arrive after launch\n\nA running SaaS product needs, every month, whether or not anyone ships a feature:\n\n- **Dependency updates.** A Laravel and Vue application pulls hundreds of packages. Left alone for a year, upgrading stops being a chore and becomes a project.\n- **Security patching.** CVEs do not wait for a convenient sprint.\n- **Backups you have actually restored.** An untested backup is a hope, not a backup.\n- **Monitoring and alerting.** Knowing a queue has stalled before a customer tells you.\n- **On-call time.** Someone has to be reachable when a payment webhook starts failing at 9pm.\n\nNone of this is visible in a demo. All of it is the difference between software that runs for five years and software that quietly rots for eighteen months and then needs rewriting.\n\n## Why we price maintenance separately\n\nWe put support and SLA work on its own line because bundling it hides it. When maintenance is folded into a build price, the first thing cut under pressure is the invisible half. Then the rewrite lands two years later and costs more than the original build.\n\nA separate line is harder to sell and easier to defend.\n\n## What we do about it\n\nEvery system we hand over ships with the operational parts already in place:\n\n```\nCI on every push          tests + build must pass before deploy\nStaging that mirrors prod  same runtime, same migrations\nAutomated backups          with a documented restore, tested\nUptime + error monitoring  alerts to a human, not a dashboard\n```\n\nThat is not a premium tier. It is what \"we run it\" means.\n\n## The question to ask a vendor\n\nNot \"what will it cost to build\". Ask what it costs to keep running in year two, who is on call, and what happens when they are not available. A vendor who has not thought about it will improvise an answer, and the improvisation is the answer.\n",[90,117,25],"2026-07-20T08:00:00+03:00","Dependency updates, security patching, tested backups, monitoring, and on-call time. The post-launch bill that most SaaS budgets leave out.",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":140},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m12 19l-7-7l7-7m7 7H5\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":142,"hidden":143},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2a2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6M2 9h4v12H2z\"\u002F>\u003Ccircle cx=\"4\" cy=\"4\" r=\"2\"\u002F>\u003C\u002Fg>",true,{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":145,"hidden":143},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6c2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4c-.9-4.2 4-6.6 7-3.8c1.1 0 3-1.2 3-1.2\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":147},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"\u002F>\u003Cpath d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":149},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m6 9l6 6l6-6\"\u002F>"]