How to Integrate M-Pesa STK Push with PHP
    PHP
    M-Pesa
    Daraja API
    Payments
    Idempotency
    Kenya

    How to Integrate M-Pesa STK Push with PHP

    Getting the STK Push prompt to appear takes an afternoon. Making sure the right person is credited exactly once — even when the callback is duplicated, forged, or never arrives — takes considerably longer. A production guide in plain PHP.

    HOA

    Harrison Onyango Aloo

    Backend Software Engineer — Node.js · Python · Payment Integrations

    June 04, 2026
    14 min read

    M-Pesa is Kenya's dominant mobile money platform, and if you're building payments for a Kenyan audience, integrating it is non-negotiable. This guide walks through STK Push using Safaricom's Daraja API and plain PHP — no framework required.

    I built this exact integration for Shena Companion Welfare Association, a production welfare platform handling member contributions, claims and agent commissions. Most of what follows is the stuff I learned after the happy path already worked.

    Because that's the thing about mobile money: getting the payment prompt to appear on someone's phone takes an afternoon. Making sure the right person is credited exactly once, even when the network drops, takes considerably longer. Most tutorials stop at the afternoon. This one doesn't.


    What is STK Push?

    STK Push sends a payment prompt directly to a customer's phone. They enter their M-Pesa PIN and the payment processes — no paybill number to remember, no till number to type.

    The flow:

    1. Your server requests an access token from Daraja
    2. Your server sends an STK Push request
    3. Safaricom pushes a prompt to the customer's phone
    4. Customer enters their PIN
    5. Safaricom POSTs the result to your callback URL

    Step 5 is where nearly all real-world bugs live. Hold that thought.


    Prerequisites

    • A Safaricom Daraja account at developer.safaricom.co.ke
    • A Daraja app with Lipa na M-Pesa Online enabled
    • Your Consumer Key, Consumer Secret, Business Shortcode and Passkey
    • A publicly accessible HTTPS callback URL (use ngrok locally)

    Step 1: Configuration

    Never hardcode credentials, and never hardcode the environment. Switching to production should be a config change, not a find-and-replace across your codebase.

    <?php
    
    function mpesaConfig(): array
    {
        $isProduction = getenv('MPESA_ENV') === 'production';
    
        return [
            'base_url'   => $isProduction
                ? 'https://api.safaricom.co.ke'
                : 'https://sandbox.safaricom.co.ke',
            'key'        => getenv('MPESA_CONSUMER_KEY'),
            'secret'     => getenv('MPESA_CONSUMER_SECRET'),
            'shortcode'  => getenv('MPESA_SHORTCODE'),
            'passkey'    => getenv('MPESA_PASSKEY'),
            'callback'   => getenv('MPESA_CALLBACK_URL'),
        ];
    }
    

    Step 2: A HTTP helper that fails properly

    Before any Daraja code, write this. Every tutorial skips it, and it's the reason integrations fall over in production.

    function mpesaRequest(string $url, array $headers, ?array $body = null): array
    {
        $ch = curl_init($url);
    
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT        => 30,
        ]);
    
        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
        }
    
        $raw    = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error  = curl_error($ch);
        curl_close($ch);
    
        if ($raw === false) {
            throw new RuntimeException("Daraja request failed: {$error}");
        }
    
        $decoded = json_decode($raw, true);
    
        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new RuntimeException("Daraja returned invalid JSON (HTTP {$status}): {$raw}");
        }
    
        if ($status >= 400) {
            $message = $decoded['errorMessage'] ?? $raw;
            throw new RuntimeException("Daraja error (HTTP {$status}): {$message}");
        }
    
        return $decoded;
    }
    

    Three things matter here:

    CURLOPT_TIMEOUT. Without it, a hanging Daraja call ties up a PHP-FPM worker indefinitely. Enough of those and your whole site stops responding — not just payments. A slow dependency should degrade one feature, not take down the application.

    Checking the HTTP status. Daraja returns useful error messages in the body on a 4xx. Swallowing them means debugging blind.

    Throwing, not returning null. A failure that returns null gets silently passed downstream until something unrelated breaks three functions later. Fail where the failure happened.

    Step 3: Access tokens, cached

    Every Daraja request needs a Bearer token. Tokens are valid for roughly an hour — the response tells you exactly how long in expires_in.

    Fetching a new one on every payment doubles your API calls and adds latency to every checkout. Cache it:

    function getMpesaToken(): string
    {
        static $cached = null;
        static $expiresAt = 0;
    
        if ($cached !== null && time() < $expiresAt) {
            return $cached;
        }
    
        $config = mpesaConfig();
        $creds  = base64_encode($config['key'] . ':' . $config['secret']);
    
        $response = mpesaRequest(
            $config['base_url'] . '/oauth/v1/generate?grant_type=client_credentials',
            ['Authorization: Basic ' . $creds]
        );
    
        if (empty($response['access_token'])) {
            throw new RuntimeException('Daraja returned no access token');
        }
    
        $cached = $response['access_token'];
        // Expire 60s early so we never send a token that dies mid-flight.
        $expiresAt = time() + (int) ($response['expires_in'] ?? 3599) - 60;
    
        return $cached;
    }
    

    The static cache lives for one request. For real traffic, move it to Redis or APCu so it's shared across workers.

    Note the 60-second safety margin. Tokens that expire between your check and Safaricom's check produce intermittent failures that are miserable to reproduce.

    Step 4: Normalise the phone number properly

    This looks trivial and it is where a surprising number of integrations break.

    You'll receive numbers as 0712345678, 254712345678, +254712345678, and 712345678. Daraja wants 254712345678.

    The naive version — '254' . ltrim($phone, '0') — turns an already-correct 254712345678 into 254254712345678.

    function normalizePhone(string $phone): string
    {
        $digits = preg_replace('/\D/', '', $phone);   // strip +, spaces, dashes
    
        if (str_starts_with($digits, '254')) {
            $normalized = $digits;
        } elseif (str_starts_with($digits, '0')) {
            $normalized = '254' . substr($digits, 1);
        } elseif (strlen($digits) === 9) {
            $normalized = '254' . $digits;            // 712345678
        } else {
            throw new InvalidArgumentException("Unrecognised phone format: {$phone}");
        }
    
        if (!preg_match('/^254[17]\d{8}$/', $normalized)) {
            throw new InvalidArgumentException("Invalid Kenyan mobile number: {$phone}");
        }
    
        return $normalized;
    }
    

    The final regex matters. Safaricom numbers start 2547 or 2541. Validating the shape here means an invalid number fails fast with a clear message, instead of becoming an opaque Daraja error later.

    Step 5: Initiate the push

    function initiateSTKPush(PDO $pdo, string $phone, int $amount, string $reference, int $paymentId): array
    {
        $config    = mpesaConfig();
        $timestamp = date('YmdHis');
        $password  = base64_encode($config['shortcode'] . $config['passkey'] . $timestamp);
    
        $response = mpesaRequest(
            $config['base_url'] . '/mpesa/stkpush/v1/processrequest',
            [
                'Authorization: Bearer ' . getMpesaToken(),
                'Content-Type: application/json',
            ],
            [
                'BusinessShortCode' => $config['shortcode'],
                'Password'          => $password,
                'Timestamp'         => $timestamp,
                'TransactionType'   => 'CustomerPayBillOnline',
                'Amount'            => $amount,
                'PartyA'            => normalizePhone($phone),
                'PartyB'            => $config['shortcode'],
                'PhoneNumber'       => normalizePhone($phone),
                'CallBackURL'       => $config['callback'],
                'AccountReference'  => $reference,
                'TransactionDesc'   => 'Payment for ' . $reference,
            ]
        );
    
        // Store the CheckoutRequestID immediately — it's the only way to
        // match the callback back to this payment.
        $stmt = $pdo->prepare(
            'UPDATE payments SET mpesa_checkout_request_id = ?, status = "pending" WHERE id = ?'
        );
        $stmt->execute([$response['CheckoutRequestID'], $paymentId]);
    
        return $response;
    }
    

    Save the CheckoutRequestID before you return. If your process dies between sending the push and storing the ID, the customer can still pay — and you'll have no way to match the incoming callback to anything. That payment is now stranded, and only a human can resolve it.


    Step 6: The schema

    Idempotency isn't code you write, it's a shape you give your data. Get this right and the code becomes easy.

    CREATE TABLE payments (
        id                        BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        user_id                   BIGINT UNSIGNED NOT NULL,
        amount                    DECIMAL(12,2)   NOT NULL,
        status                    ENUM('initiated','pending','completed','failed')
                                                  NOT NULL DEFAULT 'initiated',
        mpesa_checkout_request_id VARCHAR(64)     NULL,
        mpesa_receipt_number      VARCHAR(32)     NULL,
        payer_phone               VARCHAR(15)     NULL,
        failure_reason            VARCHAR(255)    NULL,
        completed_at              TIMESTAMP       NULL,
        created_at                TIMESTAMP       DEFAULT CURRENT_TIMESTAMP,
    
        UNIQUE KEY uq_checkout_request (mpesa_checkout_request_id),
        UNIQUE KEY uq_receipt          (mpesa_receipt_number),
        KEY idx_status_created         (status, created_at)
    );
    

    Two unique constraints are doing real work:

    • uq_checkout_request — one payment row per STK Push, always.
    • uq_receipt — the database physically cannot record the same M-Pesa receipt twice, no matter what your application code does.

    That second one is your safety net for every bug you haven't thought of yet.


    Step 7: Handling the callback safely

    Safaricom will deliver the same callback more than once. This is documented behaviour, not a fault. If it doesn't get a clean acknowledgement, it retries.

    So the question that decides whether your integration is correct is: what happens on the second delivery?

    Here's the naive version you'll find in most tutorials:

    // DON'T DO THIS
    if ($resultCode === 0) {
        updatePayment($checkoutRequestId, 'completed', $receiptNo);
        creditUserAccount($userId, $amount);   // ← runs again on every retry
    }
    

    That credits the account twice. If you're paying someone out rather than crediting a balance, you send real money twice — and you find out from an angry message, not from your logs.

    Read the metadata by name, never by index

    $body   = json_decode(file_get_contents('php://input'), true);
    $stk    = $body['Body']['stkCallback'] ?? null;
    
    if ($stk === null) {
        http_response_code(400);
        exit(json_encode(['ResultCode' => 1, 'ResultDesc' => 'Malformed callback']));
    }
    
    $checkoutRequestId = $stk['CheckoutRequestID'] ?? null;
    $resultCode        = $stk['ResultCode']        ?? null;
    $resultDesc        = $stk['ResultDesc']        ?? '';
    
    // Safaricom does NOT guarantee the order or presence of these items.
    // $items[1] works until the day it doesn't.
    $meta = array_column($stk['CallbackMetadata']['Item'] ?? [], 'Value', 'Name');
    
    $receiptNumber = $meta['MpesaReceiptNumber'] ?? null;
    $amountPaid    = $meta['Amount']             ?? null;
    $payerPhone    = $meta['PhoneNumber']        ?? null;
    

    On a failed payment CallbackMetadata is absent entirely. Positional indexing throws; array_column with ?? degrades quietly.

    The conditional update

    This is the core of the whole article.

    if ($resultCode === 0) {
    
        $stmt = $pdo->prepare(
            'UPDATE payments
                SET status = "completed",
                    mpesa_receipt_number = ?,
                    payer_phone = ?,
                    completed_at = NOW()
              WHERE mpesa_checkout_request_id = ?
                AND status = "pending"'
        );
    
        $stmt->execute([$receiptNumber, $payerPhone, $checkoutRequestId]);
    
        if ($stmt->rowCount() === 0) {
            // Either already completed, or no such payment.
            // Either way: do nothing else. This is a duplicate delivery.
            error_log("Duplicate M-Pesa callback ignored: {$checkoutRequestId}");
            http_response_code(200);
            exit(json_encode(['ResultCode' => 0, 'ResultDesc' => 'Accepted']));
        }
    
        // Only the request that actually won the transition reaches here.
        creditUserAccount($checkoutRequestId, $amountPaid);
    }
    

    Read the WHERE clause carefully: AND status = "pending". The row only changes if it hasn't already been completed. Then rowCount() tells you whether you were the one who changed it.

    One means you won. Zero means someone else already handled it, and you stop.

    This is called a conditional update, or compare-and-set. The reason it beats an if statement is timing. An if reads the status, then writes — two separate operations, with a gap. Two callbacks arriving 50ms apart on two PHP workers can both read pending, both decide to proceed, and both credit the account. The check passed twice because nothing stopped the second one between reading and writing.

    A single UPDATE has no gap. MySQL locks the row for the duration of the statement. The database arbitrates the race — which is the only component that can, because it's the only thing both requests share.

    The rule: never check and then act. Make the check part of the write.

    Always return 200

    http_response_code(200);
    echo json_encode(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
    

    A duplicate is not an error — it's Safaricom working correctly. Return 4xx or 5xx and Safaricom retries harder, so you generate more duplicates. You'd be building a feedback loop that amplifies the exact problem you're solving.

    Acknowledge everything you successfully received. Handle correctness in your own logic, not in the status code.


    Step 8: Verify the callback is real

    Your callback URL is public. It has to be — Safaricom needs to reach it.

    Which means anyone can find it and POST this:

    { "Body": { "stkCallback": {
        "CheckoutRequestID": "ws_CO_...", "ResultCode": 0,
        "CallbackMetadata": { "Item": [{ "Name": "Amount", "Value": 50000 }] } } } }
    

    They initiate a real payment, never pay, then forge the success callback themselves. Your system marks it paid and ships the goods. Idempotency does not help here — a forged callback is the first one, so it wins the race legitimately.

    Two defences, and you want both:

    A secret path segment. Register your callback with an unguessable component:

    https://yourdomain.com/mpesa/callback/9f2c1a7e4b8d6f0c3a5e7b9d1f4c6a8e
    
    $expected = getenv('MPESA_CALLBACK_SECRET');
    $provided = $_GET['token'] ?? basename($_SERVER['REQUEST_URI']);
    
    if (!hash_equals($expected, $provided)) {
        error_log('Rejected M-Pesa callback with bad secret from ' . $_SERVER['REMOTE_ADDR']);
        http_response_code(404);
        exit;
    }
    

    hash_equals rather than === — it compares in constant time, so an attacker can't discover the secret one character at a time by measuring response latency.

    Never trust the amount in the callback. Compare it against what you stored when you initiated:

    if ((float) $amountPaid !== (float) $payment['amount']) {
        error_log("Amount mismatch on {$checkoutRequestId}: expected {$payment['amount']}, got {$amountPaid}");
        // Record it, flag for review, do NOT fulfil.
    }
    

    Your database is the source of truth for what was owed. The callback only tells you what happened.

    You can additionally allowlist Safaricom's published callback IP ranges at your firewall or reverse proxy. Treat that as defence in depth rather than your primary control — ranges change, and you don't want a silent outage when they do.


    Step 9: Reconciliation — when the callback never arrives

    Everything so far protects you from double-processing. None of it protects your customer from the opposite failure: they paid, and your system never heard about it.

    Callbacks get lost. Your server restarts mid-request. Your host has a blip. The customer's payment succeeded regardless — Safaricom took their money.

    The fix is a scheduled job that asks Daraja what actually happened:

    function reconcilePendingPayments(PDO $pdo): void
    {
        // Old enough that a callback should have arrived, recent enough to still query.
        $stmt = $pdo->query(
            'SELECT * FROM payments
              WHERE status = "pending"
                AND created_at < NOW() - INTERVAL 5 MINUTE
                AND created_at > NOW() - INTERVAL 7 DAY
              LIMIT 100'
        );
    
        $config = mpesaConfig();
    
        foreach ($stmt as $payment) {
            $timestamp = date('YmdHis');
            $password  = base64_encode($config['shortcode'] . $config['passkey'] . $timestamp);
    
            try {
                $result = mpesaRequest(
                    $config['base_url'] . '/mpesa/stkpushquery/v1/query',
                    [
                        'Authorization: Bearer ' . getMpesaToken(),
                        'Content-Type: application/json',
                    ],
                    [
                        'BusinessShortCode' => $config['shortcode'],
                        'Password'          => $password,
                        'Timestamp'         => $timestamp,
                        'CheckoutRequestID' => $payment['mpesa_checkout_request_id'],
                    ]
                );
    
                // Reuse the SAME conditional-update path as the callback handler.
                // Two code paths that both complete payments will drift apart.
                applyPaymentResult($pdo, $payment['mpesa_checkout_request_id'], $result);
    
            } catch (RuntimeException $e) {
                error_log("Reconcile failed for {$payment['id']}: " . $e->getMessage());
                // Leave it pending. Next run tries again.
            }
        }
    }
    

    Run it every five minutes with cron:

    */5 * * * * /usr/bin/php /var/www/app/bin/reconcile.php >> /var/log/mpesa-reconcile.log 2>&1
    

    Three things worth noticing:

    It reuses the callback's completion logic. Two separate code paths that can both mark a payment complete will diverge, and the bug will only appear in the path you test less. One function, called from both.

    Because completion is idempotent, this is safe. If the callback arrives while the reconciler is running, one of them wins the conditional update and the other does nothing. That's the whole payoff of Step 7 — you can retry freely without fear.

    Failures are left alone. A payment that can't be reconciled stays pending and gets retried next run. Never guess a payment into completed.


    Common errors

    CodeMeaningFix
    400.002.02Invalid access tokenToken expired — re-fetch. Check your cache expiry margin.
    400.002.05Invalid shortcodeWrong shortcode, or sandbox credentials against the production URL
    17Internal errorAlmost always a wrong passkey
    1Insufficient balanceCustomer's M-Pesa balance too low
    1032Request cancelledCustomer dismissed the prompt
    1037Timeout / unreachablePhone off or out of coverage; no prompt shown
    2001Wrong PINCustomer entered an incorrect M-Pesa PIN

    Codes 1, 1032, 1037 and 2001 are normal outcomes, not system faults. Log them as information and show the customer a useful message. Alerting on these will bury the alerts that matter.


    Production checklist

    • All credentials in environment variables, never in source control
    • MPESA_ENV drives the base URL — no find-and-replace before deploying
    • HTTPS callback URL with an unguessable secret segment
    • Timeouts on every outbound Daraja call
    • Callback completion uses a conditional update and checks rowCount()
    • Unique constraint on mpesa_receipt_number
    • Callback amount validated against the stored amount
    • Callback always returns HTTP 200
    • Metadata read by Name, never by array index
    • Reconciliation cron running every 5 minutes
    • Every callback logged with its CheckoutRequestID
    • Alerts on payments stuck pending beyond 30 minutes

    Conclusion

    The happy path — token, push, callback — is an afternoon's work. Everything that makes it trustworthy is what comes after:

    Make completion idempotent, so a repeated callback is harmless. Verify the callback is genuine, so a stranger can't mark their own order paid. Reconcile continuously, so a lost callback doesn't leave a customer who paid for nothing.

    Mobile money has no synchronous confirmation. You never find out the result inside the request that started it. Correctness lives in the reconciliation, not in the request — and once you internalise that, the rest of the design follows.


    Need M-Pesa built properly?

    I've shipped production Daraja integrations for a welfare platform handling member contributions and claims, a job marketplace with automatic commission splitting, and a WhatsApp commerce platform with usage-based billing.

    If you're integrating M-Pesa and want it done with idempotency, verification and reconciliation from day one — get in touch.

    HOA

    Harrison Onyango Aloo

    Backend Software Engineer — Node.js · Python · Payment Integrations

    Thanks for reading. I write about backend engineering — payments, APIs, and the failure modes that only show up in production. Find me here:

    HOA

    Harrison Aloo

    Software Engineer | Backend Developer | Open Source Enthusiast

    Connect

    © 2026 Harrison Onyango Aloo. All rights reserved.

    Chat on WhatsApp