Overview
Icon for M-Pesa Payments

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 clipboard07XX, Copy to clipboard+254XX, Copy to clipboard254XX, and bare Copy to clipboard7XXXXXXXX formats
  • 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

npm install medusa-payment-mpesa
# or
pnpm add medusa-payment-mpesa
# or
yarn add medusa-payment-mpesa

Configuration

1. Register the provider in Copy to clipboardmedusa-config.ts

import { 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

# Required
MPESA_CONSUMER_KEY=your_consumer_key
MPESA_CONSUMER_SECRET=your_consumer_secret
MPESA_BUSINESS_SHORT_CODE=174379
MPESA_PASS_KEY=your_pass_key
MPESA_CALLBACK_BASE_URL=https://your-backend.example.com
# Optional
MPESA_ENVIRONMENT=sandbox # Default: "sandbox" | Options: "sandbox" or "production"
MPESA_INITIATOR_NAME=testapi # Required only for refunds
MPESA_INITIATOR_PASSWORD=Safaricom123 # Required only for refunds
MPESA_WEBHOOK_SECRET=supersecret # Optional secret for webhook verification
Create app at Daraja Dashboard and copy the credentials.

3. Set up Kenya Region

  1. Go to Medusa Admin → Settings → Regions
  2. Click Create
  3. Configure the region:
    • Name: Kenya
    • Currency: KES (Kenyan Shilling)
    • Countries: Add Kenya under the Countries section
  4. Configure tax and payment provider settings as needed
  5. Click Save

4. Add M-Pesa Payment Provider to Kenya Region

  1. Go to Medusa Admin → Settings → Regions → Kenya → Edit
  2. Navigate to Payment Providers
  3. Add M-Pesa as a payment provider
  4. The M-Pesa provider will appear at checkout only for carts in the Kenya region

5. Configure Tax Region for Kenya

  1. Go to Medusa Admin → Settings → Tax Regions
  2. Click Create to add a new tax region
  3. Configure for Kenya with the appropriate tax rates

Payment Flow

sequenceDiagram
participant Customer
participant Storefront
participant Medusa
participant Daraja
Storefront->>Medusa: initiatePaymentSession({ phone_number })
Medusa->>Daraja: POST /mpesa/stkpush/v1/processrequest
Daraja-->>Medusa: { CheckoutRequestID, MerchantRequestID }
Medusa-->>Storefront: payment session created (id = CheckoutRequestID)
Daraja-->>Customer: STK Push prompt on phone
Note over Customer,Daraja: Customer has ~60s to accept STK Push
Storefront->>Medusa: GET /store/mpesa/status/:checkoutRequestId
Medusa->>Daraja: POST /mpesa/stkpushquery/v1/query
Daraja-->>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:

  1. Explicitly via Copy to clipboarddata.phone_number in the payment session (recommended)
  2. From the authenticated customer's saved Copy to clipboardphone field
await 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):

import { Phone } from "@medusajs/icons"
export const paymentInfoMap: Record<string, { title: string; icon: JSX.Element }> = {
// ... other providers
pp_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:

const isMpesa = (providerId?: string) => providerId === "pp_mpesa_mpesa";
const [mpesaPhone, setMpesaPhone] = useState("");
// Validates: 07XXXXXXXXX, +254XXXXXXXXX, or 254XXXXXXXXX
const phoneValid = /^(254[0-9]{9}|0[0-9]{9}|\+254[0-9]{9})$/.test(mpesaPhone);
// On checkout submit — pass phone_number in the session data
await 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:

const 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 clipboardauthorizePayment server-side will do one last status check to confirm before placing the order. See Copy to clipboardMpesaPaymentButton in 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.

sequenceDiagram
participant Admin
participant Medusa
participant Daraja
Admin->>Medusa: Create refund (order management)
Medusa->>Medusa: refundPayment — reads mpesa_receipt_number
Medusa->>Daraja: POST /mpesa/reversal/v1/request
Note 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 succeeds
Daraja->>Medusa: POST /store/mpesa/reversal-result (ResultCode 0)
Medusa-->>Medusa: Log success
else Reversal times out
Daraja->>Medusa: POST /store/mpesa/reversal-timeout
Medusa-->>Medusa: Log warning
end
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:

ngrok 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.

await 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

  1. Log in to the Daraja Developer Portal and create a sandbox app
  2. Use the sandbox credentials:
    • Short code: Copy to clipboard174379
    • Test phone: Copy to clipboard254708374149
    • Passkey: available on the Daraja portal under your app
  3. Use the portal's Simulate tab to trigger an STK Push callback without a real phone
  4. Confirm your callback URL is reachable (use ngrok locally) before testing

License

MIT © Elvis Gisiora

Built with Medusa v2 and the Safaricom Daraja API.

You may also like

Browse all integrations

Build your own

Develop your own custom integration

Build your own integration with our API to speed up your processes. Make your integration available via npm for it to be shared in our Library with the broader Medusa community.

gift card interface

Ready to build your custom commerce setup?