Singularity Payments LogoSingularity Payments

Account Balance Query

Query your M-Pesa account balance programmatically

Overview

The Account Balance API allows you to check your M-Pesa business account balance programmatically. This is useful for monitoring your account status, reconciliation, and automated balance tracking.

How It Works

  1. Initiate Request: Your application sends an account balance query to M-Pesa
  2. M-Pesa Processing: M-Pesa retrieves your current account balance information
  3. Receive Callback: M-Pesa sends the balance details to your result URL
  4. Process Result: Your application processes the balance information

Basic Usage

Server-Side

const response = await mpesa.client.accountBalance({
  partyA: "600998", // Optional: defaults to config.shortcode
  identifierType: "4", // Optional: defaults to "4" (Paybill)
  remarks: "Balance check",
  resultUrl:
    "https://sliverlike-unpredaceously-ariana.ngrok-free.dev/api/mpesa/balance-result",
  timeoutUrl:
    "https://sliverlike-unpredaceously-ariana.ngrok-free.dev/api/mpesa/balance-timeout",
});

console.log(response);
// {
//   ConversationID: "AG_20231222_00004123456789",
//   OriginatorConversationID: "12345-67890-1",
//   ResponseCode: "0",
//   ResponseDescription: "Accept the service request successfully."
// }

Client-Side

const { data, error } = await mpesaClient.accountBalance({
  partyA: "600998", // Optional: defaults to config.shortcode
  identifierType: "4", // Optional: defaults to "4" (Paybill)
  remarks: "Daily balance check",
  resultUrl:
    "https://sliverlike-unpredaceously-ariana.ngrok-free.dev/api/mpesa/balance-result",
  timeoutUrl:
    "https://sliverlike-unpredaceously-ariana.ngrok-free.dev/api/mpesa/balance-timeout",
});

if (data?.ResponseCode === "0") {
  console.log("Balance query accepted, awaiting callback");
} else {
  console.log("Query failed:", data?.ResponseDescription);
}

The resultUrl and timeoutUrl are required fields. It defaults to the urls in the config but if you are doing more than one type of mpesa transactions on your app, you will have different endpoints to handle each transaction.

If you are only doing B2C payments

You can update your env to this

MPESA_RESULT_URL=https://yourdomain.com/api/mpesa/balance-result
MPESA_TIMEOUT_URL=https://yourdomain.com/api/mpesa/balance-timeout

Real-World Example

Here's a complete example with database storage and callback handling:

import { db } from "./db";
import { balanceQueries } from "./schema";
import { mpesa } from "@/lib/mpesa";

interface BalanceCheckRequest {
  reason?: string;
}

async function checkAccountBalance(request: BalanceCheckRequest = {}) {
  try {
    // 1. Initiate balance query
    const response = await mpesa.client.accountBalance({
      remarks: request.reason || "Balance inquiry",
    });

    // 2. Store the query with pending status
    await db.insert(balanceQueries).values({
      conversationId: response.ConversationID,
      originatorConversationId: response.OriginatorConversationID,
      status: "pending",
      remarks: request.reason || "Balance inquiry",
      createdAt: new Date(),
    });

    return {
      success: true,
      conversationId: response.ConversationID,
      message: response.ResponseDescription,
    };
  } catch (error) {
    console.error("Balance query failed:", error);
    throw error;
  }
}

Handle the Callback

Configure callback handlers when initializing the M-Pesa client:

import { MpesaClient } from "@singularity-payments/nextjs";

const mpesa = new MpesaClient(
  {
    // ... your config
  },
  {
    callbackOptions: {
      // Called when balance query succeeds
      onAccountBalanceResult: async (data) => {
        console.log("Balance Retrieved:", {
          workingBalance: data.workingBalance,
          availableBalance: data.availableBalance,
          bookedBalance: data.bookedBalance,
        });

        // Update your database
        await db.balanceQueries.update({
          where: { conversationId: data.conversationId },
          data: {
            status: "completed",
            workingBalance: data.workingBalance,
            availableBalance: data.availableBalance,
            bookedBalance: data.bookedBalance,
            updatedAt: new Date(),
          },
        });

        // Send notification
        await notifyAdmin({
          subject: "Account Balance Update",
          balance: data.availableBalance,
        });
      },

      // Validate IP (recommended for production)
      validateIp: true,
    },
  },
);

