Tutorial 1 of 9
Accept M-Pesa payments
An STK Push sends a payment prompt to a customer’s phone. They enter their M-Pesa PIN, and the money lands in your short code.
This page builds the whole path: the prompt, the callback that says what happened, a screen that tells the customer, and the failures that are waiting for you in production.
By the end you will have a payment you can prove arrived — which is a different and much smaller claim than a payment you assumed arrived.
Before you start
Section titled “Before you start”- Sandbox credentials from the Daraja portal. See Sandbox credentials.
- PHP 8.3 or higher, and Laravel 12 or 13.
- A URL Safaricom can reach from the public internet. Your laptop is not one — Testing callbacks on localhost is the whole story on that, and worth reading first if you have never done it.
Install
Section titled “Install”composer require starnerz/laravel-darajaphp artisan vendor:publish --tag=laravel-daraja-configDARAJA_MODE=sandboxDARAJA_CONSUMER_KEY=your-consumer-keyDARAJA_CONSUMER_SECRET=your-consumer-secretDARAJA_STK_SHORTCODE=174379DARAJA_STK_PASS_KEY=your-passkeyDARAJA_STK_CALLBACK_URL=https://your-public-url/daraja/stkDARAJA_ROUTES_ENABLED=trueDARAJA_ROUTES_ENABLED registers the package’s own callback endpoints, which
parse Safaricom’s payloads and dispatch typed events. You can point your own
controllers at the same parsers instead — see
Handling callbacks — but start here.
Check the credentials before writing any code:
php artisan daraja:tokenWhere payments are stored
Section titled “Where payments are stored”Two tables, and the split matters.
Schema::create('payment_attempts', function (Blueprint $table) { $table->id(); $table->string('checkout_request_id')->unique(); $table->foreignId('order_id')->constrained(); $table->decimal('amount', 12, 2); $table->string('phone'); $table->string('state')->default('pending'); // pending | paid | failed $table->string('failure_code')->nullable(); // 1032 $table->string('failure_reason')->nullable(); // Request cancelled by user $table->timestamps();});
Schema::create('payments', function (Blueprint $table) { $table->id(); $table->string('receipt')->unique(); // NLJ7RT61SV $table->string('checkout_request_id')->nullable()->index(); $table->decimal('amount', 12, 2); $table->string('msisdn')->nullable(); $table->timestamp('paid_at'); $table->timestamps();});An attempt is a prompt you sent. A payment is money that arrived. They are not the same thing and most of the trouble in M-Pesa integrations comes from treating them as one.
Attempts outnumber payments — people cancel, phones are off, PINs are mistyped. And a payment can exist with no attempt at all, if a customer pays your Pay Bill directly rather than through your checkout.
The unique index on receipt is what makes the whole thing safe to retry.
M-Pesa’s receipt number is the only identifier both you and Safaricom agree on.
Send the prompt
Section titled “Send the prompt”use Starnerz\LaravelDaraja\Facades\Daraja;
$response = Daraja::stk()->push( phone: $order->phone, amount: $order->total, accountReference: $order->reference, // 12 characters, shown to the customer description: 'Order '.$order->id, // 13 characters);
PaymentAttempt::create([ 'checkout_request_id' => $response->checkoutRequestId, 'order_id' => $order->id, 'amount' => $order->total, 'phone' => $order->phone,]);Any Kenyan format works for the phone number — 0712345678,
+254712345678, 254712345678, even with spaces. An invalid number raises a
DarajaException before anything is sent.
Three limits worth knowing before your first rejected request:
| Field | Limit |
|---|---|
accountReference |
12 characters — the customer sees this in the prompt |
description |
13 characters |
amount |
KES 1 to 250,000, whole shillings |
For a till rather than a Pay Bill, the credit party is a different number from the short code:
use Starnerz\LaravelDaraja\Enums\TransactionType;
Daraja::stk()->push( phone: $order->phone, amount: $order->total, accountReference: $order->reference, type: TransactionType::BuyGoods, partyB: '5678901', // the till number);Receive the callback
Section titled “Receive the callback”The outcome arrives at your callback URL seconds or minutes later, as an event.
use Illuminate\Contracts\Queue\ShouldQueue;use Starnerz\LaravelDaraja\Events\StkCallbackReceived;
class RecordPayment implements ShouldQueue{ public function handle(StkCallbackReceived $event): void { $callback = $event->callback;
$attempt = PaymentAttempt::firstWhere( 'checkout_request_id', $callback->checkoutRequestId, );
if (! $callback->successful()) { $attempt?->update([ 'state' => 'failed', 'failure_code' => $callback->resultCode, 'failure_reason' => $callback->resultDescription, ]);
return; }
$payment = Payment::updateOrCreate( ['receipt' => $callback->receipt()], [ 'checkout_request_id' => $callback->checkoutRequestId, 'amount' => $callback->amount(), 'msisdn' => $callback->phoneNumber(), 'paid_at' => $callback->transactionDate(), ], );
$attempt?->update(['state' => 'paid']);
$order = $attempt?->order; $order?->markPaid($payment); }}Register it wherever you keep listeners:
// bootstrap/app.php, or a service providerEvent::listen(RecordPayment::class);Three decisions in that listener are load bearing:
- It is queued. Safaricom does not wait for you, and slow endpoints get
spike arrested. The package’s route answers
200immediately and your work happens after. updateOrCreateon the receipt. Safaricom can deliver the same callback twice, and you will replay callbacks yourself while fixing things. The unique index turns a second delivery into an update instead of an exception.- The failure branch writes the code and the words.
1032and1037are different conversations with a customer, and a null column tells you neither. The code is what you count and branch on; the description is what makes the row readable six months later without anyone consulting a table.
| Result code | What happened |
|---|---|
0 |
Paid |
1032 |
Cancelled by the customer, or timed out on the handset |
1037 |
The prompt never reached the phone |
1031 |
No PIN entered in time |
2001 |
Wrong M-Pesa PIN |
1001 |
Another session is already open for that number |
Three strings, not two
Section titled “Three strings, not two”A failed payment produces three different pieces of text, and they have three different audiences. Keeping them apart saves an argument later.
| Comes from | Read by | |
|---|---|---|
1032 |
Safaricom, stable | your code — branch and count on this |
Request cancelled by user |
Safaricom, wording varies | you, reading the row months later |
| “You cancelled the payment.” | you, derived from the code | the customer |
Store the first two. Derive the third:
public function failureMessage(): string{ return match ($this->failure_code) { '1032' => 'You cancelled the payment. Try again when you are ready.', '1037', '1031' => 'The prompt timed out. Check your phone has signal and try again.', '2001' => 'That PIN was not accepted. Try again.', '1001' => 'There is already a payment in progress on that number. Wait a minute.', default => 'That payment did not go through. Try again, or use another number.', };}The package ships no enum for these codes — the full list is in
Error codes, and a match like the one above is
the whole of what most applications need.
Tell the customer what happened
Section titled “Tell the customer what happened”The prompt is asynchronous and the browser is not, so the page has to wait for something that arrives elsewhere. Polling the attempt is the simplest honest answer:
Route::get('/checkout/{attempt}/state', function (PaymentAttempt $attempt) { return ['state' => $attempt->state];});const poll = setInterval(async () => { const { state } = await fetch(`/checkout/${attemptId}/state`).then((r) => r.json());
if (state !== 'pending') { clearInterval(poll); window.location.reload(); }}, 3000);Stop polling after about ninety seconds and show the customer something useful rather than a spinner that never resolves. If you would rather not write the JavaScript, M-Pesa checkout with Livewire does the same thing in a component.
When a callback never arrives at all, ask Safaricom directly:
$status = Daraja::stk()->query($attempt->checkout_request_id);
$status->paid();$status->cancelledByUser();$status->resultDescription;Use that as a fallback, not as a polling loop against Safaricom.
Test it without a phone
Section titled “Test it without a phone”The package is built on Laravel’s HTTP client, so Http::fake() drives the
whole thing. No sandbox credentials, no network, no handset.
it('pushes a prompt and records the attempt', function () { Http::fake([ '*/oauth/*' => Http::response(['access_token' => 'test', 'expires_in' => '3599']), '*/mpesa/stkpush/*' => Http::response([ 'MerchantRequestID' => '29115-34620561-1', 'CheckoutRequestID' => 'ws_CO_191220191020363925', 'ResponseCode' => '0', 'ResponseDescription' => 'Success', 'CustomerMessage' => 'Success', ]), ]);
$order = Order::factory()->create(['total' => 1500]);
$this->post("/orders/{$order->id}/pay")->assertOk();
expect($order->attempts()->sole()->checkout_request_id) ->toBe('ws_CO_191220191020363925');});Always fake the OAuth endpoint too — the package fetches a token before the first call.
Then test the half that actually decides whether anybody gets their goods, by posting a real callback payload at the route:
it('records the payment when the callback says it succeeded', function () { $attempt = PaymentAttempt::factory()->create([ 'checkout_request_id' => 'ws_CO_191220191020363925', ]);
$this->postJson('/daraja/stk', [ 'Body' => ['stkCallback' => [ 'MerchantRequestID' => '29115-34620561-1', 'CheckoutRequestID' => 'ws_CO_191220191020363925', 'ResultCode' => 0, 'ResultDesc' => 'The service request is processed successfully.', 'CallbackMetadata' => ['Item' => [ ['Name' => 'Amount', 'Value' => 1500.0], ['Name' => 'MpesaReceiptNumber', 'Value' => 'NLJ7RT61SV'], ['Name' => 'PhoneNumber', 'Value' => 254712345678], ]], ]], ])->assertOk();
expect($attempt->fresh()->state)->toBe('paid') ->and(Payment::firstWhere('receipt', 'NLJ7RT61SV'))->not->toBeNull();});Test the cancelled payload as well. It has no CallbackMetadata at all,
which is where handlers that assume the key exists fall over:
'Body' => ['stkCallback' => [ 'MerchantRequestID' => '29115-34620561-1', 'CheckoutRequestID' => 'ws_CO_191220191020363925', 'ResultCode' => 1032, 'ResultDesc' => 'Request cancelled by user',]],Testing covers the rest, including the trap that
Http::fake() merges rather than replaces and the first matching stub wins.
What breaks in production
Section titled “What breaks in production”In roughly the order it happens to people:
- A callback URL Safaricom cannot reach. Accepted at push time, never called. Looks exactly like a customer who did not pay.
- Marking the order paid on
accepted(). Ships goods for prompts nobody ever confirmed. - A handler that throws. Safaricom delivers once. The customer paid and got nothing, and the only record is in your logs.
- The prompt that concludes neither way. No callback, no failure — query it, or it sits pending forever and every count you have is slightly wrong.
- Sandbox credentials still in the production
.env. Every push is accepted and no money ever moves.
The first four are the same problem wearing different clothes: the callback is the only thing that knows what happened, and it is the least reliable part of the system.
Going live
Section titled “Going live”Switching DARAJA_MODE to live changes the API host and the certificate used
to encrypt security credentials. It does not change your consumer key and
secret, which are per-environment and have to be set separately.
Production callback URLs must be HTTPS, and there is a list of words Safaricom rejects in them. Going live is the checklist.
You now have a working integration and one unanswered question: how do you know your database matches Safaricom’s?
That is Reconciling payments, and it is the page that stops a quiet failure becoming a wrong set of books.
Laravel Daraja is free and open source, maintained by eazysoft.
An independent open-source project, not affiliated with, endorsed by, or sponsored by Safaricom PLC or Laravel LLC. M-Pesa and Daraja are trademarks of Safaricom PLC; Laravel is a trademark of Laravel LLC. Used here only to say what this package works with.