M-Pesa Daraja in production: a practical integration guide

What the Daraja documentation leaves to you: choosing STK push or C2B, callbacks you cannot trust, duplicate receipts, the payment that is taken but never confirmed, go-live, and reconciling against the statement.

Safaricom’s Daraja documentation tells you what to send and what comes back. It tells you much less about what to do when the answer never arrives, arrives twice, or describes money you cannot place. Those cases are where an integration earns or loses the trust of whoever reads the books, so this guide is mostly about them.

Every Daraja behaviour described here links to Safaricom’s own documentation, read on 8 October 2026. Where the documentation is silent we say so, and we design around the gap rather than guess at it. The code is Laravel, written for Laravel 11 and later on PHP 8.3, and each piece below ran against Safaricom’s documented sample payloads in a test suite before it went into this post.

Choose the flow before you write code

Daraja offers two ways to take a customer payment, and they answer different questions.

STK push: you ask, the customer approves

M-Pesa Express, better known as STK push, is initiated by you. You send the amount, your shortcode and a reference, a prompt appears on the customer’s phone, and they enter their PIN. You know what the payment is for before any money moves, because you said so in the request.

The constraints that matter in practice, all from Safaricom’s page for the API:

  • AccountReference is shown to the customer and is limited to 12 characters. TransactionDesc is limited to 13.
  • The response to your request is an acknowledgement, not a payment. The result comes later, to your callback URL.
  • A customer can only have one prompt in progress. A second request while one is open fails with “Unable to lock subscriber”, and the documented advice is to wait at least a minute.
  • Result code 17 means you sent the same amount to the same customer within two minutes, and the request was refused.

STK push suits anything with a checkout: an online order, an invoice paid from a link, a subscription renewal the customer starts.

C2B: the customer pays, you are told afterwards

Customer to Business covers payments the customer starts themselves, from the M-Pesa menu, the app or USSD, to your Paybill or Till. You register a confirmation URL once, and M-Pesa posts each completed payment to it.

You do not know a C2B payment is coming. You find out after the money has moved, and what you learn is what the customer typed. For a Paybill, the BillRefNumber is the account number the customer entered, up to 20 characters. For a Till, there is no account number at all.

C2B suits recurring obligations people pay on their own schedule: rent, school fees, invoices, utility bills, anything where the payer never visits your website.

Most real businesses need both

STK push C2B
Who starts it You The customer
You know the purpose Before payment Only from what they typed
Customer types Their PIN Paybill, account number, amount, PIN
Fits Checkouts, payment links Rent, fees, invoices paid in their own time

Whichever doors you open, the payment’s identity is the same: the STK callback calls it MpesaReceiptNumber and the C2B confirmation calls it TransID, and both are documented as the unique M-Pesa transaction ID, the same code the customer receives by SMS. Key everything on it. A payment that reaches you through two doors is still one payment.

The callback contract, and why you cannot trust delivery

Here is what the documentation promises. After an STK push is processed, the result is posted to the CallBackURL you supplied. After a C2B payment completes, M-Pesa posts it to your registered confirmation URL.

Here is what it does not promise: that a callback arrives exactly once, that it arrives at all, or that it arrives before something else tells you about the same payment. Safaricom’s own guidance for failed notifications is to fall back to the Pull Transaction API or the M-Pesa organisation portal, which, in the words of the Register URL documentation, “has all the payments ever made available, whether you received the callback or not.”

Read that as the design brief. A callback is a notification. The statement is the record. Four rules follow from it:

  1. Store the raw callback before doing anything else with it. If processing fails, you still have what Safaricom sent.
  2. Answer quickly and do the work in a queue. Your HTTP handler should write a row and return.
  3. Make processing idempotent. The same callback processed twice must change nothing the second time.
  4. Keep a second path to the truth. For STK push, that is the status query. For everything, it is the statement.

Callbacks carry no signature

The documentation describes no signature, token or other authentication on callbacks. Anyone who learns your callback URL can post to it. Three things reduce that risk:

  • A secret in the path. Generate a long random string, put it in the URL you register, and return 404 for anything else. It is only as secret as your logs, so keep request paths out of anything shared.
  • An allowlist of Safaricom’s source addresses, at the firewall or the reverse proxy. We found no list in the public API documentation, so ask Safaricom’s API support for the current one rather than copying a list from a forum.
  • Callbacks only ever complete something you started. An STK result is applied only to a request your system created, for the amount it asked for. A forged callback for a checkout ID you never issued changes nothing.

The URL rules that fail registration

From the same Register URL page: production URLs must be HTTPS, public URL testers such as ngrok are not allowed and are usually blocked, and URLs must avoid the words M-PESA, Safaricom, exe, exec, cmd, SQL and query, in any case. That last list catches more than it looks like it should. /payments/query fails, and so does any path containing mpesa.

