Receive money in Kenya
Shillings arriving at the collection account we issue you: which rails reach it, what attributes each payment to an order, and why one rail gives you no reference to match on.
Shillings reach you at a collection account issued to your partner account. Four Kenyan rails can reach it, and they divide into two groups that behave differently enough to change how you build attribution.
This page is about money coming in. Money going out settles to an account registered against your partner account in advance — see KES.
Where the money goes
GET /v1/funding is the source of truth for your instructions, and the create
response on a buy order repeats them in paymentInstructions. Read them from
the response rather than storing a copy: the account we publish is yours, but
it is ours to change.
{
"kes": [
{
"rail": "mpesa_paybill",
"paybill": "000000",
"accountReference": "00000000123456",
"currency": "KES",
"instructions": "M-PESA > Lipa na M-PESA > Pay Bill > Business number 000000 > Account number 00000000123456."
},
{
"rail": "bank_transfer",
"accountReference": "00000000123456",
"beneficiaryName": "OnLink Technology Inc",
"currency": "KES",
"instructions": "Send KES to account 00000000123456 by PesaLink, RTGS or EFT. Put the paymentReference from your order in the narration."
}
]
}One entry per rail family available to you — mpesa_paybill, and
bank_transfer, which covers PesaLink, RTGS and EFT — and the array can be
empty. A partner whose collection account is not activated yet gets []
rather than a placeholder, because publishing an account nothing is watching
is money arriving nowhere. The usdt half of the same response is the sell
leg, and USDT on Tron covers it.
accountReference is the exact value the payer must quote, verbatim. On
the Pay Bill rail it goes in M-PESA's Account Number field; on a bank transfer
it is the beneficiary account number. It is your own dedicated collection
account, which is what makes matching inside it safe.
beneficiaryName is the name a bank transfer must be addressed to. It is
present only when our bank has confirmed the name back to us. If it is absent,
ask us rather than composing one: the receiving bank matches on that string,
and a guess decides whether the money lands.
Choose the rail on the order
POST /v1/orders/buy requires paymentRail, one of mpesa, pesalink,
rtgs or eft. It is not cosmetic: the rate guarantee is per rail, attribution
differs by rail, and the paymentInstructions on the create response are for
the rail you declared. So the rail is chosen by you, when you create the order,
and your payer pays on it — not on whichever rail they happen to prefer.
The two vocabularies line up like this: mpesa is the mpesa_paybill entry
above, and pesalink, rtgs and eft are all the bank_transfer entry.
The two groups, and the difference that matters
| Rail | What the payer does | What attributes the payment |
|---|---|---|
| M-PESA Pay Bill | Pays the Pay Bill, quoting your account number | The account number, plus the exact amount inside the window |
| Bank transfer (PesaLink, RTGS, EFT) | Transfers to the account number we publish | The paymentReference we issue, carried in the narration |
On a bank transfer the reference we mint travels with the money and attributes the payment exactly, whatever the amount. On M-PESA it does not travel at all.
On M-PESA there is no reference to match on
M-PESA Pay Bill has no narration field. There is nowhere to put the
paymentReference we issued, so nothing we mint can ride along with the
payment — and there is no version of your integration that changes that.
What identifies the payment instead is the account number, which is yours and is the same on every payment, plus the amount and the time window. That is enough to attribute a payment to your account, and only sometimes enough to attribute it to one order.
Two of your open orders carrying the same kesAmount inside the same window
are genuinely ambiguous. We will not guess: both are held rather than one being
picked. Use a bank transfer for same-amount orders, or do not hold two
identical open orders at once.
If you need to attribute a payment to one of your own users
This is the part worth designing before you build, because the handle you get is not the one most integrations assume.
- On a bank transfer, you have the reference we minted. It is ours, we validate it, and it identifies exactly one order.
- On M-PESA, nothing the payer types reaches you. The handles are the
account number, which is the same on every payment; the amount; the window;
and, once we have attributed the payment, the
providerReference— the M-PESA receipt code the payer also holds. That code is the one thing a payer can quote back to you.
If your product asks contributors, customers or members to identify themselves in a payment, do not build the design that assumes an identifier arrives with the money. On M-PESA it cannot. Build the one that gives each expected payment its own order, reconciles on amount and time, and has a path for a payment you cannot place. On the rails where a reference does travel, use it and prefer them.
A credit we cannot attribute to any order is not lost, and you do hear about it.
credit.received fires for every cleared credit on your account, attributed
to an order or not, so an unattributed payment reaches you as an event rather than
as a balance you have to notice. GET /v1/balances
reflects it too.
What the event cannot do is tell you which of your own users sent it — that is
the attribution problem above, not a gap in the event. Read reference off it:
on a bank transfer it is the payer's bank reference, on M-PESA it is their receipt
code, and it is the one handle a payer can quote back to you. Nothing on this API
moves an unattributed credit onto an order, so if you need that, send us the
transactionId from the event.
The whole leg, end to end
sequenceDiagram
autonumber
accTitle: Shillings arriving, from your payer to your webhook
accDescr: Seven messages between your payer, the Kenyan rail, OnLink and you. You create a buy order, declaring the rail your payer will use, and OnLink returns payment instructions for that rail. You show your payer the account number and, on a bank transfer, the reference. Your payer pays on that rail. The rail credits the account OnLink issued you. OnLink attributes the credit to your order. OnLink posts you the order.funds_confirmed webhook.
participant Payer as Your payer
participant Rail as A Kenyan rail
participant OnLink
participant You
You->>OnLink: create a buy order, declaring paymentRail
OnLink-->>You: paymentInstructions
You->>Payer: show the account number, and the reference on a bank transfer
Payer->>Rail: pay on that rail
Rail->>OnLink: credit the account we issued you
OnLink->>OnLink: attribute the credit to your order
OnLink-->>You: webhook order.funds_confirmedIn order:
- You create the buy order, declaring
paymentRail, and readpaymentInstructionsfor that rail off the response. - You show your payer the account number — and, on a bank transfer, the reference we issued.
- Your payer pays, on the rail you declared.
- The rail credits the account we issued you.
- We attribute the credit to your order, by the reference on a bank transfer or by the amount and the window on M-PESA.
- We post you
order.funds_confirmed.
Ask a payer to pay, instead of waiting for them
The rails above are all pull-by-instruction: you tell your payer where to send money and wait. On M-PESA you can also raise the prompt yourself, which turns a message you hope somebody acts on into a notification on their handset.
POST /v1/funding/mpesa-push takes
the number and the amount, and nothing else:
{
"phone": "254700000000",
"amount": "1500"
}{
"pushId": "SBX0EXAMPLE",
"status": "pending"
}Four things to know before you build on it:
- The destination is not a field. The money lands in your own KES account,
resolved from your partner record. There is nothing here that can send money
somewhere else, which is why the endpoint needs only
funding:writeand not the ceremonyPOST /v1/transfersneeds. - Nothing has moved when you get the
202. The payer enters their own PIN on their own handset. A prompt they decline or ignore produces no event at all, and there is no route that readspushId— treat it as something to quote to us, not as a resource. - The amount is whole shillings, and capped. M-PESA does not process decimals
on an inbound customer-to-business push, so
"100.50"is refused rather than rounded — as is an amount above the M-PESA per-transaction limit, and a leading zero. All three are a400on the body, refused here rather than forwarded to the bank and refused there. - There is no idempotency key. A repeated call is a second prompt, and if the
payer approves both then both deposits land and each raises its own
credit.received. Guard the retry on your side. - It is rate-limited to 10 a minute, lower than the rest of the API, because
every call rings a real phone. Over the limit is a
429withRetry-After-partner. If you need to collect faster than that, publish the account number instead and let payers push to you. - The two failures mean different things. A
422 MPESA_PUSH_REJECTEDis the bank answering and declining — usually a number that is not M-PESA-registered — so retrying it unchanged will be declined again. A503 MPESA_PUSH_UNAVAILABLEis the bank not answering, which is ambiguous: the prompt may already have gone out. Wait forcredit.receivedbefore retrying that one, or you may raise a second prompt on the same handset. See Errors.
Everything on the attribution page above still applies: this is the M-PESA rail, so there is no reference travelling with the money and the receipt code arrives afterwards on the event.
What tells you the money arrived
Two events, and which one you get depends on whether the credit belongs to an order:
credit.received— fired for every cleared credit on your KES account, whatever the rail and whoever sent it. This is the one to build on if you are taking payments rather than buying USDT, and it is the only signal for a payment that matches no order.order.funds_confirmed— fired once we have attributed a payment to a buy order. Until it fires we cannot tell "has not arrived" from "arrived, and we do not know which order it is for", and those are different problems with different remedies.
Both are signed webhooks, and both carry a delivery id that is stable across retries. A payment on a buy order raises both: the credit landed, and then it was attributed.
Polling GET /v1/orders/{id} also works and
terminates, and covers the order half. There is deliberately no equivalent poll for
an inbound credit — you do not know its identifier until we tell you, which is why
credit.received is a webhook and not a list endpoint.
Once a payment is attributed, the order carries the rail's own code alongside ours:
providerReference— the M-PESA code from your payer's handset, or their bank's reference. This is the one to show your end user; ours means nothing to them.paymentReference— ours, for your reconciliation against us.
Both are null until a payment is attributed, and both are opaque strings:
store them, show them, do not parse them.
Before you build
- Declare the rail your payer will actually use.
paymentRailis fixed at creation, and the instructions you get back are for that rail alone. - Pay the exact amount, before the deadline. The deadline is
expiresAton the order. An order that is never funded expires; a funded one does not. - One payment funds one order. A single payment meant to cover two orders does not apply its surplus to the second.
- Prefer a bank transfer where you have the choice. Exact attribution regardless of the amount is worth more than the convenience of the other rail, and it is the difference between reconciling and guessing.
- Read the cut-offs. M-PESA and PesaLink run continuously; RTGS and EFT observe a banking day. See Cut-off times.