What you'll build

A Node.js/Express backend that accepts M-Pesa mobile money payments via Safaricom's Daraja API. M-Pesa is the dominant mobile money platform in Kenya, Tanzania, and across East Africa, processing billions of dollars in transactions annually. STK Push (also called Lipa Na M-Pesa Online) is the server-initiated payment flow — your backend triggers a payment prompt directly on the customer's phone. After they enter their M-Pesa PIN, Safaricom sends a callback to your server confirming the result.

By the end of this tutorial:

Prerequisites

How M-Pesa STK Push works

  1. Your server requests an OAuth2 access token from Safaricom
  2. Your server sends an STK Push request with the customer's phone number and amount
  3. Safaricom pushes a payment prompt to the customer's phone
  4. The customer enters their M-Pesa PIN to authorize the payment
  5. Safaricom sends a callback to your server with the payment result

Get your API key

  1. Go to developer.safaricom.co.ke and create an account
  2. Create a new app — enable Lipa Na M-Pesa Sandbox
  3. Copy your Consumer Key and Consumer Secret from the app dashboard
  4. Note the test shortcode and passkey from the sandbox documentation

Add these to your .env:

MPESA_CONSUMER_KEY=your_consumer_key
MPESA_CONSUMER_SECRET=your_consumer_secret
MPESA_SHORTCODE=174379
MPESA_PASSKEY=your_passkey_from_sandbox_docs
MPESA_CALLBACK_URL=https://your-domain.com/mpesa/callback
PORT=3000

The callback URL must be HTTPS — Safaricom will reject HTTP URLs. For local development, use ngrok to expose your local server: ngrok http 3000, then set MPESA_CALLBACK_URL to the HTTPS URL it gives you.

Initialize the project

mkdir mpesa-app && cd mpesa-app
npm init -y
npm install express dotenv axios
npm install -D typescript @types/express @types/node tsx
npx tsc --init

Add to package.json scripts:

"dev": "tsx watch src/index.ts"

Build the payment endpoint

BlocWeave prompt

Create an M-Pesa STK Push payment integration with Express. 1. src/mpesa.ts — export two functions: - getAccessToken(): POST to https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials with an Authorization header of Basic ${Buffer.from(MPESA_CONSUMER_KEY + ":" + MPESA_CONSUMER_SECRET).toString("base64")}. Return the access_token from the response. Cache it in a module-level variable with its expiry (expires_in seconds from now) and only re-fetch when expired. - stkPush(phone: string, amount: number, accountRef: string): Get an access token. Build the timestamp as new Date().toISOString().replace(/[-T:.Z]/g, "").slice(0, 14) (YYYYMMDDHHmmss format). Build the password as Buffer.from(MPESA_SHORTCODE + MPESA_PASSKEY + timestamp).toString("base64"). POST to https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest with Bearer token and body: { BusinessShortCode: MPESA_SHORTCODE, Password: password, Timestamp: timestamp, TransactionType: "CustomerPayBillOnline", Amount: Math.round(amount), PartyA: phone, PartyB: MPESA_SHORTCODE, PhoneNumber: phone, CallBackURL: MPESA_CALLBACK_URL, AccountReference: accountRef, TransactionDesc: "Payment for " + accountRef }. Return the full response (CheckoutRequestID, etc). Phone must be in 2547XXXXXXXX format — strip any leading + or 0. 2. src/index.ts — Express app with: - An in-memory Map<string, object> called payments to track payment status by CheckoutRequestID. - POST /pay: validate body { phone: string, amount: number }. Normalize phone: if it starts with "0", replace with "254"; if it starts with "+", strip the "+". Reject if not matching /^2547\d{8}$/. Amount must be a positive integer (M-Pesa only accepts whole KES). Call stkPush, store the CheckoutRequestID in the payments map with status "pending", return 201 { checkoutRequestId, merchantRequestId, responseDescription }. - GET /payments/:id: look up the CheckoutRequestID in the payments map and return its current status, or 404 if not found. Use express.json() globally. Add error handling middleware that logs the error and returns 500 { error: "Internal server error" }. Load env vars with dotenv.