These are the routes the rest of this guide uses, in routes/api.php so Laravel’s CSRF protection does not apply:

php
use App\Payments\Mpesa\Http\DarajaCallbackController;

Route::post('/hooks/daraja/{secret}/stk', [DarajaCallbackController::class, 'stk']);
Route::post('/hooks/daraja/{secret}/confirm', [DarajaCallbackController::class, 'confirm']);
Route::post('/hooks/daraja/{secret}/validate', [DarajaCallbackController::class, 'validate']);

And the controller behind them. It stores, dispatches and answers, and nothing else:

app/Payments/Mpesa/Http/DarajaCallbackController.php

php
<?php

namespace App\Payments\Mpesa\Http;

use App\Payments\Mpesa\Jobs\ProcessC2bConfirmation;
use App\Payments\Mpesa\Jobs\ProcessStkResult;
use App\Payments\Mpesa\MpesaCallback;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class DarajaCallbackController
{
    public function stk(Request $request, string $secret): JsonResponse
    {
        $this->guard($secret);

        // Store first, process later. A crash after this line loses nothing.
        $callback = MpesaCallback::create(['kind' => 'stk', 'payload' => $request->all()]);
        ProcessStkResult::dispatch($callback->id);

        return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
    }

    public function confirm(Request $request, string $secret): JsonResponse
    {
        $this->guard($secret);

        $callback = MpesaCallback::create(['kind' => 'confirmation', 'payload' => $request->all()]);
        ProcessC2bConfirmation::dispatch($callback->id);

        return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Success']);
    }

    public function validate(Request $request, string $secret): JsonResponse
    {
        $this->guard($secret);

        MpesaCallback::create(['kind' => 'validation', 'payload' => $request->all()]);

        // Answer in well under the ~8 seconds M-Pesa waits. Reject only what
        // can never be right; anything merely unrecognised is accepted and
        // goes to the suspense queue after confirmation.
        $amount = (string) $request->input('TransAmount');
        if (! preg_match('/^\d+(\.0{1,2})?$/', $amount) || (int) $amount < 1) {
            return response()->json(['ResultCode' => 'C2B00013', 'ResultDesc' => 'Rejected']);
        }

        return response()->json(['ResultCode' => '0', 'ResultDesc' => 'Accepted']);
    }

    private function guard(string $secret): void
    {
        abort_unless(hash_equals((string) config('mpesa.callback_secret'), $secret), 404);
    }
}

The tables behind it are deliberately plain. mpesa_callbacks is an inbox that is written once per delivery. payment_requests holds one row per STK push you started. mpesa_transactions holds one row per M-Pesa receipt, however you learned about it, and the unique index on receipt is the most important line in the schema.

database/migrations/create_mpesa_tables.php

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Every callback, exactly as it arrived. Written before anything else
        // happens, never updated except to mark it processed.
        Schema::create('mpesa_callbacks', function (Blueprint $table) {
            $table->id();
            $table->string('kind');                 // stk, validation, confirmation
            $table->json('payload');
            $table->timestamp('processed_at')->nullable();
            $table->string('note')->nullable();
            $table->timestamps();
        });

        // One row per STK push we started.
        Schema::create('payment_requests', function (Blueprint $table) {
            $table->id();
            $table->string('checkout_request_id')->nullable()->unique();
            $table->string('merchant_request_id')->nullable();
            $table->string('phone');
            $table->unsignedBigInteger('amount_cents');
            $table->string('account_reference', 12);
            $table->string('status')->default('pending');
            $table->string('result_code')->nullable();
            $table->string('mpesa_receipt')->nullable();
            $table->unsignedSmallInteger('status_checks')->default(0);
            $table->timestamps();
        });

        // One row per M-Pesa receipt, however we learned about it.
        Schema::create('mpesa_transactions', function (Blueprint $table) {
            $table->id();
            $table->string('receipt')->unique();
            $table->unsignedBigInteger('amount_cents');
            $table->timestamp('paid_at')->nullable();
            $table->string('bill_ref')->nullable();
            $table->string('payer_name')->nullable();
            $table->string('msisdn')->nullable();
            $table->string('source');               // stk, c2b, statement
            $table->foreignId('account_id')->nullable();
            $table->string('match_status')->default('unmatched');
            $table->timestamps();
        });
    }
};

Authenticating, and the token that invalidates itself

Every call needs a bearer token from the Authorization API: a GET to /oauth/v1/generate?grant_type=client_credentials with your consumer key and secret as Basic auth. The token lasts an hour.

One line in that page’s FAQ changes how you build this: you can generate multiple tokens, “but each request invalidates the previous token.” Run two queue workers that each fetch their own token and they will keep breaking each other’s requests with “Invalid Access Token” errors that look random. So the token is minted under a lock and shared through the cache:

