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.
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 numbers | Must be one of the fixed test numbers below | Must be a real phone number |
| Money movement | None — nothing is charged or paid out | Real money is collected or disbursed |
| Approval step | No USSD/PIN prompt — the test number decides the outcome instantly | Real USSD prompt sent to the payer's phone |
| Transactions | Flagged as test — excluded from withdrawable balance and reports | Counted as live revenue |
| Rate limits & caps | Same validation rules, but the 100k UGX withdrawal cap etc. is skipped for test disbursements | Full caps apply |
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.
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.
| Provider | Sandbox available? | What you test with |
|---|---|---|
| Iotec (Uganda Mobile Money) | ✅ Yes | Fixed test phone numbers — see below |
| Stripe (Cards) | ✅ Yes | Stripe's published test cards — see below |
| Relworx (East Africa Mobile Money) | ❌ No sandbox | Live-only — cannot be exercised under a test key |
| Marz (MTN/Airtel UG, M-Pesa KE) | ❌ No sandbox | Live-only — cannot be exercised under a test 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 pattern | Outcome | What happens |
|---|---|---|
| 0111777770–0111777779 | Success | Transaction completes — this is your "happy path" number |
| 0111777780–0111777789 | Pending | Stays pending — use this to test your polling/loading state |
| 0111777790–0111777799 | SentToVendor | Simulates the "waiting on MTN/Airtel" intermediate state |
| 0111777990–0111777999 | Failed | Transaction 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 for | Number |
|---|---|
| The happy path (always succeeds) | 256111777111 |
| Testing failure handling (always fails) | 256111779111 |
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 number | Behaviour |
|---|---|
| 4242 4242 4242 4242 | Always succeeds |
| 4000 0000 0000 0002 | Always 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_xxxxxxxxxxxxxxxxxxxxStep 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/collectHost: dgatewayapi.desispay.comContent-Type: application/jsonX-Api-Key: dgw_test_xxxxxxxxxxxxxxxxxxxx
{ "amount": 5000, "currency": "UGX", "phone_number": "256111777111", "provider": "iotec", "description": "Sandbox test payment", "metadata": { "order_id": "test-order-1" }}| Field | Required | Notes |
|---|---|---|
amount | Yes | A positive number. Any amount works in sandbox — it's never actually charged. |
currency | Yes | 3-letter code. Use UGX for the Iotec sandbox. |
phone_number | Yes | One of the test numbers above. |
provider | No, but recommended | Always pass "iotec" explicitly in sandbox — it's the only mobile-money provider with a test mode. |
description | No | Up to 500 characters. |
metadata | No | Any JSON object — echoed back on the transaction and the webhook, useful for matching to your own order id. |
callback_url | No | Overrides 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"}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_a1b2c3d4e5f6X-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
| status | Meaning | What to do |
|---|---|---|
pending | Still waiting on the provider. | Keep polling (or wait for the webhook). |
completed | Money received — in sandbox, simulated. | Fulfil the order / unlock the content. |
failed | Payment 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.jsconst 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.comDGATEWAY_API_KEY=dgw_test_xxxxxxxxxxxxxxxxxxxxlib/dgateway.ts
// lib/dgateway.ts — server-side only, never import this in a client componentconst 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.comDGATEWAY_API_KEY=dgw_test_xxxxxxxxxxxxxxxxxxxxconfig/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"); }}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_KEYfor 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.
Common sandbox errors
| Code | Why it happens | Fix |
|---|---|---|
TEST_NUMBER_ON_LIVE_KEY | You 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_ERROR | A required field is missing, or amount/currency/phone_number failed basic validation. | Check the request fields table in Step 2. |
INVALID_PHONE | phone_number isn't in a recognised format. | Use one of the test numbers exactly as written. |
PROVIDER_ERROR | You passed "provider": "relworx" or "marz" under a test key — neither has a sandbox. | Use "provider": "iotec" for sandbox mobile-money tests. |
UNAUTHORIZED | The 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_FOUND | Polled 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.