Medusa v2 M-Pesa (Safaricom Daraja) payment provider — STK Push, polling and refunds
A Medusa v2 Payment Provider plugin for M-Pesa Daraja API (Safaricom Kenya).
Features:
1npm install medusa-payment-mpesa2# or3pnpm add medusa-payment-mpesa4# or5yarn add medusa-payment-mpesa
1import { loadEnv, defineConfig } from "@medusajs/framework/utils";2import type { MpesaOptions } from "medusa-payment-mpesa";34loadEnv(process.env.NODE_ENV || "development", process.cwd());56module.exports = defineConfig({7 // ...8 modules: [9 {10 resolve: "@medusajs/medusa/payment",11 options: {12 providers: [13 {14 resolve: "medusa-payment-mpesa/providers/mpesa",15 id: "mpesa",16 options: {17 consumer_key: process.env.MPESA_CONSUMER_KEY,18 consumer_secret: process.env.MPESA_CONSUMER_SECRET,19 business_short_code: process.env.MPESA_BUSINESS_SHORT_CODE,20 pass_key: process.env.MPESA_PASS_KEY,21 // Defaults to "sandbox" if not set22 environment: process.env.MPESA_ENVIRONMENT || "sandbox",23 // Must be publicly reachable — Daraja POSTs callbacks here24 callback_base_url:25 process.env.MPESA_CALLBACK_BASE_URL || process.env.BACKEND_URL,26 // Required only for refunds (M-Pesa reversals):27 initiator_name: process.env.MPESA_INITIATOR_NAME,28 initiator_password: process.env.MPESA_INITIATOR_PASSWORD,29 } satisfies MpesaOptions,30 },31 ],32 },33 },34 ],35});
1# Required2MPESA_CONSUMER_KEY=your_consumer_key3MPESA_CONSUMER_SECRET=your_consumer_secret4MPESA_BUSINESS_SHORT_CODE=1743795MPESA_PASS_KEY=your_pass_key6MPESA_CALLBACK_BASE_URL=https://your-backend.example.com78# Optional9MPESA_ENVIRONMENT=sandbox # Default: "sandbox" | Options: "sandbox" or "production"10MPESA_INITIATOR_NAME=testapi # Required only for refunds11MPESA_INITIATOR_PASSWORD=Safaricom123 # Required only for refunds12MPESA_WEBHOOK_SECRET=supersecret # Optional secret for webhook verification
Create app at Daraja Dashboard and copy the credentials.
1sequenceDiagram2 participant Customer3 participant Storefront4 participant Medusa5 participant Daraja67 Storefront->>Medusa: initiatePaymentSession({ phone_number })8 Medusa->>Daraja: POST /mpesa/stkpush/v1/processrequest9 Daraja-->>Medusa: { CheckoutRequestID, MerchantRequestID }10 Medusa-->>Storefront: payment session created (id = CheckoutRequestID)11 Daraja-->>Customer: STK Push prompt on phone1213 Note over Customer,Daraja: Customer has ~60s to accept STK Push1415 Storefront->>Medusa: GET /store/mpesa/status/:checkoutRequestId16 Medusa->>Daraja: POST /mpesa/stkpushquery/v1/query17 Daraja-->>Medusa: { ResultCode }18 Medusa-->>Storefront: { status: "pending" | "paid" | "cancelled" | "error" }1920 alt Customer paid (status: paid)21 Daraja->>Medusa: POST /store/mpesa/callback (async, may arrive later)22 Storefront->>Medusa: placeOrder23 Medusa-->>Medusa: authorizePayment → status: authorized24 else status: cancelled or error25 Storefront-->>Customer: Show error / retry26 else status: pending27 Storefront->>Medusa: placeOrder (authorizePayment re-queries Daraja)28 end
The provider normalizes all inputs to the (12-digit) format required by Daraja. Any non-digit characters (spaces, dashes, ) are stripped first.
| Input Format | Example | Normalized |
|---|---|---|
| Local with leading 0 | ||
| International | ||
| International | ||
| 9-digit without prefix | ||
| New prefix |
Numbers that don't match any of these patterns are rejected with a error.
The phone number can be passed in two ways — the first available is used:
1await sdk.store.payment.initiatePaymentSession(cart, {2 provider_id: "pp_mpesa_mpesa",3 data: {4 phone_number: "0712345678", // any supported format5 },6});
All four public routes are registered automatically by the plugin without any manual configuration.
| Method | Path | Auth | Description |
|---|---|---|---|
| Public | Daraja STK Push result callback | ||
| Public | Storefront payment status polling | ||
| Public | Daraja reversal result callback | ||
| Public | Daraja reversal timeout callback |
Configure these URLs in your Daraja app settings:
| Daraja Setting | Value |
|---|---|
| Callback URL | |
| Result URL (reversals) | |
| Queue Timeout URL |
Note: The callback URLs must be publicly accessible HTTPS endpoints. For local development, use a tunneling service like ngrok.
Add the provider to your payment info map (e.g. ):
1import { Phone } from "@medusajs/icons"23export const paymentInfoMap: Record<string, { title: string; icon: JSX.Element }> = {4 // ... other providers5 pp_mpesa_mpesa: { title: "M-Pesa", icon: <Phone /> },6}
When M-Pesa is selected, show a phone number input before the customer can submit:
1const isMpesa = (providerId?: string) => providerId === "pp_mpesa_mpesa";23const [mpesaPhone, setMpesaPhone] = useState("");4// Validates: 07XXXXXXXXX, +254XXXXXXXXX, or 254XXXXXXXXX5const phoneValid = /^(254[0-9]{9}|0[0-9]{9}|\+254[0-9]{9})$/.test(mpesaPhone);67// On checkout submit — pass phone_number in the session data8await sdk.store.payment.initiatePaymentSession(cart, {9 provider_id: "pp_mpesa_mpesa",10 data: { phone_number: mpesaPhone },11});
After the payment step is submitted, the customer has ~60 seconds to accept the M-Pesa STK Push prompt on their phone. Before calling , do a single status check and surface terminal failures immediately:
1const BACKEND_URL = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL;23type MpesaStatusResponse = {4 status: "paid" | "pending" | "cancelled" | "error";5 result_code: string | null;6 result_desc: string | null;7};89async function checkMpesaStatus(10 checkoutRequestId: string,11): Promise<MpesaStatusResponse> {12 const res = await fetch(13 `${BACKEND_URL}/store/mpesa/status/${encodeURIComponent(checkoutRequestId)}`,14 );15 return res.json();16}1718// In your payment button handler:19const { status, result_desc } = await checkMpesaStatus(checkoutRequestId);20if (status === "cancelled" || status === "error") {21 // Show result_desc to the customer and let them retry22 throw new Error(result_desc ?? "Payment was not completed.");23}24// status "paid" or "pending" → proceed; authorizePayment re-queries Daraja on placeOrder25await placeOrder();
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 that updates every poll. Break early if status becomes , or abort if it becomes /. If it reaches 90 s with no success, let the customer click "Place order" anyway — the final server-side will do one last status check to confirm before placing the order. See in storefront for reference implementation.
Refunds are processed as M-Pesa reversals and are asynchronous — Daraja sends the result to after processing. The (from the original payment callback) is required.
1sequenceDiagram2 participant Admin3 participant Medusa4 participant Daraja56 Admin->>Medusa: Create refund (order management)7 Medusa->>Medusa: refundPayment — reads mpesa_receipt_number8 Medusa->>Daraja: POST /mpesa/reversal/v1/request9 Note over Medusa,Daraja: SecurityCredential = RSA PKCS#1 v1.5<br/>initiator_password (production)<br/>base64 (sandbox)10 Daraja-->>Medusa: { ConversationID, ResponseCode: "0" }11 Medusa-->>Admin: Refund initiated (ConversationID stored)1213 alt Reversal succeeds14 Daraja->>Medusa: POST /store/mpesa/reversal-result (ResultCode 0)15 Medusa-->>Medusa: Log success16 else Reversal times out17 Daraja->>Medusa: POST /store/mpesa/reversal-timeout18 Medusa-->>Medusa: Log warning19 end
Note: A refund will fail if 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.
These are the M-Pesa STK Push result codes returned by Daraja and how this plugin maps them:
| Result Code | Meaning | response | status |
|---|---|---|---|
| Success | |||
| Request cancelled by user | |||
| Timeout — user did not respond | (terminal) | ||
| Wrong PIN entered | (terminal) | ||
| Transaction expired | (terminal) | ||
| Internal switch error | (terminal) | ||
| other | Transaction not yet settled |
Terminal codes (, , , , ) will never succeed on retry and are mapped to immediately rather than being polled again.
Production M-Pesa reversals require the initiator password to be encrypted with Safaricom's public certificate using RSA PKCS#1 v1.5 (). This is Safaricom's mandated scheme for the field — it is not configurable. Download from the Safaricom Developer Portal and place it in the root of your Medusa backend (same directory as , where resolves).
The plugin handles encryption automatically — sandbox uses base64, production uses RSA PKCS#1 v1.5. You do not need to encrypt it yourself.
must be an HTTPS URL reachable from Safaricom's servers. For local development, use a tunneling service:
1ngrok http 90002# Then set: MPESA_CALLBACK_BASE_URL=https://xxxx.ngrok.io
The field in STK Push requests is capped at 12 characters by Daraja. If you pass in the payment session data, it will be truncated automatically. This is purely a Daraja label shown on the customer's M-Pesa statement.
1await sdk.store.payment.initiatePaymentSession(cart, {2 provider_id: "pp_mpesa_mpesa",3 data: {4 phone_number: "0712345678",5 order_id: cart.id, // truncated to 12 chars — used as AccountReference6 },7});
MIT © Elvis Gisiora
Built with Medusa v2 and the Safaricom Daraja API.