app/Payments/Mpesa/DarajaClient.php

php
<?php

namespace App\Payments\Mpesa;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

final class DarajaClient
{
    public function token(): string
    {
        // Minting a token invalidates the previous one, so two workers minting
        // at once would each break the other's. One mints; the rest wait.
        return Cache::get('mpesa.token')
            ?? Cache::lock('mpesa.token.mint', 15)->block(10, fn () => Cache::get('mpesa.token') ?? $this->mint());
    }

    public function forgetToken(): void
    {
        Cache::forget('mpesa.token');
    }

    private function mint(): string
    {
        $response = Http::withBasicAuth(config('mpesa.consumer_key'), config('mpesa.consumer_secret'))
            ->timeout(10)
            ->get(config('mpesa.base_url').'/oauth/v1/generate', ['grant_type' => 'client_credentials'])
            ->throw()
            ->json();

        // Refresh well before expiry rather than discovering it mid-request.
        Cache::put('mpesa.token', $response['access_token'], max(60, (int) $response['expires_in'] - 300));

        return $response['access_token'];
    }

    public function http(): PendingRequest
    {
        return Http::baseUrl(config('mpesa.base_url'))
            ->withToken($this->token())
            ->acceptJson()
            ->timeout(30);
    }

    /**
     * Ask for an STK push. Returns the acknowledgement, which says the request
     * was accepted, not that anybody paid.
     */
    public function stkPush(string $phone, int $amountShillings, string $reference, string $description): array
    {
        $shortcode = config('mpesa.shortcode');
        $timestamp = now('Africa/Nairobi')->format('YmdHis');

        return $this->http()->post('/mpesa/stkpush/v1/processrequest', [
            'BusinessShortCode' => $shortcode,
            // The same timestamp goes in the password and in the body, or the
            // request fails with "Wrong credentials".
            'Password' => base64_encode($shortcode.config('mpesa.passkey').$timestamp),
            'Timestamp' => $timestamp,
            'TransactionType' => 'CustomerPayBillOnline',
            'Amount' => $amountShillings,
            'PartyA' => $phone,
            'PartyB' => $shortcode,
            'PhoneNumber' => $phone,
            'CallBackURL' => url('/api/hooks/daraja/'.config('mpesa.callback_secret').'/stk'),
            'AccountReference' => mb_substr($reference, 0, 12),
            'TransactionDesc' => mb_substr($description, 0, 13),
        ])->throw()->json();
    }

    /** The documented status check for one STK push. */
    public function stkStatus(string $checkoutRequestId): array
    {
        $shortcode = config('mpesa.shortcode');
        $timestamp = now('Africa/Nairobi')->format('YmdHis');

        return $this->http()->post('/mpesa/stkpushquery/v1/query', [
            'BusinessShortCode' => $shortcode,
            'Password' => base64_encode($shortcode.config('mpesa.passkey').$timestamp),
            'Timestamp' => $timestamp,
            'CheckoutRequestID' => $checkoutRequestId,
        ])->throw()->json();
    }

    public function registerC2bUrls(string $responseType = 'Completed'): array
    {
        $base = url('/api/hooks/daraja/'.config('mpesa.callback_secret'));

        return $this->http()->post('/mpesa/c2b/v2/registerurl', [
            'ShortCode' => config('mpesa.shortcode'),
            'ResponseType' => $responseType,   // exactly "Completed" or "Cancelled"
            'ConfirmationURL' => $base.'/confirm',
            'ValidationURL' => $base.'/validate',
        ])->throw()->json();
    }
}

The configuration it reads:

config/mpesa.php

php
<?php

return [
    'base_url' => env('MPESA_ENV', 'sandbox') === 'production'
        ? 'https://api.safaricom.co.ke'
        : 'https://sandbox.safaricom.co.ke',

    'consumer_key' => env('MPESA_CONSUMER_KEY'),
    'consumer_secret' => env('MPESA_CONSUMER_SECRET'),
    'shortcode' => env('MPESA_SHORTCODE'),
    'passkey' => env('MPESA_PASSKEY'),

    // A long random string. It goes in the callback path, so only Safaricom,
    // who received it at registration, knows the URL.
    'callback_secret' => env('MPESA_CALLBACK_SECRET'),
];

Two details in stkPush() come straight from the documented error table. The Password is base64(Shortcode + Passkey + Timestamp), and the timestamp inside it must be the same one sent in the body, or the request fails with “Wrong credentials”. And for a Till rather than a Paybill, TransactionType is CustomerBuyGoodsOnline, BusinessShortCode is the shortcode used at go-live and PartyB is the Till number. Getting that pairing wrong returns result code 2028, “not permitted according to product assignment”.

The timestamp is generated in East Africa Time here. The documentation gives the format, YYYYMMDDHHmmss, and not the time zone.

Idempotency: the receipt number is the key

