No real money moves in sandbox

Sandbox & testing guide

Before you touch real money, you can run your entire integration against DGateway's sandbox: the same API, the same endpoints, the same response shapes — but every call is routed to test infrastructure, no real phone prompt is sent, and no money ever moves. This page walks you through making one complete one-time payment end-to-end in sandbox, with full code for Node.js, Next.js, Go, PHP and Laravel.

If you only need the test numbers and nothing else, jump straight to Test phone numbers. If you want the full step-by-step build, start at Step 1.

How test mode works

Every DGateway API key has a prefix — dgw_test_ or dgw_live_ — and that prefix alone decides the mode. There is no separate sandbox URL or request header to set: you call exactly the same https://dgatewayapi.desispay.com host for both, and just swap which key you send.

Test key (dgw_test_…)Live key (dgw_live_…)
Phone numbersMust be one of the fixed test numbers belowMust be a real phone number
Money movementNone — nothing is charged or paid outReal money is collected or disbursed
Approval stepNo USSD/PIN prompt — the test number decides the outcome instantlyReal USSD prompt sent to the payer's phone
TransactionsFlagged as test — excluded from withdrawable balance and reportsCounted as live revenue
Rate limits & capsSame validation rules, but the 100k UGX withdrawal cap etc. is skipped for test disbursementsFull caps apply
A test number used with a live key is rejected outright with TEST_NUMBER_ON_LIVE_KEY. Keep your test and live code paths clearly separated so this never happens by accident in production.

Prerequisites

  • An app created in the DGateway admin dashboard.
  • A test API key for that app (dgw_test_…) — see Step 1 below.
  • A way to make HTTPS requests in your stack (all 5 examples below are copy-pasteable).
  • Nothing else. No real phone, no real card, no money in your wallet.

Get a test API key

In the DGateway admin dashboard, open your app and go to API Access. Switch the tab from Live to Test and copy the key shown there — it starts with dgw_test_. If no test key exists yet, generate one from that same page.

Test keys never expire and cost nothing to use. Keep one around permanently for CI and local development — don't generate a fresh one for every test run.

Which providers have a sandbox

This is the single most important thing to know before you start: not every payment provider on DGateway has a test mode. Under a dgw_test_ key, only two providers actually work — the others return PROVIDER_ERROR because they have no sandbox credentials configured at all.

ProviderSandbox available?What you test with
Iotec (Uganda Mobile Money)✅ YesFixed test phone numbers — see below
Stripe (Cards)✅ YesStripe's published test cards — see below
Relworx (East Africa Mobile Money)❌ No sandboxLive-only — cannot be exercised under a test key
Marz (MTN/Airtel UG, M-Pesa KE)❌ No sandboxLive-only — cannot be exercised under a test key
For a one-time-payment integration, build and test against Iotec in sandbox. It's the only mobile-money provider with a working test mode, and it's what every example on this page uses. If your production routing needs Relworx or Marz, those paths can only be verified with small real-money transactions on a live key.

Test phone numbers

Under a test key, Iotec's sandbox wallet decides the transaction outcome purely from the phone number you send — no real USSD prompt is sent, and nothing needs to be approved on a phone. The outcome is immediate and deterministic.

Number patternOutcomeWhat happens
0111777770–0111777779SuccessTransaction completes — this is your "happy path" number
0111777780–0111777789PendingStays pending — use this to test your polling/loading state
0111777790–0111777799SentToVendorSimulates the "waiting on MTN/Airtel" intermediate state
0111777990–0111777999FailedTransaction fails — use this to test your error handling

The 256 international format works identically: 256111777771 behaves exactly like 0111777771. Every code example below uses this pair:

Use forNumber
The happy path (always succeeds)256111777111
Testing failure handling (always fails)256111779111
Any phone number outside these patterns will not behave predictably against the Iotec test wallet. Don't use your own real number under a test key — it isn't registered against the sandbox wallet and won't resolve the way you expect.

Test cards (Stripe)