API Reference

Account Balance Request

interface AccountBalanceRequest {
  partyA?: string; // Business shortcode to query
  identifierType?: BalanceIdentifierType; // Type of organization
  remarks?: string; // Query description
  resultUrl?: string; // Override default result URL
  timeoutUrl?: string; // Override default timeout URL
}

Parameters

ParameterTypeRequiredDescription
partyAstringNoBusiness shortcode. Defaults to config.shortcode
identifierTypeBalanceIdentifierTypeNoOrganization type. Defaults to "4" (Paybill)
remarksstringNoDescription of the query. Max 100 characters
resultUrlstringNoOverride the default callback URL for this query
timeoutUrlstringNoOverride the default timeout URL for this query

Identifier Types

TypeDescriptionUsage
1MSISDN (Phone Number)For individual accounts
2Till NumberFor Till numbers
4Paybill NumberFor Paybill numbers (Common)

Account Balance Response

interface AccountBalanceResponse {
  ConversationID: string; // Unique conversation ID
  OriginatorConversationID: string; // Your unique request ID
  ResponseCode: string; // "0" = success
  ResponseDescription: string; // Human-readable response
}

Response Codes

CodeDescriptionAction
0Success. Request acceptedWait for callback with balance
1RejectedCheck error message and retry
500.001.1001Invalid credentialsVerify initiator credentials

Callback Data Structure

When M-Pesa sends the result, you'll receive:

interface AccountBalanceCallbackData {
  isSuccess: boolean;
  workingBalance?: number; // Current working account balance
  availableBalance?: number; // Available balance for transactions
  bookedBalance?: number; // Reserved/booked funds
  errorMessage?: string; // Only present on failure
}

Balance Types Explained

  • Working Balance: The current balance in your working account
  • Available Balance: Funds available for transactions (Working Balance - Booked Balance)
  • Booked Balance: Funds reserved for pending transactions

Handling Callbacks

The default configuration already handles callbacks using a catch-all route.

Route Setup

Create the callback route at app/api/mpesa/[...mpesa]/route.ts:

import { mpesa } from "@/lib/mpesa";

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

This automatically handles:

  • /api/mpesa/balance-result - Balance query results
  • /api/mpesa/balance-timeout - Timeout notifications

Process Callbacks in Your Application

import { MpesaClient } from "@singularity-payments/nextjs";

const mpesa = new MpesaClient(
  {
    // ... your config
  },
  {
    callbackOptions: {
      onAccountBalanceResult: async (data) => {
        if (data.isSuccess) {
          console.log("Account Balance:", {
            available: data.availableBalance,
            working: data.workingBalance,
            booked: data.bookedBalance,
          });

          // Update database
          await db.accountBalance.create({
            data: {
              availableBalance: data.availableBalance,
              workingBalance: data.workingBalance,
              bookedBalance: data.bookedBalance,
              queriedAt: new Date(),
            },
          });

          // Alert if balance is low
          if (data.availableBalance && data.availableBalance < 10000) {
            await sendLowBalanceAlert(data.availableBalance);
          }
        } else {
          console.error("Balance query failed:", data.errorMessage);

          await db.balanceQueries.update({
            where: { conversationId: data.conversationId },
            data: {
              status: "failed",
              errorMessage: data.errorMessage,
            },
          });
        }
      },

      // Validate IP (recommended for production)
      validateIp: true,
    },
  },
);

Best Practices

1. Store Query History

Track all balance queries for audit purposes:

const response = await mpesa.client.accountBalance({
  remarks: "Automated daily check",
});

await db.balanceHistory.create({
  data: {
    conversationId: response.ConversationID,
    originatorConversationId: response.OriginatorConversationID,
    status: "PENDING",
    queriedBy: userId,
    queriedAt: new Date(),
  },
});

2. Implement Rate Limiting

Don't query balance too frequently:

const lastQuery = await db.balanceQueries.findFirst({
  orderBy: { createdAt: "desc" },
});

const timeSinceLastQuery = Date.now() - lastQuery.createdAt.getTime();
const MIN_INTERVAL = 5 * 60 * 1000; // 5 minutes

if (timeSinceLastQuery < MIN_INTERVAL) {
  throw new Error("Please wait before querying balance again");
}

Track balance changes over time:

