Next.js
App Router API routes and Server Actions for @lumierelabs/daraja.
Singleton client
Create the client once, at module scope, and import it everywhere. Constructing Daraja(config) inside a route handler defeats token caching because a fresh AuthManager (and empty cache) is created on every request.
import { Daraja } from "@lumierelabs/daraja";
export const daraja = Daraja({
consumerKey: process.env.DARAJA_CONSUMER_KEY!,
consumerSecret: process.env.DARAJA_CONSUMER_SECRET!,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
});Route Handlers only not Edge Runtime
The SDK's AuthManager falls back to Node's Buffer for Base64 encoding when
the global btoa isn't available, and relies on Intl.DateTimeFormat with
the Africa/Nairobi timezone for STK Push timestamps. Both work fine on
Node.js Route Handlers (the default) stick to export const runtime = 'nodejs' (or omit it, since that's the default) rather than opting into
export const runtime = 'edge'.
Initiating an STK Push (Route Handler)
import { NextResponse } from "next/server";
import { daraja } from "@/lib/daraja";
import { DarajaError } from "@lumierelabs/daraja";
export async function POST(request: Request) {
const { phoneNumber, amount, orderRef } = await request.json();
try {
const push = await daraja.stkPush({
businessShortCode: "174379",
passkey: process.env.MPESA_PASSKEY!,
transactionType: "CustomerPayBillOnline",
amount,
partyA: phoneNumber,
partyB: "174379",
phoneNumber,
callBackURL: `${process.env.APP_URL}/api/callbacks/stk`,
accountReference: orderRef,
transactionDesc: "Order payment",
});
return NextResponse.json({ checkoutRequestId: push.CheckoutRequestID });
} catch (error) {
if (error instanceof DarajaError) {
return NextResponse.json(
{ error: error.message },
{ status: error.statusCode || 400 },
);
}
throw error;
}
}Initiating an STK Push (Server Action)
"use server";
import { daraja } from "@/lib/daraja";
import { DarajaError } from "@lumierelabs/daraja";
export async function initiateCheckout(formData: FormData) {
const phoneNumber = formData.get("phoneNumber") as string;
const amount = Number(formData.get("amount"));
try {
const push = await daraja.stkPush({
businessShortCode: "174379",
passkey: process.env.MPESA_PASSKEY!,
transactionType: "CustomerPayBillOnline",
amount,
partyA: phoneNumber,
partyB: "174379",
phoneNumber,
callBackURL: `${process.env.APP_URL}/api/callbacks/stk`,
accountReference: `order-${Date.now()}`,
});
return { success: true, checkoutRequestId: push.CheckoutRequestID };
} catch (error) {
return {
success: false,
error:
error instanceof DarajaError
? error.message
: "Payment failed to initiate",
};
}
}STK Push callback handler
import { NextResponse } from "next/server";
interface StkCallbackItem {
Name: string;
Value?: string | number;
}
interface StkCallbackBody {
Body: {
stkCallback: {
MerchantRequestID: string;
CheckoutRequestID: string;
ResultCode: number;
ResultDesc: string;
CallbackMetadata?: { Item: StkCallbackItem[] };
};
};
}
export async function POST(request: Request) {
const payload = (await request.json()) as StkCallbackBody;
const { CheckoutRequestID, ResultCode, ResultDesc, CallbackMetadata } =
payload.Body.stkCallback;
if (ResultCode === 0) {
const items = CallbackMetadata?.Item ?? [];
const get = (name: string) => items.find((i) => i.Name === name)?.Value;
await recordSuccessfulPayment({
checkoutRequestId: CheckoutRequestID,
amount: get("Amount"),
receipt: get("MpesaReceiptNumber"),
phoneNumber: get("PhoneNumber"),
});
} else {
await recordFailedPayment({
checkoutRequestId: CheckoutRequestID,
reason: ResultDesc,
});
}
// Daraja only checks for a 200 it does not parse this response body.
return NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
}
async function recordSuccessfulPayment(_data: unknown) {
// your database write here
}
async function recordFailedPayment(_data: unknown) {
// your database write here
}C2B confirmation handler
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const payload = await request.json();
const { TransID, TransAmount, MSISDN, BillRefNumber } = payload;
console.log(
`C2B payment: ${TransAmount} from ${MSISDN}, ref ${BillRefNumber}, txn ${TransID}`,
);
// Fields beyond these four are inconsistently populated by Daraja see
// the C2B endpoint page's callout on missing/inconsistent fields.
return NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
}Register these with c2b.registerUrl() once per shortcode not on every deploy since re-registering doesn't hurt but also doesn't need to happen automatically on every build.
Deploying to Vercel? process.env.APP_URL should be your production domain,
not a preview deployment URL preview URLs are usually randomized per-deploy
and Daraja's own DNS-level trust for a domain builds up over the app's
lifetime on Safaricom's side. Keep callback URLs pinned to your stable
production domain.