DEV Community

Sospeter Mong'are
Sospeter Mong'are

Posted on

Testing M-PESA STK Push Callbacks Locally Without Exposing Your Server

If you've ever built an M-PESA STK Push integration, you've probably hit this wall: M-PESA needs a publicly accessible callback URL to send payment confirmations, but your app is running on localhost. How do you test this without deploying every single time?

In this guide I'll show you a simple approach using webhook.site and a small Node.js poller script that automatically forwards M-PESA callbacks to your local machine.


What We're Building

When a user completes an STK Push payment, M-PESA sends a POST request to your callback URL with the payment result. Normally that URL has to be publicly accessible. Since localhost isn't public, we'll use webhook.site as a middleman - it catches the callback from M-PESA, and our poller script picks it up and forwards it to our local app automatically.

The flow looks like this:

M-PESA → webhook.site → our poller script → localhost


Prerequisites

  • Node.js installed on your machine
  • Your Laravel app running locally
  • An M-PESA Daraja API account (sandbox or production)
  • Basic understanding of how STK Push works

Step 1 - Get a webhook.site URL

Go to webhook.site and you'll immediately get a unique URL that looks like this:

https://webhook.site/f5d500b7-1196-42ef-8ecd-c906e49f7df36
Enter fullscreen mode Exit fullscreen mode

The part after the last slash is your token. Copy it - you'll need it shortly. Keep this tab open so you can see incoming requests.


Step 2 - Store the Callback URL in Your .env

Instead of hardcoding the webhook.site URL in your code, store it in your .env file so you can swap it out easily without touching any code:

MPESA_CALLBACK_URL=https://webhook.site/f5d500b7-1196-42ef-8ecd-c906e49f7df36
Enter fullscreen mode Exit fullscreen mode

On production you simply leave this variable out and it falls back to your real callback URL automatically.


Step 3 - Update Your ProcessController

In your M-PESA ProcessController where you build the STK Push request, find the CallBackURL line and update it like this:

"CallBackURL" => env('MPESA_CALLBACK_URL') ?: route('ipn.MPesa'),
Enter fullscreen mode Exit fullscreen mode

What this does is simple. If MPESA_CALLBACK_URL is set in your .env it uses that. If it's not set, it falls back to your real route. This means locally it points to webhook.site, and on production it uses your actual callback route - no code changes needed when you deploy.


Step 4 - Create the Poller Script

This is the magic piece. Create a file called forwarder.js anywhere on your machine - I put mine in the project root. Paste this in:

const WEBHOOK_TOKEN = 'f5d500b7-1196-42ef-8ecd-c906e49f7df36'; // your token here
const LOCAL_URL = 'http://localhost:8000/ipn/mpesa';
const POLL_INTERVAL = 5000; // check every 5 seconds

let lastSeenId = null;

async function poll() {
    try {
        const res = await fetch(`https://webhook.site/token/${WEBHOOK_TOKEN}/requests?sorting=newest&per_page=1`);
        const data = await res.json();

        if (!data.data || data.data.length === 0) return;

        const latest = data.data[0];

        if (latest.uuid === lastSeenId) return; // already processed this one

        lastSeenId = latest.uuid;

        const body = latest.content;
        console.log('New callback received! Forwarding to local app...');
        console.log(body);

        const forward = await fetch(LOCAL_URL, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: body
        });

        console.log('Done. Local app responded with status:', forward.status);

    } catch (err) {
        console.error('Error:', err.message);
    }
}

setInterval(poll, POLL_INTERVAL);
console.log('Watching webhook.site for incoming callbacks...');
Enter fullscreen mode Exit fullscreen mode

Replace the WEBHOOK_TOKEN value with your own token from Step 1. The LOCAL_URL should match whatever route your app uses for the M-PESA IPN.


Step 5 - Run Everything

You'll need two terminals open.

Terminal 1 - start your Laravel app:

php artisan serve
Enter fullscreen mode Exit fullscreen mode

Terminal 2 - start the poller:

node forwarder.js
Enter fullscreen mode Exit fullscreen mode

You should see this message in Terminal 2:

Watching webhook.site for incoming callbacks...
Enter fullscreen mode Exit fullscreen mode

Step 6 - Trigger a Test Payment

Go through your app's deposit flow and trigger an STK Push. Enter your phone number and hit pay. You'll get the prompt on your phone.

Once you complete the payment, here's what happens automatically:

  1. M-PESA sends the callback to webhook.site
  2. Your poller picks it up within 5 seconds
  3. It forwards the full payload to your local app
  4. Your local app processes it and updates the database

You'll see something like this in Terminal 2:

New callback received! Forwarding to local app...
{"Body":{"stkCallback":{"ResultCode":0,"CheckoutRequestID":"ws_CO_xxxxx",...}}}
Done. Local app responded with status: 200
Enter fullscreen mode Exit fullscreen mode

And your database gets updated just like it would in production.


Why Not Just Use ngrok?

Ngrok is the most common answer to this problem and it works well. However if you're running a licensed script, adding a new public URL can trigger a second domain detection and invalidate your license. The webhook.site approach avoids this completely since your app never gets a new public URL - only the callback endpoint changes temporarily, and only in your local .env.


Cleaning Up After Testing

When you're done testing, remove MPESA_CALLBACK_URL from your .env or comment it out:

# MPESA_CALLBACK_URL=https://webhook.site/f5d500b7-1196-42ef-8ecd-c906e49f7df36
Enter fullscreen mode Exit fullscreen mode

Your app will fall back to the real route automatically. Stop the forwarder.js script and you're done. Nothing was changed in your codebase, nothing gets pushed to production.


Summary

The whole setup takes about 5 minutes and gives you a proper local testing loop for M-PESA callbacks without ngrok, without deploying, and without touching your production environment. The poller script is lightweight, requires no dependencies beyond Node.js, and you can reuse the same approach for any other payment gateway callback - just change the LOCAL_URL to point to the right IPN route.

Top comments (0)