Developer documentation
Build with the TzPay API
Accept mobile money and card payments in Tanzania, receive signed webhooks, and pay out to wallets and banks, all with one REST API.
Quickstart →
Your first payment in 5 minutes.
Sandbox →
Test keys, test numbers, no real money.
Webhooks →
Get notified when payments settle.
Overview
- Base URL:
https://tzpay.wifipay.africa - Format: JSON requests and responses, UTF-8. Amounts are whole Tanzanian shillings (TZS).
- Modes:
pk_test_…keys use the sandbox;pk_live_…keys move real money. Test and live data are fully separate. - Phone numbers:
0712345678,+255712345678and255712345678are all accepted.
Quickstart
- Create an account. Sandbox access is instant.
- In the dashboard, open Developers → API keys and create a sandbox key. Copy the key ID and secret (shown once).
- Sign and send your first request (see Authentication):
// using the gateway() helper from "Request signing"
const payment = await gateway('POST', '/v1/payments', {
amount: 25000,
reference: 'ORDER-1001',
phone: '0712345678',
method: 'ussd_push',
redirect_url: 'https://myshop.co.tz/payment/done',
});
console.log(payment.status, payment.checkout_url);
The response contains a checkout_url to send your customer to, and with "method": "ussd_push" the customer immediately receives a PIN prompt on their phone.
Sandbox & test data
Sandbox keys (pk_test_…) work as soon as you register, even before your account is approved, and never move real money. Sandbox payments are processed by a simulator:
| Scenario | How to trigger it |
|---|---|
| Successful payment | Any phone number. Completes ~8 seconds after the PIN prompt is sent. |
| Failed payment | Phone number ending in 0000, e.g. 0712340000 |
| Successful withdrawal | Any payout number. Sandbox withdrawals are auto-approved. |
| Failed withdrawal | Payout number/account ending in 0000 |
Sandbox keys do not require IP whitelisting, so you can test from your laptop. You can also create test payments without code from Dashboard → Payments → New test payment with the Test mode toggle on.
Authentication & request signing
Every request to /v1/* is authenticated with your key ID and an HMAC-SHA256 signature made with your secret. Your secret never travels over the network.
| Header | Value |
|---|---|
X-Api-Key | Key ID, e.g. pk_test_abc123… |
X-Timestamp | Current Unix time in seconds (must be within 5 minutes) |
X-Nonce | Random string, 8–128 characters, unique per request |
X-Signature | hex(HMAC_SHA256(secret, canonical)) |
Idempotency-Key | Optional on POST; safely retry without creating duplicates |
canonical = METHOD + "\n" + PATH_WITH_QUERY + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(raw_body))
For GET requests the body is empty (hash of the empty string). Sign exactly the path you send, including the query string, e.g. /v1/payments?status=completed.
const crypto = require('crypto');
const BASE = process.env.GW_URL; // e.g. https://tzpay.example.com
const KEY_ID = process.env.GW_KEY_ID; // pk_test_… or pk_live_…
const SECRET = process.env.GW_SECRET; // sk_test_… or sk_live_…
async function gateway(method, path, body) {
const raw = body ? JSON.stringify(body) : '';
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString('hex');
const hash = crypto.createHash('sha256').update(raw).digest('hex');
const sig = crypto.createHmac('sha256', SECRET)
.update([method, path, ts, nonce, hash].join('\n')).digest('hex');
const res = await fetch(BASE + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-Api-Key': KEY_ID, 'X-Timestamp': ts, 'X-Nonce': nonce, 'X-Signature': sig,
...(method === 'POST' ? { 'Idempotency-Key': crypto.randomUUID() } : {}),
},
body: raw || undefined,
});
return res.json();
}
IP whitelisting (live keys)
Live keys only accept requests from your servers' public IP addresses, and every address is reviewed and approved by our team. Add them in Developers → IP whitelist; requests from any other address return 403 ip_not_whitelisted. This means a leaked key is useless to an attacker.
Idempotency
Send an Idempotency-Key header (any unique string, e.g. a UUID or your order ID) on POST requests. Retrying with the same key returns the original result; reusing it with different parameters returns 409 idempotency_conflict.
Payments
POST/v1/payments
| Field | Type | Description |
|---|---|---|
amount | integer | Required. Amount in TZS. |
reference | string | Required. Your order ID (max 100 chars). |
phone | string | Required. Customer mobile number. |
method | string | checkout (default): redirect customer to checkout_url. ussd_push: send the PIN prompt immediately. |
name, email | string | Customer details (optional). |
description | string | Shown on the checkout page. |
redirect_url | string | Where the customer returns after paying. |
cancel_url | string | Where the customer goes if they cancel. |
metadata | object | Up to 4 KB of your own data, returned in webhooks. |
{
"id": "0b7c5e1a-…",
"reference": "ORDER-1001",
"amount": 25000, "currency": "TZS",
"fee": 750, "net_amount": 24250,
"status": "processing",
"method": "ussd_push",
"livemode": false,
"checkout_url": "https://yourshop.tzpay.wifipay.africa/checkout/0b7c5e1a-…",
"created_at": "2026-10-01T09:15:00Z", "expires_at": "2026-10-01T10:15:00Z"
}
Statuses: pending → processing → completed | failed | cancelled | expired.
GET/v1/payments/{id}
Retrieve a payment. If it is still open, its status is re-confirmed with the network first, so polling always returns the truth.
GET/v1/payments
Query parameters: reference, status, limit (max 200), offset.
POST/v1/payments/{id}/push
Re-send the PIN prompt, optionally to a different number: {"phone": "0754…"}. Max 5 per payment.
Balance
GET/v1/balance
{ "available": 1450000, "held": 200000, "currency": "TZS", "livemode": true }
held is money reserved by withdrawals in progress.
Withdrawals
For safety, money can only be sent to payout accounts you saved in the dashboard (protected by two-factor verification).
GET/v1/payout-accounts
GET/v1/withdrawals/quote?amount=100000
{ "amount": 100000, "collection_fee": 0, "withdrawal_fee": 1000, "total_fee": 1000, "net_amount": 99000 }
POST/v1/withdrawals
{ "amount": 100000, "payout_account_id": "8d2f…" }
Statuses: pending_approval → approved → processing → completed | failed | rejected. Failed and rejected withdrawals return the money to your available balance.
GET/v1/withdrawals/{id}
Webhooks
Set your webhook URL in Developers → Webhooks. We POST an event whenever a payment or withdrawal changes state, and retry with exponential backoff for about 24 hours until you return a 2xx.
POST https://yourapp.example/webhooks/payments
X-Webhook-Id: 5d1e… (use to ignore duplicates)
X-Event: payment.completed
X-Signature: t=1790789375,v1=4f9a…
{ "id": "5d1e…", "event": "payment.completed", "livemode": true,
"created_at": "…", "data": { …payment object… } }
Events: payment.completed, payment.failed, payment.cancelled, withdrawal.approved, withdrawal.pending_approval, withdrawal.completed, withdrawal.failed, withdrawal.rejected.
Verifying signatures
Compute hex(HMAC_SHA256(webhook_secret, t + "." + raw_body)), compare with v1 in constant time, and reject timestamps older than 5 minutes.
// Express: use the raw body, not parsed JSON
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => {
const { t, v1 } = Object.fromEntries(req.get('X-Signature').split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.GW_WEBHOOK_SECRET)
.update(t + '.' + req.body).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!fresh || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (event.event === 'payment.completed') { /* fulfil order event.data.reference */ }
res.sendStatus(200);
});
Redirect after payment
After checkout the customer returns to your redirect_url with signed query parameters:
?payment_id=…&reference=ORDER-1001&status=completed&amount=25000&signature=…
signature = hex(HMAC_SHA256(webhook_secret, payment_id + "|" + reference + "|" + status + "|" + amount))
Verify the signature before showing a success page, and fulfil orders from webhooks or GET /v1/payments/{id}.
Errors
{ "error": { "code": "insufficient_funds", "message": "insufficient available balance" } }
| HTTP | Meaning |
|---|---|
| 400 | Validation error (see code) |
| 401 | Invalid key, signature, timestamp or reused nonce |
| 403 | IP not whitelisted, or account not active |
| 404 | Not found |
| 409 | Idempotency conflict |
| 429 | Rate limited, see Retry-After |
| 502 | The payment network rejected the request; safe to retry |
Hosted pages & domains
Every approved business gets a payment page at https://yourname.tzpay.wifipay.africa, so customers can pay without any integration. You can attach your own domain (e.g. pay.yourshop.co.tz) in Domains; HTTPS certificates are issued automatically once DNS is verified.
Need help integrating?
Email support@example.com or call +255 700 000 000.