Duplicates have more sources than Safaricom. A reverse proxy retries a request your app was slow to answer. A queue worker crashes after committing but before acknowledging the job, and the job runs again. A payment reaches you through an STK callback and again through the statement. Each of these delivers the same fact twice, and none of them can be prevented. They can only be absorbed.

Three mechanisms absorb them here:

  • A unique index on the receipt, with createOrFirst(), which inserts or, if another process got there first, returns the existing row. Two jobs racing on the same receipt produce one transaction.
  • A row lock on the payment request while its status changes, so a callback and a status check cannot both settle it.
  • A one-way state machine. Only a request that is still open (pending, unknown, or paid but without a receipt yet) can change. A second callback for a settled request is recorded and ignored.

app/Payments/Mpesa/StkOutcome.php

php
<?php

namespace App\Payments\Mpesa;

final class StkOutcome
{
    /** Documented result codes worth telling apart. Any other non-zero code is "failed". */
    private const NAMED = [
        0 => 'paid',
        1 => 'insufficient_funds',
        1019 => 'expired',
        1032 => 'cancelled',
        1037 => 'unreachable',
        2001 => 'wrong_pin',
    ];

    public static function fromCode(int $code): string
    {
        return self::NAMED[$code] ?? 'failed';
    }

    /** States a later answer is still allowed to change. */
    public const OPEN = ['pending', 'unknown', 'paid_unconfirmed'];
}

app/Payments/Mpesa/Jobs/ProcessStkResult.php

php
<?php

namespace App\Payments\Mpesa\Jobs;

use App\Payments\Mpesa\Money;
use App\Payments\Mpesa\MpesaCallback;
use App\Payments\Mpesa\MpesaTransaction;
use App\Payments\Mpesa\PaymentRequest;
use App\Payments\Mpesa\StkOutcome;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

final class ProcessStkResult implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $callbackId) {}

    public function handle(): void
    {
        $callback = MpesaCallback::findOrFail($this->callbackId);
        $result = data_get($callback->payload, 'Body.stkCallback');

        if (! is_array($result) || ! isset($result['CheckoutRequestID'], $result['ResultCode'])) {
            $callback->update(['processed_at' => now(), 'note' => 'unrecognised shape']);

            return;
        }

        DB::transaction(function () use ($callback, $result) {
            $request = PaymentRequest::where('checkout_request_id', $result['CheckoutRequestID'])
                ->lockForUpdate()
                ->first();

            if (! $request) {
                // Not ours, or ours from before the request row was saved.
                // Keep it; the sweeper and reconciliation will look again.
                $callback->update(['note' => 'unknown checkout request']);

                return;
            }

            if (! in_array($request->status, StkOutcome::OPEN, true)) {
                // A repeat, or a late callback for a request already settled.
                // The first final answer stands.
                $callback->update(['processed_at' => now(), 'note' => 'already settled']);

                return;
            }

            $code = (int) $result['ResultCode'];

            if ($code !== 0 && $request->status === 'paid_unconfirmed') {
                // The status check said paid; this says not. A person decides.
                $callback->update(['note' => 'conflicts with status check']);

                return;
            }

            if ($code !== 0) {
                $request->update(['status' => StkOutcome::fromCode($code), 'result_code' => (string) $code]);
                $callback->update(['processed_at' => now()]);

                return;
            }

            // CallbackMetadata exists only on success, and an Item may carry no Value.
            $meta = collect(data_get($result, 'CallbackMetadata.Item', []))->pluck('Value', 'Name');
            $receipt = (string) $meta->get('MpesaReceiptNumber');
            $paid = Money::toCents($meta->get('Amount', 0));

            MpesaTransaction::createOrFirst(['receipt' => $receipt], [
                'amount_cents' => $paid,
                'paid_at' => $meta->get('TransactionDate')
                    ? Carbon::createFromFormat('YmdHis', (string) $meta->get('TransactionDate'), 'Africa/Nairobi')->utc()
                    : null,
                'msisdn' => (string) $meta->get('PhoneNumber'),
                'bill_ref' => $request->account_reference,
                'source' => 'stk',
            ]);

            $request->update([
                'status' => $paid === (int) $request->amount_cents ? 'paid' : 'paid_amount_mismatch',
                'result_code' => '0',
                'mpesa_receipt' => $receipt,
            ]);
            $callback->update(['processed_at' => now()]);
        });
    }
}

Parse the payload loosely, because the documentation does

Safaricom’s own samples disagree about shapes. The STK callback sample sends Amount as the number 1.00 and PhoneNumber as a number. One C2B sample sends TransAmount as "10" and the masked MSISDN as "25470****149"; another sends "5.00" and "2547 ***** 126". Every item in CallbackMetadata is marked optional, and a failed STK result has no CallbackMetadata at all, so code that reads the receipt before checking ResultCode throws on every cancelled payment.

