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:
- Your server authenticates with Safaricom's Daraja API using OAuth2 client credentials
POST /paytriggers an STK Push prompt on the customer's phonePOST /mpesa/callbackreceives and validates the payment resultGET /payments/:idlets you check the status of any payment- All secrets are stored securely in environment variables
Prerequisites
- Node.js 18+ installed
- A Safaricom Developer account (free)
- ngrok or another HTTPS tunnel for local callback testing
- BlocWeave installed in VS Code
How M-Pesa STK Push works
- Your server requests an OAuth2 access token from Safaricom
- Your server sends an STK Push request with the customer's phone number and amount
- Safaricom pushes a payment prompt to the customer's phone
- The customer enters their M-Pesa PIN to authorize the payment
- Safaricom sends a callback to your server with the payment result
Get your API key
- Go to developer.safaricom.co.ke and create an account
- Create a new app — enable Lipa Na M-Pesa Sandbox
- Copy your Consumer Key and Consumer Secret from the app dashboard
- 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
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.
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:
0712345678becomes254712345678(replace leading 0 with 254)+254712345678becomes254712345678(strip the +)254712345678is already correct- Reject numbers that don't start with
2547after normalization
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
BlocWeave