M-Pesa Payments
Accept M-Pesa mobile payments via Daraja
Medusa M-Pesa Payment Provider
A Medusa v2 Payment Provider plugin for M-Pesa Daraja API (Safaricom Kenya).
Features:
- STK Push (Lipa Na M-Pesa Online) — customer receives a payment prompt on their phone
- Asynchronous confirmation via Daraja callback webhook + storefront status polling
- Refunds via M-Pesa reversal (requires initiator credentials)
- Sandbox and production environments
- Phone number normalization — accepts Copy to clipboard
07XX, Copy to clipboard+254XX, Copy to clipboard254XX, and bare Copy to clipboard7XXXXXXXXformats - RSA PKCS#1 v1.5 credential encryption for production reversals (required by Safaricom Daraja API)
- TypeScript-first with exported option types
Table of Contents
- Requirements
- Installation
- Configuration
- 1. Register the provider
- 2. Environment variables
- 3. Add provider to a Region
- Payment Flow
- Supported Phone Number Formats
- API Routes
- Storefront Integration
- Refund Flow
- Result Code Reference
- Production Setup
- Sandbox Testing
Requirements
- Medusa v2 (Copy to clipboard
@medusajs/medusa >= 2.3.0) - Node.js >= 20
- A Safaricom Daraja app with Lipa Na M-Pesa Online enabled
Installation
12345npm install medusa-payment-mpesa# orpnpm add medusa-payment-mpesa# oryarn add medusa-payment-mpesa
Configuration
1. Register the provider in Copy to clipboardmedusa-config.ts
1234567891011121314151617181920import { loadEnv, defineConfig } from "@medusajs/framework/utils";import type { MpesaOptions } from "medusa-payment-mpesa";loadEnv(process.env.NODE_ENV || "development", process.cwd());module.exports = defineConfig({// ...modules: [{resolve: "@medusajs/medusa/payment",options: {providers: [{resolve: "medusa-payment-mpesa/providers/mpesa",id: "mpesa",options: {consumer_key: process.env.MPESA_CONSUMER_KEY,consumer_secret: process.env.MPESA_CONSUMER_SECRET,business_short_code: process.env.MPESA_BUSINESS_SHORT_CODE,pass_key: process.env.MPESA_PASS_KEY,
2. Set environment variables
123456789101112# RequiredMPESA_CONSUMER_KEY=your_consumer_keyMPESA_CONSUMER_SECRET=your_consumer_secretMPESA_BUSINESS_SHORT_CODE=174379MPESA_PASS_KEY=your_pass_keyMPESA_CALLBACK_BASE_URL=https://your-backend.example.com# OptionalMPESA_ENVIRONMENT=sandbox # Default: "sandbox" | Options: "sandbox" or "production"MPESA_INITIATOR_NAME=testapi # Required only for refundsMPESA_INITIATOR_PASSWORD=Safaricom123 # Required only for refundsMPESA_WEBHOOK_SECRET=supersecret # Optional secret for webhook verification
Create app at Daraja Dashboard and copy the credentials.
3. Set up Kenya Region
- Go to Medusa Admin → Settings → Regions
- Click Create
- Configure the region:
- Name: Kenya
- Currency: KES (Kenyan Shilling)
- Countries: Add Kenya under the Countries section
- Configure tax and payment provider settings as needed
- Click Save
4. Add M-Pesa Payment Provider to Kenya Region
- Go to Medusa Admin → Settings → Regions → Kenya → Edit
- Navigate to Payment Providers
- Add M-Pesa as a payment provider
- The M-Pesa provider will appear at checkout only for carts in the Kenya region
5. Configure Tax Region for Kenya
- Go to Medusa Admin → Settings → Tax Regions
- Click Create to add a new tax region
- Configure for Kenya with the appropriate tax rates
Payment Flow
1234567891011121314151617181920sequenceDiagramparticipant Customerparticipant Storefrontparticipant Medusaparticipant DarajaStorefront->>Medusa: initiatePaymentSession({ phone_number })Medusa->>Daraja: POST /mpesa/stkpush/v1/processrequestDaraja-->>Medusa: { CheckoutRequestID, MerchantRequestID }Medusa-->>Storefront: payment session created (id = CheckoutRequestID)Daraja-->>Customer: STK Push prompt on phoneNote over Customer,Daraja: Customer has ~60s to accept STK PushStorefront->>Medusa: GET /store/mpesa/status/:checkoutRequestIdMedusa->>Daraja: POST /mpesa/stkpushquery/v1/queryDaraja-->>Medusa: { ResultCode }Medusa-->>Storefront: { status: "pending" | "paid" | "cancelled" | "error" }alt Customer paid (status: paid)
Supported Phone Number Formats
The provider normalizes all inputs to the Copy to clipboard254XXXXXXXXX (12-digit) format required by Daraja. Any non-digit characters (spaces, dashes, Copy to clipboard+) are stripped first.
Input Format Example Normalized Local with leading 0 Copy to clipboard0712345678 Copy to clipboard254712345678 International Copy to clipboard+254 Copy to clipboard+254712345678 Copy to clipboard254712345678 International Copy to clipboard254 Copy to clipboard254712345678 Copy to clipboard254712345678 9-digit without prefix Copy to clipboard712345678 Copy to clipboard254712345678 New Copy to clipboard01 prefix Copy to clipboard0112345678 Copy to clipboard254112345678
Numbers that don't match any of these patterns are rejected with a Copy to clipboard400 Invalid Data error.The phone number can be passed in two ways — the first available is used:
- Explicitly via Copy to clipboard
data.phone_numberin the payment session (recommended) - From the authenticated customer's saved Copy to clipboard
phonefield
123456await sdk.store.payment.initiatePaymentSession(cart, {provider_id: "pp_mpesa_mpesa",data: {phone_number: "0712345678", // any supported format},});
API Routes (added automatically by the plugin)
All four public routes are registered automatically by the plugin without any manual configuration.
Method Path Auth Description Copy to clipboardPOST Copy to clipboard/store/mpesa/callback Public Daraja STK Push result callback Copy to clipboardGET Copy to clipboard/store/mpesa/status/:checkoutRequestId Public Storefront payment status polling Copy to clipboardPOST Copy to clipboard/store/mpesa/reversal-result Public Daraja reversal result callback Copy to clipboardPOST Copy to clipboard/store/mpesa/reversal-timeout Public Daraja reversal timeout callback
Configure these URLs in your Daraja app settings:
Daraja Setting Value Callback URL Copy to clipboardhttps://your-backend.example.com/store/mpesa/callback Result URL (reversals) Copy to clipboardhttps://your-backend.example.com/store/mpesa/reversal-result Queue Timeout URL Copy to clipboardhttps://your-backend.example.com/store/mpesa/reversal-timeout
Note: The callback URLs must be publicly accessible HTTPS endpoints. For local development, use a tunneling service like ngrok.
Storefront Integration
1. Display M-Pesa in the payment method list
Add the provider to your payment info map (e.g. Copy to clipboardsrc/lib/constants.tsx):
123456import { Phone } from "@medusajs/icons"export const paymentInfoMap: Record<string, { title: string; icon: JSX.Element }> = {// ... other providerspp_mpesa_mpesa: { title: "M-Pesa", icon: <Phone /> },}
2. Collect the phone number at checkout
When M-Pesa is selected, show a phone number input before the customer can submit:
1234567891011const isMpesa = (providerId?: string) => providerId === "pp_mpesa_mpesa";const [mpesaPhone, setMpesaPhone] = useState("");// Validates: 07XXXXXXXXX, +254XXXXXXXXX, or 254XXXXXXXXXconst phoneValid = /^(254[0-9]{9}|0[0-9]{9}|\+254[0-9]{9})$/.test(mpesaPhone);// On checkout submit — pass phone_number in the session dataawait sdk.store.payment.initiatePaymentSession(cart, {provider_id: "pp_mpesa_mpesa",data: { phone_number: mpesaPhone },});
3. Check status before placing the order
After the payment step is submitted, the customer has ~60 seconds to accept the M-Pesa STK Push prompt on their phone. Before calling Copy to clipboardplaceOrder, do a single status check and surface terminal failures immediately:
1234567891011121314151617181920const BACKEND_URL = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL;type MpesaStatusResponse = {status: "paid" | "pending" | "cancelled" | "error";result_code: string | null;result_desc: string | null;};async function checkMpesaStatus(checkoutRequestId: string,): Promise<MpesaStatusResponse> {const res = await fetch(`${BACKEND_URL}/store/mpesa/status/${encodeURIComponent(checkoutRequestId)}`,);return res.json();}// In your payment button handler:const { status, result_desc } = await checkMpesaStatus(checkoutRequestId);if (status === "cancelled" || status === "error") {
Tip: The status endpoint can also be called in a polling loop (e.g. every 3 s for up to 90 s) if you want to give the customer real-time feedback while they interact with the STK Push prompt, before they click "Place order" e.g. show a message like Copy to clipboard"Waiting for M-Pesa payment… (6s / 90s)"that updates every poll. Break early if status becomes Copy to clipboardpaid, or abort if it becomes Copy to clipboardcancelled/Copy to clipboarderror. If it reaches 90 s with no success, let the customer click "Place order" anyway — the final Copy to clipboardauthorizePaymentserver-side will do one last status check to confirm before placing the order. See Copy to clipboardMpesaPaymentButtonin storefront for reference implementation.
Refund Flow
Refunds are processed as M-Pesa reversals and are asynchronous — Daraja sends the result to Copy to clipboard/store/mpesa/reversal-result after processing. The Copy to clipboardmpesa_receipt_number (from the original payment callback) is required.
12345678910111213141516171819sequenceDiagramparticipant Adminparticipant Medusaparticipant DarajaAdmin->>Medusa: Create refund (order management)Medusa->>Medusa: refundPayment — reads mpesa_receipt_numberMedusa->>Daraja: POST /mpesa/reversal/v1/requestNote over Medusa,Daraja: SecurityCredential = RSA PKCS#1 v1.5<br/>initiator_password (production)<br/>base64 (sandbox)Daraja-->>Medusa: { ConversationID, ResponseCode: "0" }Medusa-->>Admin: Refund initiated (ConversationID stored)alt Reversal succeedsDaraja->>Medusa: POST /store/mpesa/reversal-result (ResultCode 0)Medusa-->>Medusa: Log successelse Reversal times outDaraja->>Medusa: POST /store/mpesa/reversal-timeoutMedusa-->>Medusa: Log warningend
Note: A refund will fail if Copy to clipboardmpesa_receipt_number is not present in the payment session data. This value is populated by the STK Push callback when the customer pays. If the callback was not received, manual reconciliation with Safaricom is required.Result Code Reference
These are the M-Pesa STK Push result codes returned by Daraja and how this plugin maps them:
Result Code Meaning Copy to clipboard/store/mpesa/status response Copy to clipboardauthorizePayment status Copy to clipboard0 Success Copy to clipboardpaid Copy to clipboardauthorized Copy to clipboard1032 Request cancelled by user Copy to clipboardcancelled Copy to clipboardcanceled Copy to clipboard1037 Timeout — user did not respond Copy to clipboarderror Copy to clipboarderror (terminal) Copy to clipboard2001 Wrong PIN entered Copy to clipboarderror Copy to clipboarderror (terminal) Copy to clipboard1019 Transaction expired Copy to clipboarderror Copy to clipboarderror (terminal) Copy to clipboard9999 Internal switch error Copy to clipboarderror Copy to clipboarderror (terminal) other Transaction not yet settled Copy to clipboardpending Copy to clipboardpending
Terminal codes (Copy to clipboard1032, Copy to clipboard1037, Copy to clipboard2001, Copy to clipboard1019, Copy to clipboard9999) will never succeed on retry and are mapped to Copy to clipboarderror immediately rather than being polled again.
Production Setup
1. RSA certificate for reversals
Production M-Pesa reversals require the initiator password to be encrypted with Safaricom's public certificate using RSA PKCS#1 v1.5 (Copy to clipboardRSAES-PKCS1-v1_5). This is Safaricom's mandated scheme for the Copy to clipboardSecurityCredential field — it is not configurable. Download Copy to clipboardProductionCertificate.cer from the Safaricom Developer Portal and place it in the root of your Medusa backend (same directory as Copy to clipboardmedusa-config.ts, where Copy to clipboardprocess.cwd() resolves).
The plugin handles encryption automatically — sandbox uses base64, production uses RSA PKCS#1 v1.5. You do not need to encrypt it yourself.
2. Make the callback URL publicly accessible
Copy to clipboardMPESA_CALLBACK_BASE_URL must be an HTTPS URL reachable from Safaricom's servers. For local development, use a tunneling service:
12ngrok http 9000# Then set: MPESA_CALLBACK_BASE_URL=https://xxxx.ngrok.io
3. STK Push Copy to clipboardAccountReference limit
The Copy to clipboardaccount_reference field in STK Push requests is capped at 12 characters by Daraja. If you pass Copy to clipboardorder_id in the payment session data, it will be truncated automatically. This is purely a Daraja label shown on the customer's M-Pesa statement.
1234567await sdk.store.payment.initiatePaymentSession(cart, {provider_id: "pp_mpesa_mpesa",data: {phone_number: "0712345678",order_id: cart.id, // truncated to 12 chars — used as AccountReference},});
Sandbox Testing
- Log in to the Daraja Developer Portal and create a sandbox app
- Use the sandbox credentials:
- Short code: Copy to clipboard
174379 - Test phone: Copy to clipboard
254708374149 - Passkey: available on the Daraja portal under your app
- Short code: Copy to clipboard
- Use the portal's Simulate tab to trigger an STK Push callback without a real phone
- Confirm your callback URL is reachable (use ngrok locally) before testing
License
MIT © Elvis Gisiora
Built with Medusa v2 and the Safaricom Daraja API.

