M-Pesa integration for ERPNext, from STK Push to reconciliation
M-Pesa is three different integrations wearing one name. Collection, customer-initiated payments, and disbursement each have their own flow, their own callbacks, and their own reconciliation problem.
When a client says they want M-Pesa in their ERP, they usually mean one specific thing: taking payment at the point of sale. But M-Pesa on ERPNext is really three integrations sharing a brand name. STK Push collects money from a customer's phone. C2B handles payments customers initiate themselves at a paybill or till. B2C sends money out, for refunds, supplier payments, or payroll. Each has a different flow, a different callback, and a different way of going wrong.
Getting all three right means treating the asynchronous, callback-driven nature of the Daraja API as the core design constraint, not an afterthought. Here is how the pieces fit on ERPNext.
The DocTypes that hold it together
A working M-Pesa layer needs a small set of records, each owning one concern:
- M-Pesa Settings: consumer key and secret, shortcode, passkey, and the sandbox-or-production base URL.
- M-Pesa Transaction: the transaction log, with status tracking, that everything reconciles against.
- STK Push Request: one record per Lipa Na M-Pesa prompt sent to a phone.
- C2B Register URL: the validation and confirmation URLs registered with Safaricom for customer-initiated payments.
- B2C Disbursement: batches of outgoing payouts.
- Callback handlers: the result endpoints Safaricom calls back asynchronously.
On the ERPNext side, the integration surfaces as action buttons on Sales Invoice and a trigger on Payment Request, so staff initiate a charge from the document they are already looking at rather than a separate screen.
STK Push: collecting at the point of sale
STK Push is the flow everyone pictures. The operator triggers a charge, the customer gets a prompt on their phone, they enter their PIN, and the money moves. What matters for the integration is that none of the confirmation happens in the request you sent. It arrives later, on a callback.
The trap is treating the initial POST response as success. It only means the prompt was sent. The customer can still cancel, time out, or have insufficient funds. The Payment Entry must be created from the callback, not from the request, and your UI needs a pending state for the gap in between.
C2B: matching what customers send you
C2B covers payments the customer initiates without you prompting them. They simply pay your paybill or till. Safaricom hits your confirmation URL, and the integration's job is to figure out what the payment was for.
Matching is the hard part. The account reference the customer types is your only link back to an invoice, and customers mistype it, leave it blank, or reuse an old one. Build for the unmatched case from the start: park unmatchable payments in a clearing state for a human to resolve rather than dropping them or guessing. A payment you received but cannot match is still your liability to the customer.
B2C: sending money out
B2C is disbursement: refunds, supplier payouts, commission payments, even mobile-money payroll. You create a batch of recipients, submit it, and the payouts queue. Crucially, you do not learn the outcome of each payout in the submit response. You poll for it.
The reconciliation scheduler
Because every flow is asynchronous, the scheduler is what keeps your ledger honest. A typical configuration runs two cadences:
The 15-minute job closes the loop on disbursements whose callbacks never arrived. The three-hourly bulk query and pull API sweep up any collection a missed callback left in limbo. Between them, a transaction that started but never confirmed gets resolved by polling instead of sitting forever as pending.
The patterns that keep it stable
A few implementation habits separate an M-Pesa integration that runs quietly from one that pages you at night:
- Cache the OAuth token. Daraja access tokens last about an hour; fetching a fresh one on every call wastes time and rate budget. Cache until expiry and refresh lazily.
- Use idempotency keys. Networks retry, callbacks fire twice. Key on the M-Pesa transaction ID so a duplicate callback updates the existing record instead of creating a second Payment Entry.
- Retry failed callbacks through the scheduler rather than hoping Safaricom resends.
- Keep sandbox and production behind one setting. The base URL switch in M-Pesa Settings lets you rehearse against the sandbox without touching live money.
Idempotency is the one we see skipped most often, and it is the one that quietly corrupts a ledger. A double-fired confirmation callback that creates two Payment Entries against one invoice will not error. It will just overstate your collections until someone notices. Key everything on the transaction ID.
Done well, M-Pesa stops being a payment method bolted onto the ERP and becomes part of the accounting flow. Collections reconcile to invoices automatically, disbursements close themselves out, and finance stops exporting statements to match by hand. The work is in respecting the asynchrony: build for callbacks and polling, not for a tidy synchronous response that M-Pesa never actually gives you.
Planning M-Pesa collection, paybill matching, or B2C payouts on ERPNext? We have shipped all three across retail and services clients. Book a call and we will map your flows and reconciliation before a line of code is written.
Working on something like this?
We ship ERPNext and custom Frappe apps across Kenya, Uganda, Tanzania, Rwanda, Ethiopia, and Somalia. Let's talk through your build.