Singularity Payments LogoSingularity Payments
Getting Started

Quick Start

Get started with M-Pesa payments in your Next.js app in minutes.

Quick Start

Get M-Pesa payments working in your Next.js app in under 5 minutes.

Installation

Install both the Next.js server package and React client package:

npm install @singularity-payments/nextjs @singularity-payments/react
pnpm add @singularity-payments/nextjs @singularity-payments/react
yarn add @singularity-payments/nextjs @singularity-payments/react
bun add @singularity-payments/nextjs @singularity-payments/react

Setup Ngrok for Testing

M-Pesa requires a public callback URL to send payment notifications. For local development, use ngrok:

Install ngrok from ngrok.com

Start ngrok on your dev port:

ngrok http 3000

Copy the forwarding URL (e.g., https://abc123.ngrok-free.app)

Keep ngrok running while testing. M-Pesa callbacks will fail without it.

Environment Setup

Create a .env.local file in your project root:

.env.local
MPESA_CONSUMER_KEY=your_consumer_key
MPESA_CONSUMER_SECRET=your_consumer_secret
MPESA_SHORTCODE=your_shortcode
MPESA_PASSKEY=your_passkey
MPESA_ENVIRONMENT=sandbox
NEXT_PUBLIC_APP_URL=https://your-ngrok-url.ngrok-free.app

Never commit your .env file to version control. Add it to .gitignore.

Configure M-Pesa Client

Create a server configuration file:

lib/mpesa.ts
import { createMpesa } from "@singularity-payments/nextjs";

export const mpesa = createMpesa(
  {
    consumerKey: process.env.MPESA_CONSUMER_KEY!,
    consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
    passkey: process.env.MPESA_PASSKEY!,
    shortcode: process.env.MPESA_SHORTCODE!,
    environment:
      (process.env.MPESA_ENVIRONMENT as "sandbox" | "production") || "sandbox",
    callbackUrl: `${process.env.NEXT_PUBLIC_APP_URL}/api/mpesa/callback`,
  },
  {
    callbackOptions: {
      onSuccess: async (data) => {
        console.log("Payment successful:", data);
      },
      onFailure: async (data) => {
        console.log("Payment failed:", data);
      },
    },
  },
);

Create API Route

Create a catch-all API route to handle all M-Pesa operations:

app/api/mpesa/[...mpesa]/route.ts
import { mpesa } from "~/lib/mpesa";

export const { POST } = mpesa.handlers.catchAll;

This single route handles all M-Pesa operations including callbacks, payment initiation, and status queries.

Create Payment Form

Create a client component for handling payments:

components/payment-form.tsx
"use client";

import { useState } from "react";
import { useMpesaPayment, usePaymentStatus } from "@singularity-payments/react";

export function PaymentForm() {
  const [amount, setAmount] = useState("100");
  const [phoneNumber, setPhoneNumber] = useState("254712345678");

  const { initiatePayment, state, CheckoutRequestID, error, reset } =
    useMpesaPayment();
  const { status, isSuccess, isFailed } = usePaymentStatus({
    CheckoutRequestID,
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await initiatePayment({
      amount: Number(amount),
      phoneNumber,
      accountReference: "ORDER001",
      transactionDesc: "Payment",
    });
  };

  if (isSuccess) {
    return (
      <div className="mx-auto max-w-md rounded-lg border border-green-600 bg-green-50 p-6 dark:border-green-400 dark:bg-green-950">
        <h3 className="mb-2 text-lg font-semibold text-green-900 dark:text-green-100">
          Payment Successful
        </h3>
        <p className="mb-4 text-sm text-green-800 dark:text-green-200">
          {status?.ResultDesc}
        </p>
        <button
          onClick={reset}
          className="w-full rounded-md border border-green-600 bg-transparent px-4 py-2 text-green-900 hover:bg-green-100 dark:border-green-400 dark:text-green-100 dark:hover:bg-green-900"
        >
          Make Another Payment
        </button>
      </div>
    );
  }

  if (isFailed && status) {
    return (
      <div className="mx-auto max-w-md rounded-lg border border-red-600 bg-red-50 p-6 dark:border-red-400 dark:bg-red-950">
        <h3 className="mb-2 text-lg font-semibold text-red-900 dark:text-red-100">
          Payment Failed
        </h3>
        <p className="mb-4 text-sm text-red-800 dark:text-red-200">
          {status.ResultDesc}
        </p>
        <button
          onClick={reset}
          className="w-full rounded-md border border-red-600 bg-transparent px-4 py-2 text-red-900 hover:bg-red-100 dark:border-red-400 dark:text-red-100 dark:hover:bg-red-900"
        >
          Try Again
        </button>
      </div>
    );
  }

  return (
    <div className="mx-auto max-w-md rounded-lg border p-6">
      <h2 className="mb-6 text-2xl font-bold">M-Pesa Payment</h2>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="mb-2 block text-sm font-medium">Amount (KES)</label>
          <input
            type="number"
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
            min="1"
            required
            disabled={state !== "idle"}
            className="w-full rounded-md border px-3 py-2"
          />
        </div>

        <div>
          <label className="mb-2 block text-sm font-medium">Phone Number</label>
          <input
            type="tel"
            value={phoneNumber}
            onChange={(e) => setPhoneNumber(e.target.value)}
            placeholder="254712345678"
            required
            disabled={state !== "idle"}
            className="w-full rounded-md border px-3 py-2"
          />
        </div>

        <button
          type="submit"
          disabled={state !== "idle"}
          className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
        >
          {state === "initiating" && "Processing..."}
          {state === "pending" && "Check Your Phone"}
          {state === "idle" && "Pay with M-Pesa"}
        </button>
      </form>

      {error && (
        <div className="mt-4 rounded-md border border-red-600 bg-red-50 p-3 dark:border-red-400 dark:bg-red-950">
          <p className="text-sm text-red-800 dark:text-red-200">
            {error.message}
          </p>
        </div>
      )}
    </div>
  );
}

Use in Your Page

Import and use the payment form in any page:

app/page.tsx
import { PaymentForm } from "~/components/payment-form";

export default function Home() {
  return (
    <main className="flex min-h-screen items-center justify-center">
      <PaymentForm />
    </main>
  );
}

Test Your Integration

Start your development server

npm run dev

Start ngrok in another terminal

ngrok http 3000

Update your .env.local with the ngrok URL and restart your dev server

Navigate to your page with the payment form

Enter a test amount and phone number

Click Pay with M-Pesa and check your phone for the STK Push prompt

Enter your M-Pesa PIN to complete the payment

In sandbox mode, use the test credentials provided by Safaricom Daraja.

What's Next

Edit on GitHub

On this page