So amounts are parsed from their text, into integer cents, and never through a float:

app/Payments/Mpesa/Money.php

php
<?php

namespace App\Payments\Mpesa;

final class Money
{
    /**
     * Daraja's amounts arrive as "10", "5.00", 1.00 or 10500 depending on the
     * API and the sample you read. Parse the string, never a float.
     */
    public static function toCents(string|int|float $amount): int
    {
        $text = is_float($amount) ? number_format($amount, 2, '.', '') : trim((string) $amount);

        if (! preg_match('/^(\d+)(?:\.(\d{1,2}))?$/', $text, $m)) {
            throw new \InvalidArgumentException("Not an amount: {$text}");
        }

        return (int) $m[1] * 100 + (int) str_pad($m[2] ?? '0', 2, '0');
    }
}

Store times as UTC

Laravel’s datetime cast writes a Carbon’s wall-clock reading, not the instant it represents. A TransTime parsed in Nairobi time and saved as is lands three hours off in a UTC database, and every report that groups payments by day quietly moves the late-evening ones into tomorrow. Convert with ->utc() before saving, as the jobs here do, and convert query windows the same way.

Timeouts: the payment that is taken but never confirmed

The hardest STK case is silence. You sent the push, the acknowledgement said the request was accepted, and no callback has come. The customer may have paid, cancelled, ignored the prompt, or never received it.

The documented result codes tell you what the possible endings are, when you get one:

Result code Meaning, per Safaricom
0 Processed successfully
1 Insufficient balance
17 Same amount to the same customer within two minutes
1019 Transaction expired before it was processed
1032 Cancelled by the user
1037 The customer’s phone could not be reached
2001 Wrong PIN

When the callback does not come, ask. The STK status query takes the CheckoutRequestID and returns a ResultCode and ResultDesc. Notice what its documented response does not contain: the receipt number. A query can tell you the customer paid without telling you which M-Pesa transaction it was. That state needs a name of its own, paid_unconfirmed here, and the receipt is filled in later by the callback if it turns up, or from the statement if it does not.

app/Payments/Mpesa/CheckPendingStkPushes.php

php
<?php

namespace App\Payments\Mpesa;

use Illuminate\Console\Command;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\DB;

/**
 * Asks about every STK push that has waited too long for its callback.
 * Scheduled every minute: Schedule::command('mpesa:check-pending')->everyMinute()
 */
final class CheckPendingStkPushes extends Command
{
    protected $signature = 'mpesa:check-pending';

    protected $description = 'Check the status of STK pushes whose callback has not arrived';

    public function handle(DarajaClient $daraja): void
    {
        PaymentRequest::where('status', 'pending')
            ->whereNotNull('checkout_request_id')
            ->where('updated_at', '<', now()->subMinutes(2))
            ->each(function (PaymentRequest $request) use ($daraja) {
                try {
                    $answer = $daraja->stkStatus($request->checkout_request_id);
                } catch (RequestException) {
                    // An error from the status check says nothing about the
                    // payment. Ask again next run; after ten tries, stop
                    // guessing and leave it for reconciliation.
                    $request->increment('status_checks');
                    if ($request->status_checks >= 10) {
                        $request->update(['status' => 'unknown']);
                    }

                    return;
                }

                if (! isset($answer['ResultCode'])) {
                    $request->increment('status_checks');

                    return;
                }

                DB::transaction(function () use ($request, $answer) {
                    $fresh = PaymentRequest::lockForUpdate()->find($request->id);
                    if ($fresh->status !== 'pending') {
                        return;   // the callback won the race
                    }

                    $code = (int) $answer['ResultCode'];

                    // Paid, but the documented response carries no receipt
                    // number. The callback or the statement will supply it.
                    $fresh->update([
                        'status' => $code === 0 ? 'paid_unconfirmed' : StkOutcome::fromCode($code),
                        'result_code' => (string) $code,
                    ]);
                });
            });
    }
}

Register it in routes/console.php with Schedule::command('mpesa:check-pending')->everyMinute(); and run the scheduler and a queue worker in production. Without a worker, every callback is stored and none is processed, which looks exactly like Safaricom not sending them.

Three rules sit around this code:

  • An error from the status check is not an answer. A 500 from the query endpoint says nothing about the payment. Keep the request pending and ask again.
  • Never tell the customer a timed-out payment failed. Tell them you are checking. “Failed” followed by an SMS from M-Pesa saying they paid is how support tickets start.
  • Never send a second push automatically while one is pending. It will be refused while the first prompt holds the subscriber, refused again by the two-minute rule, and if it does get through you may take the money twice. Let the customer retry, and only after the first request has a final status.

For a receipt you already hold, the Transaction Status API answers asynchronously to a result URL and needs an initiator name and an encrypted SecurityCredential, a different credential set from the STK passkey. Plan for it at go-live rather than discovering it during an incident.