If your integration also accepts cards, Stripe's test mode is enabled automatically the moment you call with a dgw_test_ key — the collect response for "provider": "stripe" returns a test stripe_publishable_key (prefixed pk_test_) automatically, so your Stripe Elements form needs no special configuration client-side.

Card numberBehaviour
4242 4242 4242 4242Always succeeds
4000 0000 0000 0002Always declines

Use any future expiry date, any 3-digit CVC, and any ZIP/postal code. The full list of Stripe test cards (3D Secure, insufficient funds, etc.) is at stripe.com/docs/testing. This page's step-by-step walkthrough below uses Iotec mobile money, since it needs no client-side card form — see the main docs' Card Payments (Stripe) section for the full Elements integration.

Step 1 — Get your test key

Covered above in Get a test API key. For the rest of this walkthrough, assume it's available as an environment variable:

DGATEWAY_API_KEY=dgw_test_xxxxxxxxxxxxxxxxxxxx

Step 2 — Build the request

A one-time payment is a single POST /v1/payments/collect. Here's the full request shape and what each field means:

POST /v1/payments/collect
Host: dgatewayapi.desispay.com
Content-Type: application/json
X-Api-Key: dgw_test_xxxxxxxxxxxxxxxxxxxx
{
"amount": 5000,
"currency": "UGX",
"phone_number": "256111777111",
"provider": "iotec",
"description": "Sandbox test payment",
"metadata": { "order_id": "test-order-1" }
}
FieldRequiredNotes
amountYesA positive number. Any amount works in sandbox — it's never actually charged.
currencyYes3-letter code. Use UGX for the Iotec sandbox.
phone_numberYesOne of the test numbers above.
providerNo, but recommendedAlways pass "iotec" explicitly in sandbox — it's the only mobile-money provider with a test mode.
descriptionNoUp to 500 characters.
metadataNoAny JSON object — echoed back on the transaction and the webhook, useful for matching to your own order id.
callback_urlNoOverrides your app's configured webhook URL for this one call.

Step 3 — Send it

Send the request with your test key in the X-Api-Key header. A successful call returns immediately with status pending — in sandbox this resolves within a couple of seconds, so the next step is polling until it settles.

// Response — status is always "pending" at first, even in sandbox
{
"data": {
"reference": "dgw_a1b2c3d4e5f6",
"provider": "iotec",
"provider_ref": "iotec-sandbox-...",
"status": "pending",
"amount": 5000,
"currency": "UGX",
"phone_number": "256111777111"
},
"message": "Collection initiated"
}
Save reference — it's the id you'll poll with in the next step, and the id your webhook payload will carry once the transaction settles.

Step 4 — Poll for the result

Whether or not you also listen for webhooks in production, every example on this page polls — it's the simplest way to see a sandbox result without setting up a public webhook URL first.

GET /v1/payments/transactions/dgw_a1b2c3d4e5f6
X-Api-Key: dgw_test_xxxxxxxxxxxxxxxxxxxx
{
"data": {
"reference": "dgw_a1b2c3d4e5f6",
"status": "completed",
"amount": 5000,
"currency": "UGX",
"failure_reason": ""
},
"message": "Transaction retrieved"
}

Poll every 1–2 seconds and stop as soon as status is no longer pending — in sandbox that's almost always within a few seconds (compare with the 256111777181-style numbers if you specifically want to exercise a longer pending state).

Step 5 — Handle the outcome

statusMeaningWhat to do
pendingStill waiting on the provider.Keep polling (or wait for the webhook).
completedMoney received — in sandbox, simulated.Fulfil the order / unlock the content.
failedPayment did not go through.Show failure_reason to the user and offer to retry.

That's the entire flow. Every language example below implements exactly these five steps as one runnable script: create the payment with the always-succeeds test number, poll until it resolves, and print the result.

Node.js

A plain script using Node's built-in fetch (Node 18+). Save as test-payment.js and run with DGATEWAY_API_KEY=dgw_test_... node test-payment.js.

