TzPay

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.

Overview

Quickstart

  1. Create an account. Sandbox access is instant.
  2. In the dashboard, open Developers → API keys and create a sandbox key. Copy the key ID and secret (shown once).
  3. 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:

ScenarioHow to trigger it
Successful paymentAny phone number. Completes ~8 seconds after the PIN prompt is sent.
Failed paymentPhone number ending in 0000, e.g. 0712340000
Successful withdrawalAny payout number. Sandbox withdrawals are auto-approved.
Failed withdrawalPayout 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.

HeaderValue
X-Api-KeyKey ID, e.g. pk_test_abc123…
X-TimestampCurrent Unix time in seconds (must be within 5 minutes)
X-NonceRandom string, 8–128 characters, unique per request
X-Signaturehex(HMAC_SHA256(secret, canonical))
Idempotency-KeyOptional 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

FieldTypeDescription
amountintegerRequired. Amount in TZS.
referencestringRequired. Your order ID (max 100 chars).
phonestringRequired. Customer mobile number.
methodstringcheckout (default): redirect customer to checkout_url. ussd_push: send the PIN prompt immediately.
name, emailstringCustomer details (optional).
descriptionstringShown on the checkout page.
redirect_urlstringWhere the customer returns after paying.
cancel_urlstringWhere the customer goes if they cancel.
metadataobjectUp 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" } }
HTTPMeaning
400Validation error (see code)
401Invalid key, signature, timestamp or reused nonce
403IP not whitelisted, or account not active
404Not found
409Idempotency conflict
429Rate limited, see Retry-After
502The 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.

Get sandbox keys