STK push is the easy part. You call the API, the customer gets a prompt, they enter their PIN. Done.
The hard part is the callback. It arrives late, twice, or never. And when it arrives, you have to answer one question: which open invoice does this belong to, and for how much?
This is where payment systems die. Not in the happy path. In reconciliation.
The matching problem
A callback arrives with a transaction ID and an amount. You have a pile of open invoices. The callback doesn't tell you which invoice it pays — you have to figure that out.
If you keyed the STK request to an invoice ID at push time, you have the link. If you didn't, you're guessing from the amount and the phone number, and guessing is how money goes missing.
Rule one: every STK push carries your internal reference from the start. The callback is just the confirmation of something you already tracked.
Partial payments
Invoice for 1000. Callback for 400. The customer paid part of it.
Do not mark the invoice paid. Keep it open, track paidAmount, and keep waiting for the rest. The moment you mark a partially-paid invoice as settled, you've given away 600 and your ledger will never balance.
This sounds obvious. It breaks in production constantly, because the "mark paid" code path is the same one the full-payment path uses, and nobody tested the partial case.
Overpayments
Invoice for 1000. Callback for 1200.
Credit the difference. The customer overpaid by 200 — that 200 belongs to them, sitting as a credit on their account. Never silently absorb it into your revenue. Your ledger is append-only for a reason: every shilling has to be accounted for, and "we kept the extra" is not an accounting entry.
Idempotency: the callback will arrive twice
M-Pesa retries callbacks. Your endpoint will see the same transaction ID more than once. If applying a callback isn't idempotent, the second delivery double-applies the payment — the invoice gets paid twice, the balance goes wrong, and you're debugging a ledger that says the customer paid 2000 for a 1000 invoice.
The fix is a unique constraint on the gateway transaction ID. The first callback applies. The second one hits the constraint and is a no-op. Boring, mechanical, and the difference between a ledger you trust and one you don't.
Don't trust the callback's amount
STK amounts are fixed at request time. You asked for 1000, the customer authorized 1000. On confirmed success, apply the requested amount — the one you recorded when you initiated the push. Don't take the amount from the callback body and write it into your ledger, because the callback is an untrusted input and your own record is the source of truth.
This is the same instinct as the rest of the system: the ledger is append-only, entries only come from narrow, validated entry points, and nothing outside the system writes directly to a balance.
The shape of it
Reconciliation isn't a feature you add at the end. It's the core loop: push with a reference, receive the callback, match it to the open invoice, apply it idempotently, handle partial and overpayment as first-class cases, and never trust the callback more than your own records.
Get that loop right and the payment system is boring in the best way. Get it wrong and you're reconciling by hand at the end of every month, wondering where the money went.
I'm building a payment system in Go and Postgres. This is the part that keeps me up at night — not the API, not the STK push, but the callback arriving at 2am, twice, for an invoice that was already half-paid.
Top comments (0)