// test-payment.js
const API_URL = "https://dgatewayapi.desispay.com";
const API_KEY = process.env.DGATEWAY_API_KEY;
if (!API_KEY?.startsWith("dgw_test_")) {
throw new Error("Set DGATEWAY_API_KEY to a dgw_test_ key before running this script");
}
async function collect() {
const res = await fetch(`${API_URL}/v1/payments/collect`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Api-Key": API_KEY },
body: JSON.stringify({
amount: 5000,
currency: "UGX",
phone_number: "256111777111", // always succeeds in sandbox
provider: "iotec",
description: "Sandbox test payment",
}),
});
const { data, error } = await res.json();
if (!res.ok) throw new Error(error?.message ?? "Collect request failed");
return data;
}
async function pollUntilSettled(reference, { intervalMs = 2000, timeoutMs = 30000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${API_URL}/v1/payments/transactions/${reference}`, {
headers: { "X-Api-Key": API_KEY },
});
const { data } = await res.json();
if (data.status !== "pending") return data;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Timed out waiting for ${reference} to settle`);
}
const txn = await collect();
console.log("Created:", txn.reference, "status:", txn.status);
const result = await pollUntilSettled(txn.reference);
console.log("Final status:", result.status);
if (result.status === "completed") {
console.log("✅ Sandbox payment succeeded — amount:", result.amount, result.currency);
} else {
console.log("❌ Sandbox payment failed:", result.failure_reason);
}

Next.js

For a real app you'll want this behind your own API routes so the key never reaches the browser. App Router, three files: a server-side client, a route that creates the payment, and a route that checks its status.

.env.local

DGATEWAY_API_URL=https://dgatewayapi.desispay.com
DGATEWAY_API_KEY=dgw_test_xxxxxxxxxxxxxxxxxxxx

lib/dgateway.ts

// lib/dgateway.ts — server-side only, never import this in a client component
const API_URL = process.env.DGATEWAY_API_URL!;
const API_KEY = process.env.DGATEWAY_API_KEY!;
export async function collectPayment(params: {
amount: number;
currency: string;
phone_number: string;
provider?: string;
description?: string;
}) {
const res = await fetch(`${API_URL}/v1/payments/collect`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Api-Key": API_KEY },
body: JSON.stringify(params),
});
return res.json();
}
export async function getTransaction(reference: string) {
const res = await fetch(`${API_URL}/v1/payments/transactions/${reference}`, {
headers: { "X-Api-Key": API_KEY },
});
return res.json();
}

app/api/sandbox-payment/route.ts

import { NextResponse } from "next/server";
import { collectPayment } from "@/lib/dgateway";
export async function POST() {
const result = await collectPayment({
amount: 5000,
currency: "UGX",
phone_number: "256111777111", // always succeeds in sandbox
provider: "iotec",
description: "Sandbox test payment",
});
if (result.error) return NextResponse.json(result, { status: 400 });
return NextResponse.json(result);
}

app/api/sandbox-payment/[reference]/route.ts

import { NextRequest, NextResponse } from "next/server";
import { getTransaction } from "@/lib/dgateway";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ reference: string }> },
) {
const { reference } = await params;
const result = await getTransaction(reference);
return NextResponse.json(result);
}

Client-side: trigger + poll

