Production Payment Integration: Safaricom Daraja 3.0 & PayHero Kenya API
In East Africa, M-Pesa is the primary settlement medium for digital commerce. Integrating payment infrastructure requires understanding asynchronous telco webhooks, cryptographic password calculations, OAuth bearer tokens, and bulletproof idempotency controls to prevent lost payments or double-crediting.
1. Safaricom Daraja 3.0 STK Push Lifecycle
Lipa Na M-Pesa Online (STK Push) triggers an on-screen PIN prompt directly on the customer's phone:
- Customer Checkout: User enters mobile number (e.g.,
0712345678or254712345678) and submits payment order. - Backend Server: Obtains OAuth token from Safaricom, computes the security password hash, and dispatches an STK Push request to Daraja API.
- Safaricom Core Gateway: Pushes prompt to SIM toolkit on customer handset. Customer enters M-Pesa PIN.
- Customer Handset: Validates PIN and executes fund debit. Safaricom returns SMS receipt to customer.
- Asynchronous Webhook Callback: Safaricom dispatches an HTTP POST request containing full transaction metadata to your configured
CallBackURL. - Backend Reconciliation: Backend parses callback payload, validates
ResultCode === 0, checks receipt number against database, and credits user order.
2. Daraja 3.0 Implementation Code
// lib/daraja.ts
export async function getDarajaToken(): Promise {
const consumerKey = process.env.MPESA_CONSUMER_KEY!;
const consumerSecret = process.env.MPESA_CONSUMER_SECRET!;
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64');
const res = await fetch('https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials', {
headers: { Authorization: `Basic ${auth}` },
next: { revalidate: 3500 } // Cache token for 58 minutes
});
const data = await res.json();
if (!res.ok) throw new Error('Failed to get Daraja OAuth token');
return data.access_token;
}
export async function triggerStkPush(params: {
phoneNumber: string;
amount: number;
orderId: string;
callbackUrl: string;
}) {
const token = await getDarajaToken();
const shortcode = process.env.MPESA_SHORTCODE!;
const passkey = process.env.MPESA_PASSKEY!;
// Format timestamp: YYYYMMDDHHmmss
const now = new Date();
const timestamp = now.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
const password = Buffer.from(`${shortcode}${passkey}${timestamp}`).toString('base64');
// Format phone number to 254XXXXXXXXX
let phone = params.phoneNumber.replace(/\\D/g, '');
if (phone.startsWith('0')) phone = '254' + phone.slice(1);
if (phone.startsWith('+')) phone = phone.slice(1);
const payload = {
BusinessShortCode: shortcode,
Password: password,
Timestamp: timestamp,
TransactionType: "CustomerPayBillOnline", // or CustomerBuyGoodsOnline
Amount: Math.round(params.amount),
PartyA: phone,
PartyB: shortcode,
PhoneNumber: phone,
CallBackURL: params.callbackUrl,
AccountReference: params.orderId,
TransactionDesc: "Portal Subscription Payment"
};
const res = await fetch('https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const responseData = await res.json();
return responseData; // contains CheckoutRequestID, ResponseCode
}
3. Asynchronous Webhook Callback Route with Idempotency
// app/api/v1/mpesa/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
try {
const data = await req.json();
const { Body } = data;
const { stkCallback } = Body;
const resultCode = stkCallback.ResultCode;
const resultDesc = stkCallback.ResultDesc;
const checkoutRequestId = stkCallback.CheckoutRequestID;
// ResultCode 0 indicates payment success
if (resultCode === 0) {
const items = stkCallback.CallbackMetadata.Item;
const receipt = items.find((i: any) => i.Name === 'MpesaReceiptNumber')?.Value;
const amount = items.find((i: any) => i.Name === 'Amount')?.Value;
const phone = items.find((i: any) => i.Name === 'PhoneNumber')?.Value;
const txDate = items.find((i: any) => i.Name === 'TransactionDate')?.Value;
// Idempotency: Check if transaction has already been fulfilled
const existingTx = await db.payment.findUnique({
where: { mpesaReceipt: receipt }
});
if (existingTx) {
// Acknowledge receipt without double crediting
return NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted duplicate" });
}
// Record payment and update order
await db.$transaction([
db.payment.create({
data: {
checkoutRequestId,
mpesaReceipt: receipt,
amount: Number(amount),
phoneNumber: String(phone),
status: "COMPLETED",
rawPayload: JSON.stringify(data)
}
}),
db.order.updateMany({
where: { checkoutRequestId },
data: { status: "PAID", receiptNumber: receipt }
})
]);
} else {
// Payment was cancelled by user (1032) or failed
await db.order.updateMany({
where: { checkoutRequestId },
data: { status: "FAILED", failureReason: resultDesc }
});
}
// Always respond with HTTP 200 to Safaricom
return NextResponse.json({ ResultCode: 0, ResultDesc: "Callback processed successfully" });
} catch (error) {
console.error("M-Pesa Callback Error:", error);
return NextResponse.json({ ResultCode: 0, ResultDesc: "Callback acknowledged with internal log" });
}
}
4. PayHero Kenya API: Unified Multi-Channel Gateway
PayHero provides aggregated settlement across Till numbers, Paybills, and mobile wallets with automated webhook callbacks.
// Initiating PayHero Express STK Push
export async function createPayHeroPayment(params: {
amount: number;
phone: string;
orderReference: string;
}) {
const authHeader = 'Basic ' + Buffer.from(
`${process.env.PAYHERO_API_KEY}:${process.env.PAYHERO_API_SECRET}`
).toString('base64');
const res = await fetch('https://backend.payhero.co.ke/api/v2/payments', {
method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: params.amount,
phone_number: params.phone,
channel_id: parseInt(process.env.PAYHERO_CHANNEL_ID!),
provider: 'm-pesa',
external_reference: params.orderReference,
callback_url: 'https://api.cybertechcomps.com/api/v1/payhero/webhook'
})
});
return await res.json();
}