Singularity Payments LogoSingularity Payments

C2B Registration

Register validation and confirmation URLs for Customer to Business payments

Overview

Before you can receive C2B payments, you must register your validation and confirmation URLs with M-Pesa. This tells M-Pesa where to send payment notifications when customers pay to your paybill or till number.

How It Works

  1. Register URLs: Send your validation and confirmation URLs to M-Pesa
  2. Customer Pays: Customer initiates payment to your paybill/till
  3. Validation Request: M-Pesa sends payment details to your validation URL
  4. Accept/Reject: Your system validates and accepts or rejects the payment
  5. Confirmation: M-Pesa sends confirmation to your confirmation URL after successful payment

Usage

Unfortunately the Mpesa API does not let us have the word M-pesa in the route so we will need to configure a new Catch all route.

For Fullstack Frameworks

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

export const { POST } = mpesa.handlers.catchAll;
src/routes/api/payments/[...path]/+server.ts
import { mpesa } from '$lib/mpesa';

export const POST = mpesa.handlers.catchAll.POST;
server/api/payments/[...path].post.ts
import { mpesa } from "../../utils/mpesa";

export default mpesa.handlers.catchAll;

For Backend Frameworks

We will need to add a new route in the configuration file.

import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { createMpesa } from "@singularity-payments/elysia";

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: "sandbox",
callbackUrl: `https://your-domain.ngrok-free.dev/api/mpesa/callback`,
});

const app = new Elysia()
.use(
cors({
origin: "http://localhost:3000",
credentials: true,
allowedHeaders: ["Content-Type", "Authorization"],
methods: ["GET", "POST", "OPTIONS"],
}),
)
.get("/", () => "M-Pesa Payment API")
.get("/health", () => ({ status: "ok" }))
.group("/api/mpesa", (app) => app.use(mpesa.app))
.group("/api/payments", (app) => app.use(mpesa.app)) // Add this
.listen(3004);

console.log("Registered routes:");
app.routes.forEach((route) => {
console.log(`${route.method} ${route.path}`);
});

console.log(
`Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);
import "dotenv/config";
import express from "express";
import cors from "cors";
import { createMpesa } from "@singularity-payments/express";

const app = express();

app.use(
  cors({
    origin: ["http://localhost:3000", "http://localhost:3001"],
    credentials: true,
    methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
    preflightContinue: false,
    optionsSuccessStatus: 204,
  }),
);

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`, req.body);
  next();
});

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: "sandbox",
  callbackUrl: `https://your-domain.ngrok-free.dev/api/mpesa/callback`,
});

const mpesaRouter = express.Router();
mpesa.router(mpesaRouter);
app.use("/api/mpesa", mpesaRouter);
app.use("/api/mpesa", mpesaRouter); // Add this

app.use((err, req, res, next) => {
  console.error("Error:", err);
  res.status(500).json({
    success: false,
    error: err.message || "Internal server error",
  });
});

app.listen(3001, () => {
  console.log("Server running on port 3001");
  console.log("Environment variables loaded:", {
    hasConsumerKey: !!process.env.MPESA_CONSUMER_KEY,
    hasConsumerSecret: !!process.env.MPESA_CONSUMER_SECRET,
    hasPasskey: !!process.env.MPESA_PASSKEY,
    hasShortcode: !!process.env.MPESA_SHORTCODE,
  });
});
import Fastify from "fastify";
import cors from "@fastify/cors";
import { config } from "dotenv";
import { createMpesa } from "@singularity-payments/fastify";

config();

const fastify = Fastify({
logger: true,
});

fastify.register(cors, {
origin: "http://localhost:3000",
credentials: true,
});

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: "sandbox",
callbackUrl: `https://your-domain.ngrok-free.dev/api/mpesa/callback`,
});

fastify.register(
async (instance) => {
mpesa.router(instance);
},
{ prefix: "/api/mpesa" },
);
// Add this
fastify.register(
async (instance) => {
mpesa.router(instance);
},
{ prefix: "/api/payments" },
);
const start = async () => {
try {
await fastify.listen({ port: 3003, host: "0.0.0.0" });
console.log("Server running on port 3003");
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};

start();
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { config } from "dotenv";
import { createMpesa } from "@singularity-payments/hono";
import { cors } from "hono/cors";

config();

const app = new Hono();

app.use("/api/*", cors());
app.use(
  "/api/*",
  cors({
    origin: "http://localhost:3000",
    allowHeaders: ["X-Custom-Header", "Upgrade-Insecure-Requests"],
    allowMethods: ["POST", "GET", "OPTIONS"],
    exposeHeaders: ["Content-Length", "X-Kuma-Revision"],
    maxAge: 600,
    credentials: true,
  }),
);

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: "sandbox",
  callbackUrl: `https://your-domain.ngrok-free.dev/api/mpesa/callback`,
});