Validation and confirmation URLs

C2B URLs are registered once, with the request in registerC2bUrls() above. Safaricom’s current C2B page documents the /mpesa/c2b/v2/registerurl endpoint, and the older Register URL page still shows v1.

ResponseType decides what happens when M-Pesa cannot reach your validation URL in time: Completed takes the payment anyway, Cancelled refuses it. It must be spelled exactly that way, in sentence case. Both choices have a cost. Cancelled means your outage stops customers paying. Completed means some payments arrive that your validation never saw, so the confirmation path has to cope with anything. We choose Completed, because it does have to cope with anything regardless.

The failure modes worth knowing before you go live:

  • Validation is off unless you ask for it. External validation must be enabled by emailing Safaricom (apisupport@safaricom.co.ke or M-pesabusiness@safaricom.co.ke). Until then your validation URL is never called, and validation code that has never received a request has never been tested either.
  • Registration in production is one-time. In the sandbox you can register as often as you like. In production, changing a URL means deleting it under URL management in self-service, or asking API support, and registering again.
  • The validation answer has a deadline. The Register URL page puts it at usually under eight seconds from the moment the request leaves M-Pesa. A validation handler that queries a slow database, calls another API, or waits on a lock will miss it, and then ResponseType decides for you.

A validation response accepts with ResultCode 0, or rejects with one of C2B00011 to C2B00016, which determine the message the customer sees. Use validation only to reject what can never be right for your business. The controller above has a single defensive rule, on the amount, since the documentation says only whole shillings are supported. An account number you do not recognise does not belong in that category. It may be a typo for a real account, and bouncing it costs the customer a failed payment and a confused retry. Accept it and resolve it after confirmation, which is what validate() in the controller does.

The shape of real payment data

What a C2B confirmation actually tells you, per the documented fields:

  • BillRefNumber is whatever the customer typed, up to 20 characters, on Paybill payments only. Till payments have none.
  • MSISDN is masked. The documentation describes it as “a masked mobile number”, and both samples show it with digits replaced by asterisks. You cannot match a payment to a customer by phone number from the callback.
  • FirstName, MiddleName and LastName are the M-Pesa registration names, and each can be empty.
  • TransAmount is whole shillings, delivered as text.

Design for the cases that follow from those fields rather than hoping they are rare. A reference typed with spaces, dashes or lower case. A reference that is wrong by one character. No reference at all. A payment from someone other than the account holder, with a different name and a masked number. An amount that is part of what is owed, or more than it. Two payments that together cover one invoice.

The first step is cheap and safe: normalise the reference before comparing it, by removing everything but letters and digits and upper-casing what remains. Then allocate only on an exact match, and send everything else to a suspense queue that a person resolves:

app/Payments/Mpesa/Jobs/ProcessC2bConfirmation.php

php
<?php

namespace App\Payments\Mpesa\Jobs;

use App\Payments\Mpesa\Money;
use App\Payments\Mpesa\MpesaCallback;
use App\Payments\Mpesa\MpesaTransaction;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;

final class ProcessC2bConfirmation implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $callbackId) {}

    public function handle(): void
    {
        $callback = MpesaCallback::findOrFail($this->callbackId);
        $p = $callback->payload;

        $transaction = MpesaTransaction::createOrFirst(['receipt' => (string) $p['TransID']], [
            'amount_cents' => Money::toCents($p['TransAmount']),
            // Parsed as Nairobi time, stored as UTC: the datetime cast writes
            // the wall-clock reading, so a Nairobi Carbon lands three hours off.
            'paid_at' => Carbon::createFromFormat('YmdHis', (string) $p['TransTime'], 'Africa/Nairobi')->utc(),
            'bill_ref' => self::normaliseReference($p['BillRefNumber'] ?? ''),
            'payer_name' => trim(implode(' ', array_filter([
                $p['FirstName'] ?? null, $p['MiddleName'] ?? null, $p['LastName'] ?? null,
            ]))) ?: null,
            'msisdn' => $p['MSISDN'] ?? null,   // masked: for display, never for matching
            'source' => 'c2b',
        ]);

        if ($transaction->wasRecentlyCreated) {
            AllocateMpesaTransaction::dispatch($transaction->id);
        }

        $callback->update(['processed_at' => now()]);
    }

    /** What the customer typed, made comparable. Never treated as trusted. */
    public static function normaliseReference(?string $reference): ?string
    {
        $clean = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string) $reference));

        return $clean === '' ? null : $clean;
    }
}

app/Payments/Mpesa/Jobs/AllocateMpesaTransaction.php

php
<?php

namespace App\Payments\Mpesa\Jobs;

use App\Payments\Mpesa\MpesaTransaction;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;