Handle the webhook

Safaricom sends the payment result as a POST to your callback URL. The callback payload is deeply nested — the actual data lives inside Body.stkCallback. You must always return HTTP 200 quickly, even if the payment failed, otherwise Safaricom will keep retrying the callback.

BlocWeave prompt

Add the M-Pesa callback handler to the Express app in src/index.ts. POST /mpesa/callback: - Safaricom sends a JSON body with { Body: { stkCallback: { MerchantRequestID, CheckoutRequestID, ResultCode, ResultDesc, CallbackMetadata } } }. - Extract the stkCallback object. If Body or stkCallback is missing, log an error and return 200. - Look up the payment by CheckoutRequestID in the payments map. - If not found, log a warning and return 200 (Safaricom retries on non-200). - If ResultCode === 0, the payment succeeded. Extract the CallbackMetadata.Item array and build an object from it (each item has { Name, Value }). Update the payment record with status "completed", mpesaReceiptNumber from "MpesaReceiptNumber", transactionDate from "TransactionDate", phoneNumber from "PhoneNumber", amount from "Amount". - If ResultCode !== 0, update the payment with status "failed" and reason set to ResultDesc. Common failure codes: 1032 = user cancelled, 1037 = timeout (user didn't respond within 60s), 2001 = wrong PIN. - Always return 200 { ResultCode: 0, ResultDesc: "Accepted" } immediately — Safaricom requires a fast response and will retry up to 3 times on non-200. Add a utility function parseCallbackMetadata(items: Array<{Name: string, Value: any}>): Record<string, any> that converts the items array into a key-value object.

Callback payload example

A successful payment callback looks like this:

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          { "Name": "Amount", "Value": 100 },
          { "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
          { "Name": "TransactionDate", "Value": 20191219102115 },
          { "Name": "PhoneNumber", "Value": 254708374149 }
        ]
      }
    }
  }
}

Test it

Start the server and expose it via ngrok:

npm run dev
# In another terminal:
ngrok http 3000

Update MPESA_CALLBACK_URL in .env with the ngrok HTTPS URL (e.g., https://abc123.ngrok-free.app/mpesa/callback), then restart the server.

Trigger a test payment using the Safaricom sandbox test credentials:

# Use the sandbox test number 254708374149
curl -X POST http://localhost:3000/pay \
  -H "Content-Type: application/json" \
  -d '{"phone": "254708374149", "amount": 100}'

You should get back a response like:

{
  "checkoutRequestId": "ws_CO_21062026120000123456",
  "merchantRequestId": "12345-67890123-1",
  "responseDescription": "Success. Request accepted for processing"
}

In the sandbox, the STK Push prompt is simulated — the callback will fire automatically after a few seconds. Check the payment status:

curl http://localhost:3000/payments/ws_CO_21062026120000123456

Common errors

Error Cause Fix
"Bad Request - Invalid BusinessShortCode" Wrong shortcode Use sandbox shortcode 174379
"The initiator information is invalid" Wrong passkey Check passkey from sandbox docs
"Invalid Access Token" Expired or wrong credentials Verify consumer key/secret
"Invalid PhoneNumber" Wrong format Must be 2547XXXXXXXX, no + or leading 0

Handling phone number formats

Kenyan customers enter phone numbers in many formats. Normalize them before calling the API:

Production checklist: (1) Switch the base URL from sandbox.safaricom.co.ke to api.safaricom.co.ke. (2) Replace the sandbox shortcode and passkey with your live ones from the Safaricom M-Pesa portal. (3) Store payments in a real database, not an in-memory Map. (4) Add idempotency checks — Safaricom may send the same callback more than once. (5) Add request logging and monitoring for failed callbacks. (6) M-Pesa amounts must be whole numbers in KES (no decimals). (7) The STK Push prompt times out after 60 seconds if the user doesn't respond.

Project structure after this tutorial

src/
  mpesa.ts          # Daraja API auth + STK Push logic
  index.ts          # Express app, routes, callback handler
.env                # MPESA_CONSUMER_KEY, MPESA_CONSUMER_SECRET, etc.
package.json