app.route("/api/mpesa", mpesa.app);
app.route("/api/payments", mpesa.app);

serve({
  fetch: app.fetch,
  port: 3002,
});

console.log("Server running on port 3002");

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

Server Side

const response = await mpesa.client.registerC2BUrl({
  shortCode: "600998", // use this shortcode for C2B Transactions
  responseType: "Completed",
  confirmationURL:
    "https://your-domain.ngrok-free.dev/api/payments/c2b-confirmation",
  validationURL:
    "https://your-domain.ngrok-free.dev/api/payments/c2b-validation",
});

Client Side

const { data, error } = await mpesaClient.registerC2B({
  shortCode: "600998", // use this shortcode for C2B Transactions
  responseType: "Completed",
  confirmationURL: "https://yourdomain.com/api/payments/c2b-confirmation",
  validationURL: "https://yourdomain.com/api/payments/c2b-validation",
});

if (error) {
  console.error("Registration failed:", error);
} else {
  console.log("URLs registered successfully:", data);
}

Successful Response

{
  "OriginatorCoversationID": "12345-67890-1",
  "ResponseCode": "0",
  "ResponseDescription": "Success"
}

Complete Example with Database

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

interface RegisterC2BUrlsRequest {
  shortCode: string;
  confirmationURL: string;
  validationURL: string;
}

async function registerC2BUrls(request: RegisterC2BUrlsRequest) {
  const { shortCode, confirmationURL, validationURL } = request;

  try {
    const response = await mpesa.client.registerC2BUrl({
      shortCode,
      responseType: "Completed",
      confirmationURL,
      validationURL,
    });

    await db.insert(c2bRegistrations).values({
      shortCode,
      confirmationURL,
      validationURL,
      originatorConversationId: response.OriginatorCoversationID,
      responseCode: response.ResponseCode,
      responseDescription: response.ResponseDescription,
      registeredAt: new Date(),
    });

    return {
      success: true,
      message: "URLs registered successfully",
      data: response,
    };
  } catch (error) {
    console.error("C2B URL registration failed:", error);
    throw error;
  }
}

API Reference

C2B Register Request

interface C2BRegisterRequest {
  shortCode: string;
  responseType: "Completed" | "Cancelled";
  confirmationURL: string;
  validationURL: string;
}

Parameters

ParameterTypeRequiredDescription
shortCodestringYesYour paybill or till number
responseTypestringYesWhen to send confirmation: Completed/Cancelled
confirmationURLstringYesURL to receive payment confirmations
validationURLstringYesURL to validate payments before processing

Response Types

TypeDescription
CompletedM-Pesa sends confirmation only for completed payments
CancelledM-Pesa sends confirmation for both completed and cancelled payments

C2B Register Response

interface C2BRegisterResponse {
  OriginatorCoversationID: string;
  ResponseCode: string;
  ResponseDescription: string;
}

Response Codes

CodeDescriptionMeaning
0SuccessURLs registered successfully
1RejectedRegistration failed

Setting Up Callback URLs

Validation URL Setup

The validation URL is called before M-Pesa processes the payment. You can accept or reject the payment based on your business logic.

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

const mpesa = new MpesaClient(
  {
    environment: "sandbox",
    consumerKey: process.env.MPESA_CONSUMER_KEY!,
    consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
    shortcode: process.env.MPESA_SHORTCODE!,
    passkey: process.env.MPESA_PASSKEY!,
    initiatorName: process.env.MPESA_INITIATOR_NAME!,
    securityCredential: process.env.MPESA_SECURITY_CREDENTIAL!,
    callbackUrl: `${process.env.APP_URL}/api/mpesa/callback`,
    resultUrl: `${process.env.APP_URL}/api/mpesa/result`,
    timeoutUrl: `${process.env.APP_URL}/api/mpesa/timeout`,
  },
  {
    c2bValidationOptions: {
      onValidate: async (data) => {
        console.log("Validating C2B payment:", {
          amount: data.TransAmount,
          billRefNumber: data.BillRefNumber,
          phone: data.MSISDN,
          transactionId: data.TransID,
        });

        const amount = parseFloat(data.TransAmount);

        if (amount < 10) {
          return {
            accept: false,
            message: "Minimum payment is KES 10",
          };
        }

        const order = await db.orders.findUnique({
          where: { billRefNumber: data.BillRefNumber },
        });

        if (!order) {
          return {
            accept: false,
            message: "Invalid bill reference number",
          };
        }

        if (order.amount !== amount) {
          return {
            accept: false,
            message: "Amount does not match order",
          };
        }

        if (order.status === "paid") {
          return {
            accept: false,
            message: "Order already paid",
          };
        }

        return {
          accept: true,
          message: "Payment validated successfully",
        };
      },
    },
  },
);

