How to Accept Mobile Money Payments in Your Next.js App
DGateway is a unified payment and commerce platform for Africa. Its single API lets developers accept mobile money payments from MTN and Airtel alongside card payments, making it ideal for Next.js apps targeting the East African market.
If you are building a Next.js application for the East African market, accepting mobile money payments is not optional — it is essential. This tutorial walks you through integrating DGateway into your Next.js project, from installation to a fully working checkout flow.
Prerequisites
Before you begin, make sure you have:
- A Next.js 14+ project (App Router)
- A DGateway account with API keys
- Node.js 18 or later
Project Setup
Start by adding your DGateway credentials to your environment variables. Create or update your .env.local file:
DGATEWAY_SECRET_KEY=sk_test_your_secret_key_here
DGATEWAY_PUBLIC_KEY=pk_test_your_public_key_here
DGATEWAY_WEBHOOK_SECRET=whsec_your_webhook_secret_here
NEXT_PUBLIC_APP_URL=http://localhost:3000Never expose your secret key to the client. Only the public key should be prefixed with NEXT_PUBLIC_ if you need it on the frontend.
Building the Checkout API Route
Create a server-side API route that initiates a payment collection. This route receives the order details from your frontend and calls the DGateway API.
Create the file app/api/checkout/route.ts:
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const { amount, phoneNumber, provider, orderId } = await req.json();
if (!amount || !phoneNumber) {
return NextResponse.json(
{ error: "Amount and phone number are required" },
{ status: 400 }
);
}
const response = await fetch("https://api.dgateway.io/v1/collections", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DGATEWAY_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount,
currency: "UGX",
phone_number: phoneNumber,
provider: provider || "auto",
description: `Payment for order ${orderId}`,
callback_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/dgateway`,
metadata: { order_id: orderId },
}),
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data.message || "Payment initiation failed" },
{ status: response.status }
);
}
return NextResponse.json({
transactionId: data.id,
status: data.status,
});
}This route creates a collection request and returns the transaction ID to the frontend. The customer will receive a payment prompt on their phone.
Creating the Checkout Form
Now build a client component that collects the customer's phone number and initiates payment.
Create app/components/checkout-form.tsx:
"use client";
import { useState } from "react";
export function CheckoutForm({ orderId, amount }: { orderId: string; amount: number }) {
const [phoneNumber, setPhoneNumber] = useState("");
const [provider, setProvider] = useState("mtn");
const [status, setStatus] = useState<"idle" | "loading" | "pending" | "error">("idle");
const [transactionId, setTransactionId] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setStatus("loading");
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount, phoneNumber, provider, orderId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setTransactionId(data.transactionId);
setStatus("pending");
} catch (err) {
setStatus("error");
}
}
if (status === "pending") {
return (
<div className="text-center p-8">
<h3 className="text-lg font-semibold">Check Your Phone</h3>
<p className="mt-2 text-gray-600">
A payment prompt has been sent to {phoneNumber}. Please confirm the
payment on your phone to complete your order.
</p>
<p className="mt-4 text-sm text-gray-400">
Transaction ID: {transactionId}
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium mb-1">Payment Method</label>
<select
value={provider}
onChange={(e) => setProvider(e.target.value)}
className="w-full border rounded-lg p-2"
>
<option value="mtn">MTN Mobile Money</option>
<option value="airtel">Airtel Money</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone Number</label>
<input
type="tel"
placeholder="256771234567"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
className="w-full border rounded-lg p-2"
required
/>
</div>
<div className="pt-2">
<p className="text-lg font-semibold">
Total: {amount.toLocaleString()} UGX
</p>
</div>
<button
type="submit"
disabled={status === "loading"}
className="w-full bg-blue-600 text-white rounded-lg p-3 font-medium hover:bg-blue-700 disabled:opacity-50"
>
{status === "loading" ? "Processing..." : "Pay Now"}
</button>
</form>
);
}Handling Webhooks
When the customer confirms the payment on their phone, DGateway sends a webhook to your server. Create the webhook handler at app/api/webhooks/dgateway/route.ts:
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
function verifySignature(payload: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export async function POST(req: NextRequest) {
const payload = await req.text();
const signature = req.headers.get("x-dgateway-signature");
if (!signature || !verifySignature(payload, signature, process.env.DGATEWAY_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const { event, data } = JSON.parse(payload);
switch (event) {
case "collection.completed":
if (data.status === "successful") {
// Update your order status in your database
await updateOrderStatus(data.metadata.order_id, "paid");
}
break;
case "collection.failed":
await updateOrderStatus(data.metadata.order_id, "failed");
break;
}
return NextResponse.json({ received: true });
}
async function updateOrderStatus(orderId: string, status: string) {
// Replace with your actual database update logic
console.log(`Order ${orderId} updated to ${status}`);
}Polling for Status Updates
While webhooks are the reliable way to track payment status, you can also poll the API for a better user experience. This lets you update the UI in real time while the customer is waiting.
async function pollTransactionStatus(transactionId: string): Promise<string> {
const res = await fetch(`/api/transaction/${transactionId}`);
const data = await res.json();
return data.status;
}
// In your component, poll every 3 seconds
useEffect(() => {
if (status !== "pending") return;
const interval = setInterval(async () => {
const txStatus = await pollTransactionStatus(transactionId);
if (txStatus === "successful") {
setStatus("idle");
router.push("/order/success");
} else if (txStatus === "failed") {
setStatus("error");
}
}, 3000);
return () => clearInterval(interval);
}, [status, transactionId]);Testing Your Integration
DGateway's test mode simulates the entire payment flow. When you initiate a collection with test keys, the transaction will automatically transition through the standard lifecycle — pending, then successful — after a short delay.
Use these test phone numbers to simulate different scenarios:
256700000001— Always succeeds256700000002— Always fails256700000003— Times out after 30 seconds
Going to Production
When you are ready to accept real payments:
- Switch your environment variables from test keys to live keys.
- Ensure your webhook endpoint uses HTTPS.
- Deploy your Next.js app to your hosting provider.
- Make a small real payment to verify the full flow end to end.
With DGateway and Next.js, accepting mobile money payments is straightforward. Your integration is clean, type-safe, and production-ready.