final class AllocateMpesaTransaction implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $transactionId) {}

    public function handle(): void
    {
        DB::transaction(function () {
            $transaction = MpesaTransaction::lockForUpdate()->findOrFail($this->transactionId);

            if ($transaction->match_status !== 'unmatched') {
                return;   // already handled
            }

            $accountId = $transaction->bill_ref
                ? DB::table('accounts')->where('reference', $transaction->bill_ref)->value('id')
                : null;

            // An exact match is allocated. Anything else waits for a person.
            $transaction->update($accountId
                ? ['account_id' => $accountId, 'match_status' => 'matched']
                : ['match_status' => 'suspense']);
        });
    }
}

It is tempting to make that matcher clever: fuzzy references, name similarity, amount heuristics. Every guess it makes is a payment credited to somebody, and a wrong credit is worse than a slow one, because it hides two errors behind one confident-looking record. Exact matching handles the clean cases. The suspense queue is the product for the rest. We wrote about that design separately in reconciliation failure modes.

Reconciling against the Paybill statement

Callbacks tell you what Safaricom managed to deliver. The statement in the M-Pesa organisation portal tells you what happened. Reconciliation is the daily comparison of the two, and it has exactly three outcomes:

  • On the statement, not in your records. A lost callback. Record it from the statement and allocate it like any other payment.
  • In both, with different amounts. Should never happen. Stop and look.
  • In your records, not on the statement. Also should never happen, and the most worrying of the three, because it may be a forged callback. Never fix it automatically.

app/Payments/Mpesa/StatementReconciler.php

php
<?php

namespace App\Payments\Mpesa;

use App\Payments\Mpesa\Jobs\AllocateMpesaTransaction;
use DateTimeInterface;
use Illuminate\Support\Carbon;

/**
 * Compares the M-Pesa statement with what the callbacks told us.
 *
 * The statement is the source of truth: it lists every payment to the
 * shortcode whether or not a callback reached us. Rows arrive here already
 * mapped from the org portal export into one shape.
 */
final class StatementReconciler
{
    /**
     * @param  iterable<array{receipt: string, amount_cents: int, completed_at: DateTimeInterface}>  $statement
     * @return array{recovered: list<string>, mismatched: list<string>, unexplained: list<string>}
     */
    public function reconcile(iterable $statement, DateTimeInterface $from, DateTimeInterface $to): array
    {
        $report = ['recovered' => [], 'mismatched' => [], 'unexplained' => []];
        $seen = [];

        foreach ($statement as $row) {
            $seen[$row['receipt']] = true;
            $ours = MpesaTransaction::firstWhere('receipt', $row['receipt']);

            if (! $ours) {
                // Money arrived and no callback told us. Record it from the
                // statement and send it through allocation like any other.
                $created = MpesaTransaction::createOrFirst(['receipt' => $row['receipt']], [
                    'amount_cents' => $row['amount_cents'],
                    'paid_at' => Carbon::instance($row['completed_at'])->utc(),
                    'source' => 'statement',
                ]);
                AllocateMpesaTransaction::dispatch($created->id);
                $report['recovered'][] = $row['receipt'];
            } elseif ((int) $ours->amount_cents !== $row['amount_cents']) {
                $report['mismatched'][] = $row['receipt'];
            }
        }

        // Ours, in the window, and not on the statement: never auto-fixed.
        // Bindings format the wall clock too, so the window goes in as UTC.
        $window = [Carbon::instance($from)->utc(), Carbon::instance($to)->utc()];
        MpesaTransaction::whereBetween('paid_at', $window)
            ->pluck('receipt')
            ->reject(fn (string $receipt) => isset($seen[$receipt]))
            ->each(function (string $receipt) use (&$report) {
                $report['unexplained'][] = $receipt;
            });

        return $report;
    }
}

The reconciler takes rows already mapped into one shape: receipt, amount in cents, completion time. Mapping the portal’s export into that shape is the only format-specific code, so keep it in one small class you can change when the export changes.

Getting the statement at all needs portal access. Per Safaricom’s go-live notes, the portal requires a Business Administrator created by Safaricom for your shortcode, who then creates the users who can view statements, such as a Business Manager. Request that access at the same time as you apply to go live, not after the first missing payment.

Any paid_unconfirmed STK request still waiting for a receipt is matched by a person against the recovered rows, by amount and time. It is the one place in this design where a human compares two records by eye, and it should be rare enough to stay that way.

Sandbox to production: what changes on go-live