"use client";
import { useState } from "react";
export function SandboxPayButton() {
const [status, setStatus] = useState<string>("idle");
async function runTestPayment() {
setStatus("creating");
const createRes = await fetch("/api/sandbox-payment", { method: "POST" });
const { data } = await createRes.json();
setStatus("pending");
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const pollRes = await fetch(`/api/sandbox-payment/${data.reference}`);
const { data: txn } = await pollRes.json();
if (txn.status !== "pending") {
setStatus(txn.status); // "completed" or "failed"
return;
}
await new Promise((r) => setTimeout(r, 2000));
}
setStatus("timeout");
}
return (
<button onClick={runTestPayment}>
Run sandbox payment ({status})
</button>
);
}

Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
const apiURL = "https://dgatewayapi.desispay.com"
type txnResult struct {
Data struct {
Reference string `json:"reference"`
Status string `json:"status"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
FailureReason string `json:"failure_reason"`
} `json:"data"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func apiKey() string {
key := os.Getenv("DGATEWAY_API_KEY")
if key == "" {
panic("set DGATEWAY_API_KEY to a dgw_test_ key before running this")
}
return key
}
func doRequest(method, path string, body interface{}) (*txnResult, error) {
var reqBody *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
reqBody = bytes.NewReader(b)
} else {
reqBody = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, apiURL+path, reqBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", apiKey())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out txnResult
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if out.Error != nil {
return nil, fmt.Errorf("%s", out.Error.Message)
}
return &out, nil
}
func collect() (*txnResult, error) {
return doRequest("POST", "/v1/payments/collect", map[string]interface{}{
"amount": 5000,
"currency": "UGX",
"phone_number": "256111777111", // always succeeds in sandbox
"provider": "iotec",
"description": "Sandbox test payment",
})
}
func pollUntilSettled(reference string) (*txnResult, error) {
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
result, err := doRequest("GET", "/v1/payments/transactions/"+reference, nil)
if err != nil {
return nil, err
}
if result.Data.Status != "pending" {
return result, nil
}
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("timed out waiting for %s to settle", reference)
}
func main() {
txn, err := collect()
if err != nil {
panic(err)
}
fmt.Println("Created:", txn.Data.Reference, "status:", txn.Data.Status)
result, err := pollUntilSettled(txn.Data.Reference)
if err != nil {
panic(err)
}
if result.Data.Status == "completed" {
fmt.Printf("✅ Sandbox payment succeeded — amount: %.0f %s\n", result.Data.Amount, result.Data.Currency)
} else {
fmt.Println("❌ Sandbox payment failed:", result.Data.FailureReason)
}
}

PHP

Plain PHP with cURL — no framework, no dependencies. Run with DGATEWAY_API_KEY=dgw_test_... php test-payment.php.

<?php
// test-payment.php
$apiUrl = "https://dgatewayapi.desispay.com";
$apiKey = getenv("DGATEWAY_API_KEY");
if (!$apiKey || !str_starts_with($apiKey, "dgw_test_")) {
fwrite(STDERR, "Set DGATEWAY_API_KEY to a dgw_test_ key before running this script\n");
exit(1);
}
function dgatewayRequest(string $method, string $path, ?array $body = null): array
{
global $apiUrl, $apiKey;
$ch = curl_init($apiUrl . $path);
$headers = ["X-Api-Key: $apiKey", "Content-Type: application/json"];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
function collectPayment(): array
{
$result = dgatewayRequest("POST", "/v1/payments/collect", [
"amount" => 5000,
"currency" => "UGX",
"phone_number" => "256111777111", // always succeeds in sandbox
"provider" => "iotec",
"description" => "Sandbox test payment",
]);
if (isset($result["error"])) {
throw new Exception($result["error"]["message"]);
}
return $result["data"];
}
function pollUntilSettled(string $reference, int $timeoutSeconds = 30): array
{
$deadline = time() + $timeoutSeconds;
while (time() < $deadline) {
$result = dgatewayRequest("GET", "/v1/payments/transactions/$reference");
$data = $result["data"];
if ($data["status"] !== "pending") {
return $data;
}
sleep(2);
}
throw new Exception("Timed out waiting for $reference to settle");
}
$txn = collectPayment();
echo "Created: {$txn['reference']} status: {$txn['status']}\n";
$result = pollUntilSettled($txn["reference"]);
if ($result["status"] === "completed") {
echo "✅ Sandbox payment succeeded — amount: {$result['amount']} {$result['currency']}\n";
} else {
echo "❌ Sandbox payment failed: {$result['failure_reason']}\n";
}

Laravel

Using Laravel's Http facade, wrapped in a small Artisan command so you can run php artisan dgateway:test-payment straight from your project.

.env

DGATEWAY_API_URL=https://dgatewayapi.desispay.com
DGATEWAY_API_KEY=dgw_test_xxxxxxxxxxxxxxxxxxxx

config/services.php

<?php
// Add to the 'services' array in config/services.php
'dgateway' => [
'url' => env('DGATEWAY_API_URL', 'https://dgatewayapi.desispay.com'),
'key' => env('DGATEWAY_API_KEY'),
],

app/Console/Commands/TestDGatewayPayment.php

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class TestDGatewayPayment extends Command
{
protected $signature = 'dgateway:test-payment';
protected $description = 'Run one sandbox payment against DGateway and print the result';
public function handle(): int
{
$key = config('services.dgateway.key');
if (!$key || !str_starts_with($key, 'dgw_test_')) {
$this->error('Set DGATEWAY_API_KEY to a dgw_test_ key in your .env first.');
return self::FAILURE;
}
$created = $this->collect();
$this->info("Created: {$created['reference']} status: {$created['status']}");
$result = $this->pollUntilSettled($created['reference']);
if ($result['status'] === 'completed') {
$this->info("✅ Sandbox payment succeeded — amount: {$result['amount']} {$result['currency']}");
} else {
$this->error("❌ Sandbox payment failed: {$result['failure_reason']}");
}
return self::SUCCESS;
}
private function collect(): array
{
$response = Http::withHeaders([
'X-Api-Key' => config('services.dgateway.key'),
])->post(config('services.dgateway.url') . '/v1/payments/collect', [
'amount' => 5000,
'currency' => 'UGX',
'phone_number' => '256111777111', // always succeeds in sandbox
'provider' => 'iotec',
'description' => 'Sandbox test payment',
]);
if ($response->failed()) {
throw new \Exception($response->json('error.message') ?? 'Collect request failed');
}
return $response->json('data');
}
private function pollUntilSettled(string $reference, int $timeoutSeconds = 30): array
{
$deadline = now()->addSeconds($timeoutSeconds);
while (now()->lessThan($deadline)) {
$response = Http::withHeaders([
'X-Api-Key' => config('services.dgateway.key'),
])->get(config('services.dgateway.url') . "/v1/payments/transactions/{$reference}");
$data = $response->json('data');
if ($data['status'] !== 'pending') {
return $data;
}
sleep(2);
}
throw new \Exception("Timed out waiting for {$reference} to settle");
}
}
Prefer a web route instead of a CLI command? Put the same two Http:: calls inside a controller method and trigger it from a route — the logic doesn't change, only how it's invoked.

Switching to live keys

Once your sandbox run above prints a ✅, going live is a configuration change, not a code change:

  • Generate a live key (dgw_live_…) from the same API Access page.
  • Swap DGATEWAY_API_KEY for the live key — in whichever environment variable mechanism your stack uses (.env, your host's secrets manager, etc.).
  • Replace the test phone number with a real one collected from your actual payer.
  • Make sure your app has completed KYC verification — live collections above the connection-fee threshold require a verified, provider-enabled account.
  • If you use webhooks, confirm your production webhook URL is reachable over HTTPS and that you verify the signature — see the webhook verification snippet in the FAQ.
Never hardcode a key of either kind in client-side code. Every example above calls DGateway from a server (a script, a Next.js route handler, a Laravel command/controller) for exactly this reason.

Common sandbox errors

CodeWhy it happensFix
TEST_NUMBER_ON_LIVE_KEYYou sent one of the test phone numbers while using a dgw_live_ key.Use a real phone number with a live key, or switch back to your test key.
VALIDATION_ERRORA required field is missing, or amount/currency/phone_number failed basic validation.Check the request fields table in Step 2.
INVALID_PHONEphone_number isn't in a recognised format.Use one of the test numbers exactly as written.
PROVIDER_ERRORYou passed "provider": "relworx" or "marz" under a test key — neither has a sandbox.Use "provider": "iotec" for sandbox mobile-money tests.
UNAUTHORIZEDThe API key is missing, malformed, or doesn't belong to your app.Double-check the X-Api-Key header and that you copied the whole key.
NOT_FOUNDPolled a reference that doesn't exist or belongs to a different app.Confirm you're polling the reference returned by the collect call, with the same API key.

Ready to build?

Copy one of the examples above, swap in your test key, and run it — you'll have a working one-time payment in minutes.