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
- Register URLs: Send your validation and confirmation URLs to M-Pesa
- Customer Pays: Customer initiates payment to your paybill/till
- Validation Request: M-Pesa sends payment details to your validation URL
- Accept/Reject: Your system validates and accepts or rejects the payment
- 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
import { mpesa } from "~/lib/mpesa";
export const { POST } = mpesa.handlers.catchAll;import { mpesa } from '$lib/mpesa';
export const POST = mpesa.handlers.catchAll.POST;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
| Parameter | Type | Required | Description |
|---|---|---|---|
shortCode | string | Yes | Your paybill or till number |
responseType | string | Yes | When to send confirmation: Completed/Cancelled |
confirmationURL | string | Yes | URL to receive payment confirmations |
validationURL | string | Yes | URL to validate payments before processing |
Response Types
| Type | Description |
|---|---|
Completed | M-Pesa sends confirmation only for completed payments |
Cancelled | M-Pesa sends confirmation for both completed and cancelled payments |
C2B Register Response
interface C2BRegisterResponse {
OriginatorCoversationID: string;
ResponseCode: string;
ResponseDescription: string;
}Response Codes
| Code | Description | Meaning |
|---|---|---|
0 | Success | URLs registered successfully |
1 | Rejected | Registration 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 3000Then 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_SECRETVerify 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:
- URLs not publicly accessible
- Firewall blocking Safaricom IPs
- SSL certificate issues
Solutions:
ngrok http 3000Ensure 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
- You must register URLs before accepting C2B payments
- URLs must be publicly accessible over HTTPS in production
- Validation callback must respond within 30 seconds
- Use ngrok or similar tools for local development
- Register separate URLs for sandbox and production
- Safaricom IPs must not be blocked by your firewall
- Response type "Completed" is recommended for most use cases
- Always implement duplicate checking in confirmation handler
- Store all callback data for audit purposes
- Test thoroughly in sandbox before going to production