Confirmation URL Setup

The confirmation URL is called after a successful payment. This is where you update your database and fulfill orders.

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

const mpesa = new MpesaClient(
  {
    environment: "sandbox",
    consumerKey: process.env.MPESA_CONSUMER_KEY!,
    consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
    shortcode: process.env.MPESA_SHORTCODE!,
    passkey: process.env.MPESA_PASSKEY!,
    initiatorName: process.env.MPESA_INITIATOR_NAME!,
    securityCredential: process.env.MPESA_SECURITY_CREDENTIAL!,
    callbackUrl: `${process.env.APP_URL}/api/mpesa/callback`,
    resultUrl: `${process.env.APP_URL}/api/mpesa/result`,
    timeoutUrl: `${process.env.APP_URL}/api/mpesa/timeout`,
  },
  {
    c2bConfirmationOptions: {
      onConfirm: async (data) => {
        console.log("C2B payment confirmed:", {
          transactionId: data.TransID,
          amount: data.TransAmount,
          billRefNumber: data.BillRefNumber,
          phone: data.MSISDN,
          transactionTime: data.TransTime,
        });

        await db.payments.create({
          data: {
            transactionId: data.TransID,
            amount: parseFloat(data.TransAmount),
            billRefNumber: data.BillRefNumber,
            phoneNumber: data.MSISDN,
            transactionTime: new Date(data.TransTime),
            businessShortCode: data.BusinessShortCode,
            firstName: data.FirstName,
            middleName: data.MiddleName,
            lastName: data.LastName,
            status: "completed",
          },
        });

        await db.orders.update({
          where: { billRefNumber: data.BillRefNumber },
          data: {
            status: "paid",
            paidAt: new Date(),
            transactionId: data.TransID,
          },
        });

        await sendPaymentConfirmationEmail({
          email: order.email,
          amount: data.TransAmount,
          transactionId: data.TransID,
        });

        await sendPaymentConfirmationSMS({
          phone: data.MSISDN,
          amount: data.TransAmount,
          transactionId: data.TransID,
        });
      },

      isDuplicate: async (transactionId) => {
        const existing = await db.payments.findUnique({
          where: { transactionId },
        });
        return !!existing;
      },

      validateIp: true,
    },
  },
);

Callback Data Structure

Validation Request Data

interface C2BCallback {
  TransactionType: string;
  TransID: string;
  TransTime: string;
  TransAmount: string;
  BusinessShortCode: string;
  BillRefNumber: string;
  InvoiceNumber?: string;
  OrgAccountBalance?: string;
  ThirdPartyTransID?: string;
  MSISDN: string;
  FirstName?: string;
  MiddleName?: string;
  LastName?: string;
}

Validation Response

Your validation endpoint must respond with:

{
  "ResultCode": 0,
  "ResultDesc": "Accepted"
}

Or to reject:

{
  "ResultCode": 1,
  "ResultDesc": "Rejected - Insufficient amount"
}

Best Practices

1. Use HTTPS URLs

Always use HTTPS for production URLs:

const response = await mpesa.client.registerC2BUrl({
  shortCode: "600998",
  responseType: "Completed",
  confirmationURL: "https://yourdomain.com/api/mpesa/c2b-confirmation",
  validationURL: "https://yourdomain.com/api/mpesa/c2b-validation",
});

2. Validate All Payments

Implement proper validation logic:

c2bValidationOptions: {
  onValidate: async (data) => {
    const validations = [
      { check: parseFloat(data.TransAmount) >= 10, message: "Amount too low" },
      { check: await orderExists(data.BillRefNumber), message: "Invalid order" },
      { check: await checkDuplicate(data.TransID), message: "Duplicate payment" },
    ];

    for (const validation of validations) {
      if (!validation.check) {
        return { accept: false, message: validation.message };
      }
    }

    return { accept: true, message: "Validated" };
  },
}

3. Prevent Duplicate Processing

Always check for duplicate transactions:

c2bConfirmationOptions: {
  isDuplicate: async (transactionId) => {
    const exists = await db.payments.count({
      where: { transactionId },
    });
    return exists > 0;
  },
}

