A borrower pays your paybill from their phone. Somewhere between that moment and your trial balance, a person is usually sitting with two screens open. This is what Safaricom actually sends you, why the payment sometimes cannot be matched, and what a system has to do about each case.
What Safaricom actually sends you
Paybill collections reach a system through the Customer to Business (C2B) part of Safaricom's Daraja API. You register two URLs against your shortcode, and Safaricom posts to them:
- The validation URL — called before the payment completes, if validation is enabled on your shortcode. You may accept or reject. Most lenders should accept everything and sort it out afterwards: rejecting a payment because the account number looks wrong turns a reconciliation problem into a customer standing at a shop counter being told their money was refused.
- The confirmation URL — called after the payment has completed. This is the one that matters, and it is the record you post from.
A confirmation payload carries, among other fields: a transaction reference
(TransID — the TFG4H8K2L1-looking code
the customer also gets by SMS), the amount, the paying phone number, the
timestamp, the shortcode that was paid, and BillRefNumber — the
account number the customer typed.
The matching, and why it is not just a lookup
In the happy case, BillRefNumber is the loan account number, the
system finds exactly one loan, and the payment is applied against the
schedule. In our experience that is most payments, and it is the whole reason
automated reconciliation is worth building.
The rest look like this:
| What arrives | Why | What a system should do |
|---|---|---|
| An ID number instead of a loan number | It is the number the customer knows by heart. | Match on the client's ID, then on their loans. One active loan, apply it. More than one, suspense. |
| A phone number, or nothing at all | The reference field was left blank or filled with the payer's own number. | Match on the paying MSISDN against client phone numbers. This is a good match when it is unique and a bad one when a family shares a handset. |
| A loan number with a typo | Keypad. | Suspense. Do not fuzzy-match a financial reference; a near-miss credits somebody else's loan. |
| Someone else's number entirely | A relative or employer is paying on the borrower's behalf. | Match on the reference, not the payer. The person paying is not necessarily the borrower. |
The same TransID twice |
Safaricom retries a callback it did not get an acknowledgement for. | Ignore the second one. See idempotency below. |
| A reversal | The customer or Safaricom reversed the transaction. | Post a reversing entry. Never delete the original. |
Idempotency is not optional, and it is not hard
Safaricom will deliver the same confirmation more than once. This is normal behaviour for any callback system: if the acknowledgement is slow or lost, the sender retries, because the alternative is losing a payment notification entirely. It is the correct design on their side and it is your problem on yours.
The fix is one line of schema: a unique constraint on the transaction
reference. Store the raw callback, keyed on TransID, before you
do anything with it. If the insert violates the unique constraint, you have
seen this payment; acknowledge and stop. Then, and only then, do the matching
and the posting.
A system that matches first and stores second will, sooner or later, credit a borrower twice for one payment — and it will do it on the day the network is bad, which is the day nobody is looking.
The suspense account is a feature, not a failure
A payment that cannot be matched confidently should still be received. The money is in your bank; pretending otherwise puts your books out by exactly that amount.
The standard accounting treatment is to debit the bank or M-PESA control account and credit a suspense liability. When a human works out whose payment it was, the suspense is cleared and the loan is credited. Two entries, both traceable, and at no point is the cash unaccounted for.
This is also the metric to watch. A suspense list that is short and gets cleared daily is a healthy system. A suspense list with four hundred items on it means your account-number convention is not working and the fix is upstream — usually in what you print on the customer's repayment SMS.
Posting to the ledger in the same moment
The reason to automate this is not typing speed. It is that a payment recorded in the loan module and a payment recorded in the accounts are two separate acts, done by two people at two different times, and any gap between them is where the month-end argument comes from.
A matched repayment should write its journal in the same transaction that applies it to the schedule — debit the M-PESA control account, credit loan principal for the principal portion, credit interest income for the interest portion, credit penalty income if a penalty was settled. If the journal fails to write, the application to the schedule must fail with it. Either both happened or neither did.
Do that and the loan ledger and the trial balance are the same number by construction rather than by reconciliation. See why your loan book and your ledger disagree for the six things that still put them out even when this is right.
Allocation order, and writing it down
When a payment is smaller than what is due, something has to decide what it pays first. Penalties, then interest, then principal is the most common order in Kenyan microfinance, but it is a policy decision with real consequences for the borrower and it should be a configured rule rather than an accident of whoever wrote the code.
Whatever you choose, three things follow:
- It should be visible on the customer's statement, so a borrower can see why a payment of KES 3,000 reduced their principal by KES 1,900.
- It should be the same for every loan of the same product, every time.
- Changing it should not silently re-allocate history.
What happens when the branch is offline
This is the question every field team asks, and the answer is better than people expect. Safaricom delivers the callback to your server, not to anybody's browser. A branch with no connection is a branch that cannot open the system — it is not a branch whose collections stop posting. When the connection returns, the payments are already applied and the journals are already written.
What you lose while offline is data entry: new clients, new applications, manual receipts. That is a real cost and worth planning around, but it is a much smaller one than losing collections.