The sandbox is for learning request shapes. Production differs in ways that are all documented, and all easy to miss:

  • New credentials. Going live moves your app to production with new consumer keys, and the production passkey arrives by email after go-live. Nothing from the sandbox carries over, including the test shortcode 174379.
  • Go-live needs a person with the right role. The form asks for the username of an operator with the Business Administrator or Business Manager role on the M-Pesa organisation portal, case-sensitive, and sends a one-time code to that user’s phone, which must be a Safaricom line.
  • No simulation. The C2B simulate endpoint does not exist in production. Real payments only.
  • URL registration stops being repeatable, as above.
  • HTTPS only, as above.
  • Rate limits are real. The documented errors include “Spike Arrest Violation” and “Quota Violation”. The limits themselves are not in the pages we read, so build your bulk operations to back off rather than to a number.

Testing when the sandbox will not misbehave for you

The sandbox will happily return a successful callback. There is no documented way to make it send one twice, send one late, lose one, or send one for a payment you did not request, which are exactly the cases that matter. So those cases are tested in your own suite, with Safaricom’s documented samples as fixtures and the HTTP client faked.

Each behaviour in this guide has a test. A few of them, where stkSuccess() returns Safaricom’s documented success sample and pendingRequest() creates the request it answers:

php
it('records a successful STK result once, however many times it arrives', function () {
    pendingRequest();

    $this->postJson('/api/hooks/daraja/a-long-random-string/stk', stkSuccess())->assertOk();
    $this->postJson('/api/hooks/daraja/a-long-random-string/stk', stkSuccess())->assertOk();

    expect(MpesaTransaction::count())->toBe(1)
        ->and(PaymentRequest::first()->status)->toBe('paid')
        ->and(MpesaCallback::count())->toBe(2);
});

it('settles a push whose callback never came, then takes the receipt from a late callback', function () {
    Http::fake([
        '*/oauth/v1/generate*' => Http::response(['access_token' => 'tok', 'expires_in' => '3599']),
        '*/stkpushquery/v1/query' => Http::response([
            'ResponseCode' => '0', 'CheckoutRequestID' => 'ws_CO_191220191020363925',
            'ResultCode' => '0', 'ResultDesc' => 'The service request is processed successfully.',
        ]),
    ]);
    $request = pendingRequest();
    $request->forceFill(['updated_at' => now()->subMinutes(5)])->saveQuietly();

    app(CheckPendingStkPushes::class)->handle(app(DarajaClient::class));
    expect($request->fresh()->status)->toBe('paid_unconfirmed');

    $this->postJson('/api/hooks/daraja/a-long-random-string/stk', stkSuccess())->assertOk();
    expect($request->fresh()->status)->toBe('paid')
        ->and($request->fresh()->mpesa_receipt)->toBe('NLJ7RT61SV');
});

it('treats an error from the status check as no answer at all', function () {
    Http::fake([
        '*/oauth/v1/generate*' => Http::response(['access_token' => 'tok', 'expires_in' => '3599']),
        '*/stkpushquery/v1/query' => Http::response(['errorCode' => '500.001.1001'], 500),
    ]);
    $request = pendingRequest();
    $request->forceFill(['updated_at' => now()->subMinutes(5)])->saveQuietly();

    app(CheckPendingStkPushes::class)->handle(app(DarajaClient::class));

    expect($request->fresh()->status)->toBe('pending')
        ->and($request->fresh()->status_checks)->toBe(1);
});

The rest cover a failed result with no metadata, a paid amount that differs from the request, a callback for a checkout ID nobody issued, a wrong secret, a masked C2B confirmation delivered twice, a reference typed in lower case with spaces, the validation answers, all three reconciliation outcomes, a reconciliation window that would have been three hours off, and every amount format the documentation shows.

Then, once live, a short smoke test with real money. The smallest C2B amount is one shilling, per the documented result codes. Pay once by STK push and once from the menu to each shortcode you support, cancel one prompt on purpose to see result code 1032 arrive, and check the next day’s statement against your records. That last step is also how you settle the time zone question above with evidence rather than assumption.

Before you go live

  • Production credentials and passkey in the environment, sandbox URLs gone from the code.
  • Callback URLs on HTTPS, free of the banned words, with a secret in the path.
  • Callback source addresses allowlisted, from Safaricom’s current list.
  • A queue worker and the scheduler running, and an alert if the callback inbox stops emptying.
  • A unique index on the receipt number, and a test that posts every callback twice.
  • The status-check sweeper scheduled, and a customer message for “we are checking”.
  • Validation enabled with Safaricom if you rely on it, and answering in well under eight seconds.
  • Portal access for statements, and a daily reconciliation with someone who reads its report.

Sources

Safaricom Daraja documentation, read on 8 October 2026: Authorization, M-Pesa Express, M-Pesa Express Query, Customer to Business, Customer to Business Register URL and Transaction Status.

If you would rather have this built and run for you, it is the kind of work our SaaS product development covers.

Related reading

Next step

Want this applied to your situation?

A post has to generalise. A 30 minute call does not. Tell us where you are and we will tell you what we would do first.

The first call is free and takes 30 minutes. We reply within one business day.