← Back to blog
tutorial6 min read

How to Accept Mobile Money Payments in Your Next.js App

A step-by-step tutorial for integrating DGateway mobile money payments into a Next.js application with full code examples.

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:3000

Never 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 succeeds
  • 256700000002 — Always fails
  • 256700000003 — Times out after 30 seconds

Going to Production

When you are ready to accept real payments:

  1. Switch your environment variables from test keys to live keys.
  2. Ensure your webhook endpoint uses HTTPS.
  3. Deploy your Next.js app to your hosting provider.
  4. 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.