guide · payments
M-PESA STK Push in Laravel: The Practical Guide
Most Daraja tutorials get you to CheckoutRequestID and stop. Production is where the real work starts: expired tokens, duplicate callbacks, timeouts, users who close the prompt, and reconciliation when Safaricom says "success" but your DB says otherwise. This is the flow I actually ship.
1. Get your Daraja credentials
Create an app on the Safaricom Daraja portal and note your consumer_key, consumer_secret, BusinessShortCode (paybill/till) and PassKey. Sandbox first — always.
2. Cache the access token properly
Tokens expire in ~3600s. Requesting one per transaction will get you rate-limited and will break under load. Cache it:
$token = Cache::remember('mpesa_token', 3500, function () {
return Http::withBasicAuth(
config('mpesa.key'), config('mpesa.secret')
)->get(config('mpesa.base') . '/oauth/v1/generate?grant_type=client_credentials')
->json('access_token');
});
3. Fire the STK Push
The password is base64(shortcode + passkey + timestamp). Always store the CheckoutRequestID — it's your reconciliation key:
$ts = now()->format('YmdHis');
$password = base64_encode($shortcode . $passkey . $ts);
$res = Http::withToken($token)->post(
config('mpesa.base') . '/mpesa/stkpush/v1/processrequest',
[
'BusinessShortCode' => $shortcode,
'Password' => $password,
'Timestamp' => $ts,
'TransactionType' => 'CustomerPayBillOnline',
'Amount' => $amount,
'PartyA' => $phone, // 2547XXXXXXXX
'PartyB' => $shortcode,
'PhoneNumber' => $phone,
'CallBackURL' => route('mpesa.callback'),
'AccountReference' => $order->reference,
'TransactionDesc' => 'Order payment',
]
);
$payment->update([
'checkout_request_id' => $res->json('CheckoutRequestID'),
'status' => 'pending',
]);
4. The callback — where integrations die
Three rules that separate production integrations from demos:
- Idempotency. Safaricom retries callbacks. Update only if the payment is still
pending— never blindly overwrite a confirmed payment. - Don't trust the body alone. Match
CheckoutRequestIDagainst your stored record before crediting anything. - Handle the failure codes.
ResultCode 1032= user cancelled,1037= timeout,2001= wrong PIN. Mark them failed — don't leave payments pending forever.
public function callback(Request $request)
{
$cb = $request->input('Body.stkCallback');
$payment = Payment::where('checkout_request_id', $cb['CheckoutRequestID'])
->where('status', 'pending') // idempotency
->firstOrFail();
if ($cb['ResultCode'] === 0) {
$meta = collect($cb['CallbackMetadata']['Item']);
$payment->update([
'status' => 'paid',
'receipt' => $meta->firstWhere('Name', 'MpesaReceiptNumber')['Value'],
'amount_paid' => $meta->firstWhere('Name', 'Amount')['Value'],
]);
} else {
$payment->update(['status' => 'failed', 'fail_code' => $cb['ResultCode']]);
}
return response()->json(['ResultCode' => 0]); // always 200
}
5. What about timeouts?
Safaricom's prompt can stall. Queue a transaction status query 60s after initiation for anything still pending — don't make users pay twice.
TL;DR: cache tokens, store CheckoutRequestID, make callbacks idempotent, handle failure codes, and always return 200 to Safaricom even when the payment failed.
I've shipped this flow in marketplaces, booking systems and subscription platforms. If you'd rather have it just work — I do M-PESA integrations end-to-end, or send me your stack.