Singularity Payments LogoSingularity Payments

Transaction Status

Query the status of any M-Pesa transaction using Transaction ID

Overview

Transaction Status allows you to query the current status of any M-Pesa transaction using its Transaction ID. This is useful for verifying B2C, B2B, reversals, or any other M-Pesa transaction that didn't complete as expected or when you need to confirm transaction details.

Where to Get the Transaction ID

The Transaction ID comes from the callback of the original transaction, NOT from the initial response. Here's how:

From B2C Callback

callbackOptions: {
  onB2CResult: async (data) => {
    if (data.isSuccess) {
      const transactionId = data.transactionId;

      // Store this for future status queries
      await db.b2cTransactions.update({
        where: { conversationId: data.conversationId },
        data: {
          mpesaTransactionId: transactionId,
          status: "completed",
        },
      });
    }
  },
}

From B2B Callback

callbackOptions: {
  onB2BResult: async (data) => {
    if (data.isSuccess) {
      const transactionId = data.transactionId;

      // Store this for future status queries
      await db.b2bTransactions.update({
        where: { conversationId: data.conversationId },
        data: {
          mpesaTransactionId: transactionId,
          status: "completed",
        },
      });
    }
  },
}

Important Notes

  • The initial response ConversationID is NOT the transaction ID
  • Transaction ID is only available after M-Pesa processes the payment
  • Always store the transaction ID from the callback for future queries
  • If you don't have the transaction ID, you cannot use this API

How It Works

  1. Initiate Query: Your application sends a transaction status request with the Transaction ID
  2. M-Pesa Processes: M-Pesa looks up the transaction in their system
  3. Receive Callback: M-Pesa sends the transaction details to your result URL
  4. Process Result: Your application processes the transaction information

Use Cases

  • Confirm reversal completion
  • Investigate failed transactions
  • Audit and reconciliation purposes
  • Customer support inquiries

Basic Usage

Server-Side

const response = await mpesa.client.transactionStatus({
  transactionID: "PGK1234567",
  remarks: "Checking transaction status",
  resultUrl: "https://yourdomain.ngrok.com/status-result",
  timeoutUrl: "https://yourdomain.ngrok.com/status-timeout",
});

Client-Side

const response = await mpesaClient.transactionStatus({
  transactionID: "PGK1234567",
  remarks: "Checking transaction status",
  resultUrl: "https://yourdomain.ngrok.com/status-result",
  timeoutUrl: "https://yourdomain.ngrok.com/status-timeout",
});

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/status-result
MPESA_TIMEOUT_URL=https://yourdomain.com/api/mpesa/status-timeout

The response will look like this if successful:

{
  "ConversationID": "AG_20251231_00004e9b3e3e3e3e3e3e",
  "OriginatorConversationID": "9e2d-4d13-b15f-cbf9a0b7e00f10096",
  "ResponseCode": "0",
  "ResponseDescription": "Accept the service request successfully."
}

Real-World Example

In a real application, you might want to check transaction status when callbacks are delayed or missing:

import { db } from "./db";
import { transactions, statusChecks } from "./schema";
import { eq } from "drizzle-orm";
import { mpesa } from "@/lib/mpesa";

interface StatusCheckRequest {
  transactionId: string;
  reason: string;
}

