The Complete Next.js Payment Integration Guide
DGateway is a unified payment and commerce platform for Africa. It lets developers accept MTN Mobile Money, Airtel Money, and card payments through a single API — while also providing tools to sell courses, digital products, event tickets, templates, and more. This guide covers everything you need to integrate DGateway into a Next.js app.
Table of Contents
- Project Setup
- One-Time Payments (Collection)
- Status Polling & Webhooks
- Recurring Payments (Subscriptions)
- Withdrawals (Disbursements)
- Embedding Your Store
- Embedding Courses
- Embedding Events & Tickets
- Embedding Products & Templates
- Embedding Payment Links
- Troubleshooting & FAQs
- Going-Live Checklist
1. Project Setup
Install Dependencies
npx create-next-app@latest my-app --typescript --app
cd my-app
npm install axiosEnvironment Variables
Create .env.local:
DGATEWAY_API_URL=https://dgatewayapi.desispay.com
DGATEWAY_API_KEY=dgw_live_your_key_here
DGATEWAY_WEBHOOK_SECRET=your_webhook_secretNever expose your API key on the client. All DGateway API calls must go through your server (API routes).
Create the API Client
Create lib/dgateway.ts:
const API_URL = process.env.DGATEWAY_API_URL!;
const API_KEY = process.env.DGATEWAY_API_KEY!;
async function dgw(path: string, options: RequestInit = {}) {
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
...options.headers,
},
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message || "DGateway API error");
return json;
}
export async function collectPayment(params: {
amount: number;
currency: string;
phone_number: string;
provider?: string;
description?: string;
metadata?: Record<string, unknown>;
}) {
return dgw("/v1/payments/collect", {
method: "POST",
body: JSON.stringify(params),
});
}
export async function verifyTransaction(reference: string) {
return dgw("/v1/webhooks/verify", {
method: "POST",
body: JSON.stringify({ reference }),
});
}
export async function disburse(params: {
amount: number;
currency: string;
phone_number: string;
provider?: string;
description?: string;
}) {
return dgw("/v1/payments/disburse", {
method: "POST",
body: JSON.stringify(params),
});
}
export async function createSubscription(params: {
plan_slug: string;
customer_email: string;
customer_name: string;
customer_phone: string;
provider?: string;
}) {
return dgw("/v1/subscriptions", {
method: "POST",
body: JSON.stringify(params),
});
}
export async function chargeSubscription(params: {
subscription_ref: string;
phone_number: string;
provider?: string;
}) {
return dgw("/v1/subscriptions/charge", {
method: "POST",
body: JSON.stringify(params),
});
}2. One-Time Payments
API Route: app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { collectPayment } from "@/lib/dgateway";
export async function POST(req: Request) {
const body = await req.json();
const { amount, currency, phone_number, provider, description } = body;
try {
const result = await collectPayment({
amount,
currency: currency || "UGX",
phone_number,
provider: provider || "iotec",
description: description || "Payment",
});
return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
}Frontend: Checkout Component
"use client";
import { useState } from "react";
export function CheckoutForm({
amount,
description,
}: {
amount: number;
description: string;
}) {
const [phone, setPhone] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "polling" | "success" | "error"
>("idle");
const [error, setError] = useState("");
const handlePay = async () => {
setStatus("loading");
setError("");
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount,
currency: "UGX",
phone_number: phone,
provider: "iotec",
description,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Payment failed");
const reference = data.data?.reference;
if (!reference) throw new Error("No reference returned");
// Poll for confirmation
setStatus("polling");
let attempts = 0;
const poll = setInterval(async () => {
attempts++;
try {
const statusRes = await fetch("/api/checkout/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reference }),
});
const statusData = await statusRes.json();
if (statusData.data?.status === "completed") {
clearInterval(poll);
setStatus("success");
} else if (statusData.data?.status === "failed") {
clearInterval(poll);
setStatus("error");
setError(statusData.data?.failure_reason || "Payment failed");
}
} catch {}
if (attempts >= 60) {
clearInterval(poll);
setStatus("error");
setError("Payment confirmation timed out");
}
}, 5000);
} catch (err: any) {
setStatus("error");
setError(err.message);
}
};
if (status === "success") {
return (
<div className="text-center p-8">
<p className="text-2xl mb-2">✅</p>
<h2 className="text-xl font-bold">Payment Confirmed!</h2>
<p className="text-gray-500 mt-2">Thank you for your purchase.</p>
</div>
);
}
if (status === "polling") {
return (
<div className="text-center p-8">
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4" />
<h2 className="text-lg font-semibold">Waiting for payment...</h2>
<p className="text-gray-500 mt-2">
Check your phone and enter your PIN.
</p>
</div>
);
}
return (
<div className="max-w-sm mx-auto p-6 border rounded-xl">
<h2 className="text-lg font-bold mb-4">
Pay UGX {amount.toLocaleString()}
</h2>
<label className="block text-sm font-medium mb-1">Phone Number</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="256700000000"
className="w-full border rounded-lg px-3 py-2 mb-4"
/>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<button
onClick={handlePay}
disabled={!phone || status === "loading"}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold disabled:opacity-50"
>
{status === "loading" ? "Processing..." : "Pay Now"}
</button>
</div>
);
}Status Polling Route: app/api/checkout/status/route.ts
import { NextResponse } from "next/server";
import { verifyTransaction } from "@/lib/dgateway";
export async function POST(req: Request) {
const { reference } = await req.json();
try {
const result = await verifyTransaction(reference);
return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
}3. Status Polling & Webhooks
Option A: Polling (Simplest)
The checkout component above already implements polling — it checks every 5 seconds until the transaction is confirmed or fails. This is the simplest approach and works for most use cases.
Option B: Webhooks (Recommended for Production)
For production apps, implement a webhook endpoint to receive real-time payment confirmations:
Create app/api/webhooks/dgateway/route.ts:
import { NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/prisma"; // Your Prisma client
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("X-DGateway-Signature");
const secret = process.env.DGATEWAY_WEBHOOK_SECRET!;
// Verify signature
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (signature !== `sha256=${expected}`) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(body);
const { reference, status, amount, currency } = event;
// Update your database
if (status === "completed") {
await prisma.order.update({
where: { paymentReference: reference },
data: { status: "paid", paidAt: new Date() },
});
// Grant access, send email, etc.
}
return NextResponse.json({ status: "received" });
}Understanding Webhook URL vs Webhook Secret
These are two separate settings in your DGateway dashboard (Settings → Webhook section):
Webhook URL — This is YOUR server's endpoint where DGateway sends payment notifications. When a transaction completes or fails, DGateway makes a POST request to this URL with the transaction details. You must set this in the dashboard.
Example: https://yourdomain.com/api/webhooks/dgateway
Webhook Secret — This is an auto-generated HMAC signing key. DGateway uses it to sign every webhook payload. You copy it from your dashboard and store it as DGATEWAY_WEBHOOK_SECRET in your .env. Your server uses it to verify that the webhook actually came from DGateway (not a malicious third party).
How verification works:
- DGateway computes
HMAC-SHA256(payload, your_webhook_secret) - Sends the result in the
X-DGateway-Signatureheader - Your server computes the same HMAC and compares
- If they match → the webhook is authentic
// In your webhook handler:
const expected = crypto
.createHmac("sha256", process.env.DGATEWAY_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
const signature = req.headers.get("X-DGateway-Signature");
if (signature !== expected) {
// Reject — this webhook is not from DGateway
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}Where to find these: Go to your DGateway dashboard → Settings. The Webhook URL is an input you fill in. The Webhook Secret is shown below it with a copy button.
Is webhook verification required? No — it's strongly recommended but not enforced. If you don't verify, your endpoint will still receive webhooks. But without verification, anyone who knows your webhook URL could send fake payment confirmations.
4. Recurring Payments (Subscriptions)
DGateway supports subscription billing with automatic renewal. Here's how to set it up:
Step 1: Create a Subscription Plan
In the DGateway dashboard, go to your app → Subscription Plans → Create Plan with:
- Name: "Pro Plan"
- Slug:
pro-monthly - Amount: 50000 UGX
- Interval: monthly
- Provider: iotec
Step 2: Subscribe a Customer
Create app/api/subscribe/route.ts:
import { NextResponse } from "next/server";
import { createSubscription } from "@/lib/dgateway";
export async function POST(req: Request) {
const { email, name, phone, plan } = await req.json();
try {
const result = await createSubscription({
plan_slug: plan || "pro-monthly",
customer_email: email,
customer_name: name,
customer_phone: phone,
provider: "iotec",
});
return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
}Step 3: Charge Renewals with Vercel Cron Jobs
DGateway fires webhooks when subscriptions are due for renewal. But you can also proactively charge using a cron job.
Create app/api/cron/charge-subscriptions/route.ts:
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { chargeSubscription } from "@/lib/dgateway";
export async function GET(req: Request) {
// Verify cron secret (Vercel sends this header)
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Find subscriptions due for renewal
const dueSubscriptions = await prisma.subscription.findMany({
where: {
status: "active",
nextDueDate: { lte: new Date() },
},
});
const results = [];
for (const sub of dueSubscriptions) {
try {
const result = await chargeSubscription({
subscription_ref: sub.dgatewayRef,
phone_number: sub.customerPhone,
provider: "iotec",
});
results.push({
id: sub.id,
status: "charged",
ref: result.data?.reference,
});
} catch (error: any) {
results.push({ id: sub.id, status: "failed", error: error.message });
}
}
return NextResponse.json({ charged: results.length, results });
}Add to vercel.json:
{
"crons": [
{
"path": "/api/cron/charge-subscriptions",
"schedule": "0 6 * * *"
}
]
}This runs daily at 6 AM UTC and charges all due subscriptions.
5. Withdrawals (Disbursements)
Send money from your DGateway balance to any phone number:
Create app/api/withdraw/route.ts:
import { NextResponse } from "next/server";
import { disburse } from "@/lib/dgateway";
export async function POST(req: Request) {
const { amount, phone_number, currency } = await req.json();
try {
const result = await disburse({
amount,
currency: currency || "UGX",
phone_number,
provider: "iotec",
description: "Payout",
});
return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
}Important: Disbursements are deducted from your app's available balance (collected minus already withdrawn). Make sure you have sufficient balance before disbursing.
6. Embedding Your Store
DGateway gives every app a public shop page. You can embed your store products directly in your Next.js app:
Fetch Store Data
// lib/store.ts
const API_URL =
process.env.NEXT_PUBLIC_DGATEWAY_API_URL ||
"https://dgatewayapi.desispay.com";
export async function getShop(slug: string) {
const res = await fetch(`${API_URL}/api/store/shop/${slug}`, {
next: { revalidate: 60 }, // Cache for 1 minute
});
if (!res.ok) return null;
return res.json();
}
export async function getProduct(slug: string) {
const res = await fetch(`${API_URL}/api/store/${slug}`);
if (!res.ok) return null;
return res.json();
}
export async function getCourse(slug: string) {
const res = await fetch(`${API_URL}/api/store/courses/${slug}`);
if (!res.ok) return null;
return res.json();
}
export async function getEvent(slug: string) {
const res = await fetch(`${API_URL}/api/store/events/${slug}`);
if (!res.ok) return null;
return res.json();
}Display Store Products
// app/store/page.tsx
import { getShop } from "@/lib/store";
import Link from "next/link";
export default async function StorePage() {
const data = await getShop("your-shop-slug");
if (!data) return <p>Store not found</p>;
const { shop, products, courses, payment_links } = data.data;
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">{shop.name}</h1>
{/* Products */}
{products?.length > 0 && (
<section className="mb-12">
<h2 className="text-xl font-semibold mb-4">Products</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product: any) => (
<Link
key={product.id}
href={`/store/pay/${product.slug}`}
className="border rounded-xl p-4 hover:shadow-lg transition"
>
{product.thumbnail_url && (
<img
src={product.thumbnail_url}
alt={product.name}
className="w-full h-48 object-cover rounded-lg mb-3"
/>
)}
<h3 className="font-semibold">{product.name}</h3>
<p className="text-blue-600 font-bold mt-1">
{product.currency} {product.price.toLocaleString()}
</p>
</Link>
))}
</div>
</section>
)}
{/* Courses */}
{courses?.length > 0 && (
<section className="mb-12">
<h2 className="text-xl font-semibold mb-4">Courses</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{courses.map((course: any) => (
<a
key={course.id}
href={`https://dgatewayadmin.desispay.com/store/courses/${course.slug}`}
target="_blank"
rel="noopener noreferrer"
className="border rounded-xl p-4 hover:shadow-lg transition"
>
{course.thumbnail && (
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-48 object-cover rounded-lg mb-3"
/>
)}
<h3 className="font-semibold">{course.title}</h3>
<p className="text-sm text-gray-500 mt-1">
{course.short_description}
</p>
<p className="text-blue-600 font-bold mt-2">
{course.access_type === "free"
? "Free"
: `${course.currency} ${course.price.toLocaleString()}`}
</p>
</a>
))}
</div>
</section>
)}
</div>
);
}7. Embedding Courses
To embed DGateway courses in your site, you have two options:
Option A: Link to DGateway (Simplest)
<a
href={`https://dgatewayadmin.desispay.com/store/courses/${course.slug}`}
target="_blank"
className="btn"
>
View Course
</a>Option B: Custom Course Page with API
// app/courses/[slug]/page.tsx
import { getCourse } from "@/lib/store";
export default async function CoursePage({
params,
}: {
params: { slug: string };
}) {
const data = await getCourse(params.slug);
if (!data) return <p>Course not found</p>;
const course = data.data;
return (
<div className="max-w-4xl mx-auto px-4 py-12">
{course.thumbnail && (
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-64 object-cover rounded-2xl mb-8"
/>
)}
<h1 className="text-3xl font-bold">{course.title}</h1>
<p className="text-gray-500 mt-2">by {course.instructor_name}</p>
<p className="mt-4">{course.short_description}</p>
<div className="mt-8 p-6 bg-blue-50 rounded-xl">
<p className="text-2xl font-bold text-blue-600">
{course.access_type === "free"
? "Free"
: `${course.currency} ${course.price.toLocaleString()}`}
</p>
<a
href={`https://dgatewayadmin.desispay.com/store/courses/${course.slug}`}
className="mt-4 inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold"
>
Enroll Now
</a>
</div>
{/* Curriculum */}
<h2 className="text-xl font-semibold mt-12 mb-4">Curriculum</h2>
{course.modules?.map((mod: any) => (
<div key={mod.id} className="mb-4 border rounded-lg p-4">
<h3 className="font-semibold">{mod.title}</h3>
<ul className="mt-2 space-y-1">
{mod.lessons?.map((lesson: any) => (
<li
key={lesson.id}
className="text-sm text-gray-600 flex items-center gap-2"
>
{lesson.is_free_preview ? "▶" : "🔒"} {lesson.title}
<span className="text-gray-400">
({lesson.duration_minutes}min)
</span>
</li>
))}
</ul>
</div>
))}
</div>
);
}8. Embedding Events & Tickets
// app/events/page.tsx
const API_URL = "https://dgatewayapi.desispay.com";
export default async function EventsPage() {
const res = await fetch(`${API_URL}/api/store/events?page=1&page_size=12`, {
next: { revalidate: 60 },
});
const data = await res.json();
const events = data.data || [];
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">Upcoming Events</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{events.map((event: any) => (
<a
key={event.id}
href={`https://dgatewayadmin.desispay.com/store/events/${event.slug}`}
target="_blank"
className="border rounded-xl overflow-hidden hover:shadow-lg"
>
{event.thumbnail && (
<img
src={event.thumbnail}
alt={event.title}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<p className="text-sm text-blue-600 font-semibold">
{new Date(event.event_date).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})}
</p>
<h3 className="font-semibold mt-1">{event.title}</h3>
{event.venue_name && (
<p className="text-sm text-gray-500 mt-1">
📍 {event.venue_name}
</p>
)}
</div>
</a>
))}
</div>
</div>
);
}9. Embedding Products & Templates
Digital Products
Products are fetched from the shop endpoint and purchased via the checkout flow:
// Purchase a product
const res = await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({
amount: product.price,
currency: product.currency,
phone_number: buyerPhone,
provider: "iotec",
description: `Purchase: ${product.name}`,
}),
});After payment confirmation, the buyer downloads the file via:
https://dgatewayapi.desispay.com/api/store/download/{transactionReference}
Templates
Templates work the same way as products. Fetch from:
GET https://dgatewayapi.desispay.com/api/store/templates/{slug}
10. Embedding Payment Links
Payment links are the simplest integration — no API calls needed:
// Just link to the DGateway checkout page
<a
href={`https://dgatewayadmin.desispay.com/store/pay/${paymentLink.slug}`}
className="bg-blue-600 text-white px-6 py-3 rounded-lg"
>
Pay Now
</a>Or use the API to create payment links programmatically:
// In your DGateway dashboard, create a payment link with:
// - Name: "Consultation Fee"
// - Amount: 100000 (or 0 for buyer-defined)
// - Currency: UGX
// - Providers: iotec
//
// Share the link: https://dgatewayadmin.desispay.com/store/pay/consultation-fee11. Troubleshooting & FAQs
"My withdrawals are always showing pending"
Cause: Withdrawals go through a verification process. The system:
- Checks your available balance
- Checks the provider's master balance
- Sends the disbursement
- Waits for provider confirmation (3-5 seconds)
- Verifies the provider balance decreased
Fix:
- Ensure you have sufficient available balance (collected minus already withdrawn)
- Check that the destination phone number is valid (format:
256XXXXXXXXXor0XXXXXXXXX) - If using Iotec, ensure MTN/Airtel lines are operational (check System Health in dashboard)
- Withdrawals below 500 UGX are blocked
"My deposits show pending"
Cause: The deposit was initiated but the payment hasn't been confirmed yet.
Fix:
- Check your phone for the USSD prompt and enter your PIN
- If you already entered the PIN, wait 30-60 seconds — the webhook from Iotec may be delayed
- The system now actively checks with the provider when you poll for status
- If still pending after 5 minutes, the payment may have failed on the provider side. Check your mobile money statement
"My subscriptions are not charging"
Cause: Subscription charges require the customer's phone to be reachable for the USSD prompt.
Fix:
- Ensure the cron job is running (
/api/cron/charge-subscriptions) - Check that the subscription is in
activestatus (notpausedorcancelled) - Verify the customer's phone number is correct
- Check if the provider line is healthy (MTN/Airtel may be down)
- DGateway sends
subscription.payment_duewebhooks before charging — implement a handler to notify customers
"Payment prompt not showing on phone"
Fix:
- Verify the phone number format:
256XXXXXXXXX(12 digits) or0XXXXXXXXX(10 digits) - Don't use the test number (
256111777777) with a live API key - Check that the phone has sufficient airtime/balance for the transaction
- Ensure the SIM card is active and has mobile money enabled
- Check System Health in the dashboard — the provider line may be down
"Webhook not firing"
Fix:
- Verify your webhook URL is correct in the DGateway dashboard
- The URL must be publicly accessible (not
localhost) - Check that your server responds with
200 OKwithin 15 seconds - DGateway retries failed webhooks 5 times with exponential backoff
- Check the webhook signature verification — use HMAC-SHA256
"Transaction shows 'completed' but webhook didn't fire"
Fix:
- Implement status polling as a fallback (see Section 3)
- The active provider check in the polling endpoint will detect completed transactions even without webhooks
- Check your server logs for 500 errors on the webhook endpoint
"How do I test without real money?"
Use test mode:
- Generate a test API key (prefixed
dgw_test_) in your dashboard - Use test phone numbers:
0111777771,0111777772, etc. - Transactions are simulated — no real money moves
- Same API, same code — just swap the key when going live
"What currencies are supported?"
| Currency | Providers |
|---|---|
| UGX (Uganda Shillings) | Iotec, Relworx |
| KES (Kenya Shillings) | Relworx |
| TZS (Tanzania Shillings) | Relworx |
| RWF (Rwanda Francs) | Relworx |
| USD, EUR, GBP | Stripe |
"What are the commission rates?"
| Transaction Type | Rate |
|---|---|
| API transactions | 8% |
| Payment links | 10% |
| Event tickets | 10% |
| Digital products | 12% |
| Templates | 13% |
| Courses | 15% |
| Direct deposits | 6% |
12. DGateway Going-Live Checklist
Before you switch from test to live and start accepting real money, go through every item on this checklist:
API & Authentication
- Live API key generated — go to Dashboard → API Keys → Generate a live key (
dgw_live_...) - Test key removed from production — your
.envon Vercel/production uses the live key, NOTdgw_test_... - API key is server-side only — the key is in
DGATEWAY_API_KEYenv var, never exposed to the browser/client - Phone number validation — only accept
256XXXXXXXXX(12 digits) or0XXXXXXXXX(10 digits) - Test phone numbers blocked — never use
256111777777or0111777771with a live key (DGateway blocks this)
Webhooks
- Webhook URL set in dashboard — Settings → Webhook URL → your production endpoint (e.g.,
https://yourdomain.com/api/webhooks/dgateway) - Webhook URL is publicly accessible — not
localhost, not behind VPN - Webhook secret copied — Settings → Webhook Secret → copied to
DGATEWAY_WEBHOOK_SECRETenv var - Signature verification implemented — your webhook handler verifies
X-DGateway-Signaturewith HMAC-SHA256 - Handler responds with 200 OK — within 15 seconds (process async, respond fast)
- Idempotency — your handler doesn't process the same webhook twice (check by transaction reference)
Payments
- Test transaction with real money — make a small payment (500 UGX) with your live key to verify the full flow
- Polling works — the checkout UI polls for status and shows success/failure correctly
- Error messages are user-friendly — don't show raw API errors to customers
- Loading states — spinner while waiting for payment, disabled button during processing
- Timeout handling — if polling times out after 5 minutes, show a clear message
- failure_reason displayed — when a payment fails, show the reason to the user
Subscriptions (if applicable)
- Vercel Cron Job configured —
vercel.jsonhas the cron schedule for charging due subscriptions - CRON_SECRET set — the cron endpoint is protected with
Authorization: Bearer {CRON_SECRET} - Subscription webhooks handled —
subscription.payment_due,subscription.past_dueevents processed - Customer notifications — email customers before charging (payment_due webhook)
Store & Products (if applicable)
- Shop customized — banner, logo, description set in Settings → Store Settings
- Products published — all products/courses/events marked as active and published
- Checkout pages tested — visit your store links and complete a test purchase
- Download links working — after purchase, buyers can download digital products
WordPress Plugin (if applicable)
- Plugin installed and activated — latest version from the download link
- API URL and key configured — in WordPress → Settings → DGateway
- WooCommerce checkout tested — complete a test order with Mobile Money
- Order status updates — orders move from "Pending" to "Completed" after payment
Security & Compliance
- HTTPS enabled — your site uses SSL (Vercel provides this automatically)
- Environment variables set — all secrets in Vercel dashboard, not in code
- No sensitive data in client code — API keys, webhook secrets, database URLs are server-side only
- CORS configured — if using the API from a different domain, add it in Dashboard → Settings → CORS
Connection Fee
- Connection fee paid — UGX 150,000 one-time fee to unlock full production API access
- Billing page visited — Dashboard → Billing → verify payment status
Monitoring
- Dashboard bookmarked — dgatewayadmin.desispay.com for monitoring transactions
- WhatsApp group joined — Join Support Group for help and updates
- Provider health checked — System Health page shows all lines healthy
Pro tip: Do a complete end-to-end test with a friend or family member. Have them visit your checkout page, pay with their phone, and verify the entire flow works — from USSD prompt to confirmation to product delivery.
Next Steps
- Read the full API docs: dgateway.desispay.com/docs
- Example app: github.com/MUKE-coder/dgateway/tree/main/examples/ecommerce-app
- WordPress plugin: dgateway.desispay.com/docs/wordpress
- Join support: WhatsApp Group
This tutorial is part of the DGateway developer series. Follow @desishub for more guides on building with African payments.