callbackOptions: {
  onAccountBalanceResult: async (data) => {
    if (data.isSuccess) {
      // Get previous balance
      const previous = await db.balanceSnapshots
        .findFirst({
          orderBy: { createdAt: "desc" },
        });

      // Calculate change
      const change = previous
        ? data.availableBalance - previous.availableBalance
        : 0;

      // Store snapshot
      await db.balanceSnapshots.create({
        data: {
          availableBalance: data.availableBalance,
          workingBalance: data.workingBalance,
          change,
          createdAt: new Date(),
        },
      });

      // Alert on significant changes
      if (Math.abs(change) > 50000) {
        await notifySignificantBalanceChange(change);
      }
    }
  },
}

4. Handle Timeouts

Account for queries that timeout:

// Set up timeout tracking
await db.balanceQueries.create({
  data: {
    conversationId: response.ConversationID,
    status: "pending",
    timeoutAt: new Date(Date.now() + 2 * 60 * 1000), // 2 minutes
  },
});

// Background job to check for timeouts
setInterval(async () => {
  const timedOut = await db.balanceQueries.findMany({
    where: {
      status: "pending",
      timeoutAt: { lt: new Date() },
    },
  });

  for (const query of timedOut) {
    await db.balanceQueries.update({
      where: { id: query.id },
      data: { status: "timeout" },
    });

    await notifyBalanceQueryTimeout(query);
  }
}, 60000); // Check every minute

Automated Balance Monitoring

Set up automated balance checks:

import { CronJob } from "cron";

// Check balance every 6 hours
const balanceCheckJob = new CronJob("0 */6 * * *", async () => {
  try {
    console.log("Running scheduled balance check");

    const response = await mpesa.client.accountBalance({
      remarks: "Automated scheduled check",
    });

    await db.scheduledChecks.create({
      data: {
        conversationId: response.ConversationID,
        type: "balance",
        scheduledAt: new Date(),
      },
    });
  } catch (error) {
    console.error("Scheduled balance check failed:", error);
  }
});

balanceCheckJob.start();

Troubleshooting

"Invalid Initiator Credentials"

Cause: Incorrect initiator name or security credential

Solution:

# Verify credentials
echo $MPESA_INITIATOR_NAME
echo $MPESA_SECURITY_CREDENTIAL

# Regenerate security credential from developer portal if needed

"Invalid ShortCode"

Cause: Incorrect shortcode for environment

Solution:

// Sandbox
shortcode: "174379";

// Production
shortcode: "YOUR_PAYBILL_NUMBER";

Callback Not Received

Causes:

  1. Result URL not publicly accessible
  2. Firewall blocking Safaricom IPs
  3. Route not properly configured

Solutions:

# 1. Use ngrok for local development
ngrok http 3000

# 2. Verify route exists
# app/api/mpesa/[...mpesa]/route.ts

# 3. Check Safaricom IPs are allowed
# 196.201.214.200, 196.201.214.206, etc.

Balance Shows Zero

Cause: Querying wrong shortcode or account not active

Solution:

  • Verify you're querying the correct shortcode
  • Ensure the account has been activated by Safaricom
  • Check that you have the correct identifier type
  • Verify initiator has permission to query balance

Result Codes

Common result codes in callbacks:

CodeDescriptionMeaning
0SuccessBalance retrieved successfully
1Insufficient fundsNot applicable for balance queries
11Invalid parametersCheck initiator credentials and shortcode
17System errorTry again later

Security Considerations

  1. Restrict Access: Only allow authorized users to query balance
  2. Validate IPs: Always validate callback IPs in production
  3. Encrypt Storage: Encrypt balance data in your database
  4. Audit Trail: Log all balance queries and access
  5. Alert on Anomalies: Set up alerts for unusual balance changes
// Example security implementation
async function checkBalance(userId: string) {
  // 1. Verify user has permission
  const user = await db.users.findUnique({ where: { id: userId } });
  if (!user.canViewBalance) {
    throw new Error("Unauthorized");
  }

  // 2. Log the action
  await db.auditLogs.create({
    data: {
      userId,
      action: "BALANCE_QUERY",
      timestamp: new Date(),
    },
  });

  // 3. Execute query
  return await mpesa.client.accountBalance({
    remarks: `Queried by ${user.email}`,
  });
}

Additional Resources

Edit on GitHub

On this page