async function checkTransactionStatus(request: StatusCheckRequest) {
  const { transactionId, reason } = request;

  try {
    // Verify transaction exists in local database
    const existingTransaction = await db
      .select()
      .from(transactions)
      .where(eq(transactions.mpesaTransactionId, transactionId))
      .limit(1);

    if (existingTransaction.length === 0) {
      throw new Error("Transaction not found in local database");
    }

    // Query M-Pesa for transaction status
    const response = await mpesa.client.transactionStatus({
      transactionID: transactionId,
      remarks: reason || "Status verification",
      occasion: "Transaction audit",
    });

    // Record the status check
    await db.insert(statusChecks).values({
      conversationId: response.ConversationID,
      originatorConversationId: response.OriginatorConversationID,
      transactionId: transactionId,
      localTransactionId: existingTransaction[0].id,
      reason: reason,
      status: "pending",
      createdAt: new Date(),
    });

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

Complete Flow: From B2C Payment to Status Check

Here's the complete flow showing where the transaction ID comes from:

// 1. Initiate B2C payment
async function processB2CPayment(amount: number, phoneNumber: string) {
  const response = await mpesa.client.b2c({
    amount,
    phoneNumber,
    commandID: "BusinessPayment",
    remarks: "Payment to customer",
  });

  // Save with ConversationID (not transaction ID yet)
  await db.b2cTransactions.create({
    data: {
      conversationId: response.ConversationID,
      status: "pending",
      amount,
      phoneNumber,
    },
  });
}

// 2. Handle callback to get transaction ID
callbackOptions = {
  onB2CResult: async (data) => {
    if (data.isSuccess) {
      // NOW we have the transaction ID
      await db.b2cTransactions.update({
        where: { conversationId: data.conversationId },
        data: {
          mpesaTransactionId: data.transactionId, // Save this!
          status: "completed",
        },
      });
    }
  },
};

// 3. Later, check transaction status
async function checkIfPaymentWentThrough(conversationId: string) {
  const transaction = await db.b2cTransactions.findUnique({
    where: { conversationId },
  });

  if (!transaction.mpesaTransactionId) {
    throw new Error("Transaction ID not yet available. Wait for callback.");
  }

  // Now we can query the status
  return await mpesa.client.transactionStatus({
    transactionID: transaction.mpesaTransactionId,
    remarks: "Verifying payment status",
  });
}

Automated Status Check for Stuck Transactions

import { and, lt, eq } from "drizzle-orm";

async function checkStuckTransactions() {
  const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);

  // Find transactions that are still pending after 5 minutes
  const stuckTransactions = await db
    .select()
    .from(transactions)
    .where(
      and(
        eq(transactions.status, "pending"),
        lt(transactions.createdAt, fiveMinutesAgo),
      ),
    );

  for (const transaction of stuckTransactions) {
    try {
      await mpesa.client.transactionStatus({
        transactionID: transaction.mpesaTransactionId,
        remarks: "Automated status check for stuck transaction",
      });

      // Update check attempts
      await db
        .update(transactions)
        .set({
          statusCheckAttempts: transaction.statusCheckAttempts + 1,
          lastStatusCheck: new Date(),
        })
        .where(eq(transactions.id, transaction.id));
    } catch (error) {
      console.error(
        `Failed to check status for transaction ${transaction.id}:`,
        error,
      );
    }
  }
}

Handling Transaction Status Callbacks

callbackOptions = {
  onTransactionStatusResult: async (data) => {
    if (data.isSuccess) {
      console.log("Transaction Status Retrieved:", {
        receiptNo: data.receiptNo,
        amount: data.amount,
        completedTime: data.completedTime,
        originatorConversationId: data.originatorConversationId,
      });

      // Update the status check record
      await db
        .update(statusChecks)
        .set({
          status: "completed",
          receiptNo: data.receiptNo,
          amount: data.amount,
          completedTime: data.completedTime,
          updatedAt: new Date(),
        })
        .where(
          eq(
            statusChecks.originatorConversationId,
            data.originatorConversationId,
          ),
        );

      // Update the original transaction
      const statusCheck = await db
        .select()
        .from(statusChecks)
        .where(
          eq(
            statusChecks.originatorConversationId,
            data.originatorConversationId,
          ),
        )
        .limit(1);

      if (statusCheck.length > 0) {
        await db
          .update(transactions)
          .set({
            status: "completed",
            mpesaReceiptNumber: data.receiptNo,
            actualAmount: data.amount,
            completedAt: new Date(data.completedTime),
            updatedAt: new Date(),
          })
          .where(eq(transactions.id, statusCheck[0].localTransactionId));
      }
    } else {
      console.log("Transaction Status Query Failed:", {
        errorMessage: data.errorMessage,
      });

      await db
        .update(statusChecks)
        .set({
          status: "failed",
          errorMessage: data.errorMessage,
          updatedAt: new Date(),
        })
        .where(
          eq(
            statusChecks.originatorConversationId,
            data.originatorConversationId,
          ),
        );
    }
  },
};

API Reference

Transaction Status Request

interface GeneralTransactionStatusRequest {
  transactionID: string;
  partyA?: string;
  identifierType?: "1" | "2" | "4";
  remarks?: string;
  occasion?: string;
  resultUrl?: string;
  timeoutUrl?: string;
}

Parameters

ParameterTypeRequiredDescription
transactionIDstringYesM-Pesa transaction ID to query (e.g., PGK1234567)
partyAstringNoOrganization receiving the transaction. Defaults to your shortcode
identifierTypestringNoType of organization: 1 (MSISDN), 2 (Till Number), 4 (Paybill). Defaults to 4
remarksstringNoComments about the query. Defaults to "Transaction status query"
occasionstringNoOptional additional information
resultUrlstringNoOverride the default result callback URL
timeoutUrlstringNoOverride the default timeout callback URL

Identifier Types

CodeDescriptionWhen to Use
1MSISDNQuerying transactions for a phone number
2Till NumberQuerying Buy Goods transactions
4PaybillQuerying Paybill transactions (default)

Transaction Status Response

interface GeneralTransactionStatusResponse {
  ConversationID: string;
  OriginatorConversationID: string;
  ResponseCode: string;
  ResponseDescription: string;
}

Response Codes

CodeDescriptionAction
0Success. Request acceptedWait for callback with transaction details
1RejectedCheck error message and retry
500.001.1001Invalid credentialsVerify initiator name/security credential
400.008.02Invalid transaction IDVerify the transaction ID is correct

Handling Callbacks

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

Callback Configuration

Default configuration already handles Transaction Status callbacks

Process Callbacks in Your Application

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

const mpesa = new MpesaClient(
  {
    // ... your config
  },
  {
    callbackOptions: {
      onTransactionStatusResult: async (data) => {
        if (data.isSuccess) {
          console.log("Transaction found:", {
            receiptNo: data.receiptNo,
            amount: data.amount,
            completedTime: data.completedTime,
            originatorConversationId: data.originatorConversationId,
          });

          // Update your database
          await db.transactionStatusQueries.update({
            where: { originatorConversationId: data.originatorConversationId },
            data: {
              status: "completed",
              receiptNo: data.receiptNo,
              amount: data.amount,
              completedTime: new Date(data.completedTime),
            },
          });

          // Find and update the original transaction
          const originalTransaction = await db.transactions.findFirst({
            where: { mpesaTransactionId: data.receiptNo },
          });

          if (originalTransaction) {
            await db.transactions.update({
              where: { id: originalTransaction.id },
              data: {
                status: "completed",
                verifiedAt: new Date(),
              },
            });
          }
        } else {
          console.log("Transaction not found or error:", {
            errorMessage: data.errorMessage,
          });

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

      validateIp: true,
    },
  },
);

Callback Data Structure

interface TransactionStatusCallbackData {
  isSuccess: boolean;
  receiptNo?: string;
  amount?: number;
  completedTime?: string;
  originatorConversationId?: string;
  errorMessage?: string;
}

Best Practices

1. Store Status Query Metadata

Always track your status queries to avoid redundant checks:

const response = await mpesa.transactionStatus({
  transactionID: "PGK1234567",
  remarks: "Status check",
});

// Track the query
await db.statusQuery.create({
  data: {
    conversationId: response.ConversationID,
    originatorConversationId: response.OriginatorConversationID,
    transactionId: "PGK1234567",
    status: "PENDING",
    queriedAt: new Date(),
  },
});

2. Implement Query Rate Limiting

Don't query the same transaction too frequently:

async function canQueryTransaction(transactionId: string): Promise<boolean> {
  const recentQuery = await db.statusQuery.findFirst({
    where: {
      transactionId,
      queriedAt: {
        gte: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes
      },
    },
  });

  return !recentQuery;
}

async function safeTransactionStatusCheck(transactionId: string) {
  if (!(await canQueryTransaction(transactionId))) {
    throw new Error("Transaction queried too recently. Please wait 2 minutes.");
  }

  return await mpesa.client.transactionStatus({
    transactionID: transactionId,
    remarks: "Status check",
  });
}

3. Use for Reconciliation

Schedule regular reconciliation checks for pending transactions:

import { CronJob } from "cron";

// Run every 30 minutes
const reconciliationJob = new CronJob("0 */30 * * * *", async () => {
  const pendingTransactions = await db.transaction.findMany({
    where: {
      status: "PENDING",
      createdAt: {
        // Older than 10 minutes but less than 24 hours
        gte: new Date(Date.now() - 24 * 60 * 60 * 1000),
        lte: new Date(Date.now() - 10 * 60 * 1000),
      },
    },
  });

  for (const transaction of pendingTransactions) {
    try {
      await mpesa.client.transactionStatus({
        transactionID: transaction.mpesaTransactionId,
        remarks: "Scheduled reconciliation check",
      });
    } catch (error) {
      console.error(`Reconciliation failed for ${transaction.id}:`, error);
    }
  }
});

reconciliationJob.start();

4. Handle Transaction Not Found

Not all transaction IDs will be found in M-Pesa's system:

callbackOptions: {
  onTransactionStatusResult: async (data) => {
    if (!data.isSuccess) {
      if (data.errorMessage?.includes("not found")) {
        // Transaction doesn't exist in M-Pesa
        await db.transaction.update({
          where: { mpesaTransactionId: transactionId },
          data: {
            status: "NOT_FOUND",
            notes: "Transaction not found in M-Pesa system",
          },
        });

        // Notify support team
        await notifySupport({
          type: "transaction_not_found",
          transactionId,
        });
      }
    }
  },
}

5. Audit Trail

Maintain a complete audit trail of all status checks:

async function auditTransactionStatusCheck(
  transactionId: string,
  userId: string,
  reason: string,
) {
  // Log the status check attempt
  await db.auditLog.create({
    data: {
      action: "TRANSACTION_STATUS_CHECK",
      userId,
      transactionId,
      reason,
      timestamp: new Date(),
      ipAddress: request.ip,
    },
  });

  return await mpesa.client.transactionStatus({
    transactionID: transactionId,
    remarks: reason,
  });
}

Common Scenarios

Scenario 1: Verify B2C Payment

async function verifyB2CPayment(b2cTransactionId: string) {
  const b2cRecord = await db.b2cTransaction.findUnique({
    where: { id: b2cTransactionId },
  });

  if (!b2cRecord.mpesaTransactionId) {
    throw new Error("No M-Pesa transaction ID available yet");
  }

  return await mpesa.client.transactionStatus({
    transactionID: b2cRecord.mpesaTransactionId,
    remarks: "Verifying B2C payment completion",
  });
}

Scenario 2: Customer Support Query

async function handleCustomerInquiry(customerId: string, mpesaCode: string) {
  // Verify the transaction belongs to this customer
  const customerTransaction = await db.transaction.findFirst({
    where: {
      customerId,
      mpesaTransactionId: mpesaCode,
    },
  });

  if (!customerTransaction) {
    throw new Error("Transaction not found for this customer");
  }

  return await mpesa.client.transactionStatus({
    transactionID: mpesaCode,
    remarks: `Customer support inquiry for customer ${customerId}`,
  });
}

Scenario 3: Batch Status Check

async function batchStatusCheck(transactionIds: string[]) {
  const results = [];

  for (const transactionId of transactionIds) {
    try {
      const response = await mpesa.client.transactionStatus({
        transactionID: transactionId,
        remarks: "Batch reconciliation",
      });

      results.push({
        transactionId,
        success: true,
        conversationId: response.ConversationID,
      });

      // Rate limiting: wait 1 second between requests
      await new Promise((resolve) => setTimeout(resolve, 1000));
    } catch (error) {
      results.push({
        transactionId,
        success: false,
        error: error.message,
      });
    }
  }

  return results;
}

Troubleshooting

"Invalid Initiator"

Cause: Incorrect initiator name or security credential

Solution:

# Verify your environment variables
echo $MPESA_INITIATOR_NAME
echo $MPESA_SECURITY_CREDENTIAL

Verify these match your M-Pesa portal configuration exactly.

"Transaction Not Found"

Cause: Transaction ID doesn't exist in M-Pesa system or is too old

Solution:

  • Verify the transaction ID is correct (check for typos)
  • Check if the transaction is very old (M-Pesa may archive old transactions)
  • Ensure the transaction was actually initiated successfully

"Invalid Transaction ID"

Cause: Transaction ID format is incorrect

Solution: Transaction IDs from M-Pesa typically follow the format PGK1234567. Ensure you're using the correct transaction ID format from the callback.

Callback Not Received

Causes:

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

Solutions:

# Use ngrok for local testing
ngrok http 3000

Verify your callback route exists:

// app/api/mpesa/[...mpesa]/route.ts
export const { POST } = mpesa.handlers.catchAll;

"Request Cancelled"

Cause: M-Pesa couldn't process the request

Solution:

  • Check if your business account is active
  • Verify you have the correct permissions for transaction status queries
  • Ensure your credentials are current

Security Considerations

1. Protect Transaction IDs

Transaction IDs can be sensitive. Don't expose them unnecessarily:

app.get("/api/transaction-status/:id", authenticate, async (req, res) => {
  const userId = req.user.id;

  // Verify user owns this transaction
  const transaction = await db.transaction.findFirst({
    where: {
      id: req.params.id,
      userId: userId,
    },
  });

  if (!transaction) {
    return res.status(404).json({ error: "Transaction not found" });
  }

  const status = await mpesa.client.transactionStatus({
    transactionID: transaction.mpesaTransactionId,
    remarks: "User-initiated status check",
  });

  res.json({ status });
});

2. Rate Limit Status Queries

Prevent abuse by limiting status check frequency:

const mpesa = new MpesaClient(config, {
  rateLimitOptions: {
    enabled: true,
    maxRequests: 20,
    windowMs: 60000, // 20 requests per minute
  },
});

3. Validate Callback IPs

Always validate that callbacks are from Safaricom:

callbackOptions: {
  validateIp: true, // Default: true
}

4. Log All Status Queries

Maintain security audit logs:

async function loggedTransactionStatus(transactionId: string, userId: string) {
  await db.securityLog.create({
    data: {
      action: "TRANSACTION_STATUS_QUERY",
      userId,
      transactionId,
      timestamp: new Date(),
      ipAddress: req.ip,
    },
  });

  return await mpesa.client.transactionStatus({
    transactionID: transactionId,
    remarks: "Status check",
  });
}

Performance Optimization

1. Cache Recent Queries

Avoid duplicate queries within a short time window:

import { Redis } from "ioredis";

const redis = new Redis();

async function cachedTransactionStatus(transactionId: string) {
  const cacheKey = `tx_status:${transactionId}`;
  const cached = await redis.get(cacheKey);

  if (cached) {
    return JSON.parse(cached);
  }

  const result = await mpesa.client.transactionStatus({
    transactionID: transactionId,
    remarks: "Status check",
  });

  // Cache for 2 minutes
  await redis.setex(cacheKey, 120, JSON.stringify(result));

  return result;
}

2. Batch Processing

Process multiple status checks efficiently:

async function batchProcessStatusChecks(transactionIds: string[]) {
  const BATCH_SIZE = 5;
  const DELAY_MS = 2000; // 2 seconds between batches

  for (let i = 0; i < transactionIds.length; i += BATCH_SIZE) {
    const batch = transactionIds.slice(i, i + BATCH_SIZE);

    await Promise.all(
      batch.map((id) =>
        mpesa.client
          .transactionStatus({
            transactionID: id,
            remarks: "Batch processing",
          })
          .catch((err) => console.error(`Failed for ${id}:`, err)),
      ),
    );

    // Wait before next batch
    if (i + BATCH_SIZE < transactionIds.length) {
      await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
    }
  }
}
Edit on GitHub

On this page