4. Enable IP Validation

Validate that callbacks come from Safaricom:

c2bConfirmationOptions: {
  validateIp: true,
}

5. Log All Callbacks

Keep detailed logs for troubleshooting:

c2bConfirmationOptions: {
  onConfirm: async (data) => {
    await db.c2bLogs.create({
      data: {
        transactionId: data.TransID,
        payload: JSON.stringify(data),
        processedAt: new Date(),
      },
    });

    // Process payment
  },
}

6. Handle Failures Gracefully

Implement proper error handling:

c2bConfirmationOptions: {
  onConfirm: async (data) => {
    try {
      await processPayment(data);
    } catch (error) {
      console.error("Payment processing failed:", error);

      await db.failedPayments.create({
        data: {
          transactionId: data.TransID,
          error: error.message,
          payload: JSON.stringify(data),
        },
      });

      await notifyAdmin({
        subject: "C2B Payment Processing Failed",
        transactionId: data.TransID,
        error: error.message,
      });
    }
  },
}

Local Development

Using ngrok

For local testing, use ngrok to expose your local server:

ngrok http 3000

Then register your ngrok URLs:

const ngrokUrl = "https://your-ngrok-url.ngrok.io";

const response = await mpesa.client.registerC2BUrl({
  shortCode: "600998",
  responseType: "Completed",
  confirmationURL: `${ngrokUrl}/api/mpesa/c2b-confirmation`,
  validationURL: `${ngrokUrl}/api/mpesa/c2b-validation`,
});

Environment-Specific URLs

Use environment variables for different environments:

const baseUrl =
  process.env.NODE_ENV === "production"
    ? "https://yourdomain.com"
    : process.env.NGROK_URL || "http://localhost:3000";

const response = await mpesa.client.registerC2BUrl({
  shortCode: process.env.MPESA_SHORTCODE!,
  responseType: "Completed",
  confirmationURL: `${baseUrl}/api/mpesa/c2b-confirmation`,
  validationURL: `${baseUrl}/api/mpesa/c2b-validation`,
});

Troubleshooting

"Invalid Access Token"

Cause: Consumer key/secret incorrect or expired

Solution:

echo $MPESA_CONSUMER_KEY
echo $MPESA_CONSUMER_SECRET

Verify credentials in the developer portal and regenerate if needed.

"Invalid ShortCode"

Cause: Incorrect shortcode for environment

Solution:

shortCode: "600998";

Use the correct shortcode for your environment.

URLs Not Receiving Callbacks

Causes:

  1. URLs not publicly accessible
  2. Firewall blocking Safaricom IPs
  3. SSL certificate issues

Solutions:

ngrok http 3000

Ensure your URLs are:

  • Publicly accessible
  • Using HTTPS in production
  • Not blocking IPs: 196.201.214.200, 196.201.214.206, etc.

"Validation URL Timeout"

Cause: Validation endpoint taking too long to respond

Solution: Optimize your validation logic to respond within 30 seconds:

c2bValidationOptions: {
  onValidate: async (data) => {
    const validationPromise = validatePayment(data);
    const timeoutPromise = new Promise((_, reject) =>
      setTimeout(() => reject(new Error("Timeout")), 25000)
    );

    try {
      await Promise.race([validationPromise, timeoutPromise]);
      return { accept: true, message: "Validated" };
    } catch (error) {
      console.error("Validation timeout:", error);
      return { accept: true, message: "Accepted by default" };
    }
  },
}

Testing Validation Logic

Test your validation without actual M-Pesa calls:

const testCallback: C2BCallback = {
  TransactionType: "Pay Bill",
  TransID: "TEST123",
  TransTime: "20251222143000",
  TransAmount: "100.00",
  BusinessShortCode: "600998",
  BillRefNumber: "TEST-001",
  MSISDN: "254712345678",
  FirstName: "John",
  MiddleName: "",
  LastName: "Doe",
};

const parsed = mpesa.client.parseC2BCallback(testCallback);
console.log("Parsed callback:", parsed);

Important Notes

  1. You must register URLs before accepting C2B payments
  2. URLs must be publicly accessible over HTTPS in production
  3. Validation callback must respond within 30 seconds
  4. Use ngrok or similar tools for local development
  5. Register separate URLs for sandbox and production
  6. Safaricom IPs must not be blocked by your firewall
  7. Response type "Completed" is recommended for most use cases
  8. Always implement duplicate checking in confirmation handler
  9. Store all callback data for audit purposes
  10. Test thoroughly in sandbox before going to production
Edit on GitHub

On this page