Composable money APIs for Tanzania — collect, disburse, swap, and settle. One key, real-time, on-chain.
// Collect TZS from any mobile network
await fetch('https://www.ntzs.co.tz/api/v1/deposits', {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY },
body: JSON.stringify({
userId,
amountTzs: 10_000,
phoneNumber: '0744000000',
}),
})The platform
Collections
Pull funds from every mobile network and bank, T+0.
Disbursements
Pay out to phones and banks — single or bulk.
Wallets
Issue HD wallets to your users in one call.
Transfers
Move value between users instantly, on-chain.
Swap
Convert USDC ⇄ nTZS at a live rate.
Ramp
Wallet-less USDC ⇄ mobile-money settlement.
Every request requires your partner API key as a Bearer token in the Authorization header. Keys are environment-scoped.
ntzs_live_, test keys with ntzs_test_. Generate or rotate your key from the partner dashboard.curl -X POST https://www.ntzs.co.tz/api/v1/users \
-H "Authorization: Bearer ntzs_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"externalId":"user_1","email":"user@example.com"}'Build the whole integration before a single shilling moves. Test keys hit the same endpoints and return the same shapes — with simulated money, simulated identity and simulated payment providers.
livemode: false). What is simulated: the blockchain, the payment providers and the identity registry. Test data lives in its own tables and can never touch the nTZS reserve or supply.| Use this value… | …and you get |
|---|---|
Amount (deposit) or destination (payout) ending 13 | Fails — a payout reverts and the balance comes back |
Destination ending 02 | reconcile_required — burned, unconfirmed, no refund |
Amount or destination ending 99 | Stays pending forever — test your timeouts |
Destination ending 00 | No registered name — the unverified-destination warning |
Lipa till 61115582 / 70031820 | ENZI COFFEE COMPANY LIMITED / NEDA LABS LIMITED |
NIDA ending 0000 | 202 kyc_pending_review — clear it with the approve endpoint |
Anything else | Completes |
GET /api/v1/testmodereports which rails are actually live, so build ahead — but check there before you promise a launch date.curl -X POST https://www.ntzs.co.tz/api/v1/testmode/signup \
-H "Content-Type: application/json" \
-d '{"name":"Acme Bank","email":"dev@acme.co.tz"}'
# → { "apiKey": "ntzs_test_…", "webhookSecret": "whsec_…" }
# Then point every call below at that key. Start with:
curl https://www.ntzs.co.tz/api/v1/testmode \
-H "Authorization: Bearer ntzs_test_xxxxxxxxxxxx"# Settle every pending transaction now (instead of waiting ~3s)
POST /api/v1/testmode/advance
# Clear a simulated manual KYC review
POST /api/v1/testmode/users/{userId}/approve
# Wipe every test user and transaction on this key
POST /api/v1/testmode/resetRegister a user and provision an on-chain wallet in a single call. Wallets are deterministically derived from your partner seed — no blockchain transaction required.
id field. This is the nTZS user ID you will pass as userId in all subsequent requests (deposits, transfers, withdrawals). It is different from your own externalId.202 with kyc_attestation_required: you verify the customer in your own onboarding and report it via Identity (KYC), which issues the wallet. Platform-run instant NIDA verification at create-user (a 201) is a per-partner enablement — contact NEDA Labs if your integration needs it.externalId returns the existing user. Safe to call on every login.const res = await fetch('https://www.ntzs.co.tz/api/v1/users', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
externalId: 'your-internal-user-id', // required — your own system's user ID
email: 'user@example.com', // required
name: 'Jane Doe', // optional
country: 'TZ', // optional — default TZ; non-TZ signups omit NIDA
nidaNumber: '19990102614010000120', // required for TZ — user's 20-digit NIDA
phone: '255712345678', // required for TZ — user's OWN mobile money line
}),
}){
"id": "14e17d04-ec7f-4d99-91a3-dfbaca19fba1",
"externalId": "your-internal-user-id",
"email": "user@example.com",
"name": "Jane Doe",
"phone": "255712345678",
"kycStatus": "approved",
"walletAddress": "0x531B87EfdEBD19bfd05700DF6218d4786Cf2201C",
"balance": 0
}{
"id": "14e17d04-ec7f-4d99-91a3-dfbaca19fba1",
"externalId": "your-internal-user-id",
"kycStatus": "pending_review",
"code": "kyc_pending_review",
"nextStep": "kyc_session",
"walletAddress": null
}
// The user exists but has NO wallet yet. Don't make them wait —
// open a document-capture session (next section) so they can
// verify in ~2 minutes, then re-call POST /api/v1/users.Every wallet is backed by a verified identity. The standard flow: you verify the customer in your own onboarding — Tanzanian or not — and report the outcome to us here; the wallet is issued on your attestation. Instant NIDA-registry verification at create-user is a per-partner enablement.
| Tier | What happens | Speed |
|---|---|---|
| A | NIDA + phone pair verified against a bank-grade registry at create-user | instant |
| B | The phone's telco SIM registration (NIDA + fingerprints by law) corroborates — a contradiction outranks | instant |
| B′ | You verified the customer yourself and attest the outcome to us — the wallet is issued on that call | instant |
| C | Our compliance team reviews the collected evidence | < 1 business day |
202, the nextStep field tells you who resolves it. With a reliance agreement it is kyc_attestation: your own approval issues the wallet and returns the walletAddress in the same response, so a customer you have already verified never waits behind a second review. Without one it is compliance_review, and the kyc.updated webhook tells you when it clears.reference, verifiedBy and verifiedAt. Talk to us to arrange it.country at create-user and the customer is created without a NIDA, then activated by your attestation. Passports, national IDs, driving licences, residence permits and voter IDs are all accepted.kycStatus: "none" — attach an identity with POST /api/v1/users/:id/kyc (NIDA + phone, same outcomes as signup), or attest one you verified yourself.// You verified this customer yourself. Tell us, and the wallet
// is issued on this call — there is no second approval step.
// Requires a signed KYC reliance agreement with NEDA Labs.
const res = await fetch(
'https://www.ntzs.co.tz/api/v1/users/14e17d04-ec7f-4d99-91a3-dfbaca19fba1/kyc/attestation',
{
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
decision: 'approved', // or 'rejected' (then notes is required)
country: 'KE', // ISO-3166 alpha-2 of the document
idType: 'PASSPORT', // NATIONAL_ID | PASSPORT | DRIVERS_LICENSE
// | RESIDENCE_PERMIT | VOTER_ID
idNumber: 'A1234567',
fullName: 'Jane Wanjiru Doe', // as it appears on the document
reference: 'YOURCO-KYC-88213', // YOUR case id — we may ask for the file
verifiedBy: 'compliance@yourco.com', // who made the decision
verifiedAt: '2026-08-04T09:30:00Z', // when (within the last 365 days)
method: 'document_and_selfie', // optional, free text
}),
}
){
"id": "14e17d04-ec7f-4d99-91a3-dfbaca19fba1",
"externalId": "your-internal-user-id",
"kycStatus": "approved",
"caseId": "6f1e...c2a9",
"walletAddress": "0x531B87EfdEBD19bfd05700DF6218d4786Cf2201C"
}
// Errors worth handling:
// 403 kyc_reliance_not_granted no reliance agreement on your account
// 400 verified_at_stale decision older than 365 days — re-verify
// 409 identity_mismatch document number disagrees with the NIDA we hold
// 409 identity_already_registered that document already backs another of your usersFetch a user's on-chain nTZS balance alongside their profile. The balance is read live from Base mainnet at request time.
const res = await fetch(
'https://www.ntzs.co.tz/api/v1/users/14e17d04-ec7f-4d99-91a3-dfbaca19fba1',
{ headers: { 'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx' } }
)
const user = await res.json()
// {
// id: "14e17d04-ec7f-4d99-91a3-dfbaca19fba1",
// externalId: "your-internal-user-id",
// email: "user@example.com",
// phone: "255712345678",
// kycStatus: "approved", // approved | pending_review | rejected | none
// walletAddress: "0x531B87EfdEBD19bfd05700DF6218d4786Cf2201C",
// balanceTzs: 25000, // nTZS balance (18 decimals, integer TZS units)
// balanceUsdc: 6.50 // USDC balance (6 decimals, float)
// }Initiate a payment in Tanzanian Shillings. On success, nTZS is minted 1:1 to the user's wallet. Supports mobile money (push prompt), Lipa Namba (user-initiated — works even with no push rail), card and bank transfer.
id returned from POST /api/v1/users — not your own externalId.const res = await fetch('https://www.ntzs.co.tz/api/v1/deposits', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: '14e17d04-ec7f-4d99-91a3-dfbaca19fba1', // id from POST /api/v1/users
amountTzs: 10000, // minimum 500 TZS
paymentMethod: 'mobile_money', // default
phoneNumber: '255712345678', // required for mobile_money — use phoneNumber, not phone
}),
})
// { id, status: "submitted", amountTzs: 10000,
// paymentMethod: "mobile_money",
// instructions: "Check your phone for the mobile money payment prompt" }body: JSON.stringify({
userId: user.id,
amountTzs: 10000,
paymentMethod: 'card',
redirectUrl: 'https://yourapp.com/payment/success', // required, must be HTTPS
cancelUrl: 'https://yourapp.com/payment/cancel', // required, must be HTTPS
})
// { id, status: "submitted", amountTzs: 10000,
// paymentMethod: "card",
// paymentUrl: "https://pay.snippe.sh/c/..." }
// → redirect your user to paymentUrl to complete card payment// No phone number: the payment is matched by a generated reference the
// payer puts in the transfer narration.
body: JSON.stringify({
userId: user.id,
amountTzs: 250000,
paymentMethod: 'bank_transfer',
})
// {
// id, status: "submitted", amountTzs: 250000,
// paymentMethod: "bank_transfer",
// reference: "NTZ-7K2M9Q",
// instructions: {
// institution: "Selcom Paytech", // the payer selects this in
// accountNumber: "5529…", // RENDER FROM THE RESPONSE — a
// accountName: "NEDA LABS LIMITED", // hardcoded account number sends
// // every donor to the wrong place
// // the day it changes
// reference: "NTZ-7K2M9Q", // MUST go in the description
// amountTzs: 250000, // MUST match exactly
// note: "..." // ready-to-show payer guidance
// }
// }
// Show instructions in your UI. The user sends a bank transfer (TIPS — any
// Tanzanian bank) of EXACTLY amountTzs with the reference in the transfer
// description; nTZS mints automatically once the credit lands, typically
// within ~10 minutes. The reference stays valid for 72 hours and
// GET /api/v1/deposits/:id echoes it while the deposit is open, so you can
// re-show the payment details. A transfer with a missing reference or a
// different amount is held for manual review — money is never lost.// No push prompt: your user pays OUR Lipa Namba from their own
// mobile-money menu. Works on EVERY network — including when no push
// rail can reach the user's network — because the user initiates it.
body: JSON.stringify({
userId: user.id,
amountTzs: 10000,
paymentMethod: 'lipa_namba',
phoneNumber: '255744123456', // the number they will PAY FROM — this
}) // is the matching key, not a prompt target
// {
// id, status: "submitted", amountTzs: 10000,
// paymentMethod: "lipa_namba",
// instructions: {
// lipaNamba: "70031820", // the business number they pay
// accountName: "NEDA LABS LIMITED", // shown in their M-Pesa confirm screen
// amountTzs: 10000, // MUST be paid exactly
// payFromPhone: "255744123456", // MUST be paid from this number
// note: "..." // ready-to-show payer guidance
// }
// }
// Show instructions; the user does "Lipa kwa M-Pesa / Lipa Namba" in their
// own menu. The credit is matched by EXACT amount + payer phone and nTZS
// mints automatically, typically within ~5 minutes — poll
// GET /api/v1/deposits/:id for the status change. A payment from a
// different number or a different amount is held for manual review rather
// than guessed — money is never lost. Their network may add its own fee on
// top; say so in your UI to avoid "I paid 1,050, got 1,000" tickets.// Payment-collection mode: mint nTZS directly to your platform treasury
// instead of the user's individual wallet. Useful for marketplaces and
// escrow flows where you collect funds before distributing them.
//
// No attestation needed on the payer for this mode: create the user once
// (POST /api/v1/users — the 202 kyc_attestation_required response still
// returns the id) and use that id here as the tracking reference. No
// wallet is issued to the payer and no review is opened; attestation
// matters only if you later want THIS payer to hold a wallet.
body: JSON.stringify({
userId: user.id, // the payer, for tracking
amountTzs: 50000,
paymentMethod: 'mobile_money',
phoneNumber: '255712345678',
collectToTreasury: true, // mint to partner treasury wallet
})Move nTZS or USDC between platform users or to any external wallet address. Settlement is on-chain and synchronous — the API responds only after the transaction is confirmed.
toUserId sends to a platform user's wallet. toAddress sends to any Ethereum-compatible address on Base. Both fields cannot be set at the same time.const res = await fetch('https://www.ntzs.co.tz/api/v1/transfers', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
fromUserId: 'uuid-of-sender',
toUserId: 'uuid-of-recipient', // nTZS user on your platform
amountTzs: 5000,
metadata: { orderId: 'ord_123', note: 'Payment for order' }, // optional
}),
})
const transfer = await res.json()
// {
// id: "uuid...",
// status: "completed",
// txHash: "0xabc...",
// amountTzs: 5000,
// recipientAmountTzs: 4975, // after platform fee
// feeAmountTzs: 25,
// feeTxHash: "0xdef...", // fee tx to your treasury, if fee > 0
// toAddress: "0x531B..." // resolved destination wallet
// }// Send nTZS to ANY wallet address — no recipient user required.
// Use toAddress instead of toUserId.
const res = await fetch('https://www.ntzs.co.tz/api/v1/transfers', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
fromUserId: 'uuid-of-sender',
toAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18', // any valid EVM address
amountTzs: 10000,
}),
})
const transfer = await res.json()
// {
// id: "uuid...",
// status: "completed",
// txHash: "0xabc...",
// amountTzs: 10000,
// recipientAmountTzs: 9950,
// feeAmountTzs: 50,
// toAddress: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18"
// }// Same endpoint — add token: 'USDC' and use the token-agnostic amount field.
// USDC uses 6 decimals — fractional amounts are supported.
const res = await fetch('https://www.ntzs.co.tz/api/v1/transfers', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
fromUserId: 'uuid-of-sender',
toAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18',
token: 'USDC',
amount: 12.5, // 12.5 USDC
}),
})
const transfer = await res.json()
// {
// id: "uuid...",
// status: "completed",
// txHash: "0xabc...",
// token: "usdc",
// amount: 12.5,
// recipientAmount: 12.4375, // after platform fee
// feeAmount: 0.0625,
// feeTxHash: "0xdef...",
// toAddress: "0x742d35..."
// // Legacy amountTzs / recipientAmountTzs / feeAmountTzs are omitted for non-nTZS tokens.
// }// The second tender at merchant checkout: your signed-in user pays a
// Biashara merchant directly from their own nTZS balance — no STK push,
// no waiting on mobile money. Same handle, linkId and amount rules as the
// hosted checkout; the sale lands in the merchant's sales list and stats
// exactly like a mobile-money sale, marked paymentMethod: 'ntzs_balance'.
const res = await fetch('https://www.ntzs.co.tz/api/v1/merchant/pay', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(), // recommended — one key, one payment
},
body: JSON.stringify({
payerUserId: user.id, // YOUR user — you are vouching they are signed in
handle: 'mamantilie', // the merchant's checkout handle
amountTzs: 25000, // whole shillings, min 100; must match a fixed link's amount
linkId: 'uuid-of-link', // optional — validated against the merchant's link
payerName: 'Asha M.', // optional — shown in the merchant's sales list
}),
})
const sale = await res.json()
// 201 — settlement is synchronous; this response is final, no polling needed:
// {
// id: "uuid...", // the sale (collection) id
// collectionId: "uuid...",
// transferId: "uuid...",
// status: "completed",
// paymentMethod: "ntzs_balance",
// txHash: "0xabc...",
// amountTzs: 25000,
// merchant: { handle: "mamantilie", businessName: "Mama Ntilie" },
// livemode: true
// }
// The merchant receives the FULL amount — no fee is skimmed from a sale.
//
// Key errors: 400 insufficient_balance (details.available / .shortfall),
// 400 invalid_amount, 404 'Merchant not found or inactive',
// 404 'Payment link not found or inactive', 400 'Amount does not match link amount',
// 403 wallet_frozen, 409 in-flight/idempotency replay, 429 throttled.
//
// If your checkout screen polls GET /api/pay/status?id=…, pass the returned
// id — it answers { status: "success" } in the same shape as a mobile-money
// deposit id.
//
// Test mode: same endpoint, test key. The merchant and link are validated
// FOR REAL (a wrong handle fails now, not on go-live day); the payer's
// simulated balance is debited; the real merchant sees nothing.
//
// A balance sale is a transfer between two nTZS holders — nothing is minted,
// no deposit is created, supply does not change.Two-step flow: quote, then execute. The quote returns the recipient's registered name, the fee breakdown and the net amount — everything your confirmation screen must show — plus a signed quoteId that authorizes execution at exactly those terms. amountTzs is always the amount the recipient RECEIVES (net).
fees.totalFeeTzs) and what the recipient receives — this is a Bank of Tanzania consumer-disclosure requirement. On success say “TZS 10,000 is on its way to JOHN DOE (fees TZS 382)” — never present the gross burn amount as the amount “sent”.const res = await fetch('https://www.ntzs.co.tz/api/v1/withdrawals/quote', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
amountTzs: 10000, // recipient receives this (net), minimum 5,000 TZS
phoneNumber: '255712345678', // Vodacom, Airtel, Tigo/Yas, Halotel, TTCL
// …or a BANK instead of a phone (exactly one destination):
// bankCode: 'CRDB', accountNumber: '0152768903600'
}),
})
const quote = await res.json()
// {
// quoteId: "eyJ2IjoxLCJw…", // null if balance.sufficient is false
// expiresAt: "…", // valid 5 minutes
// recipientName: "JOHN DOE", // registered holder — null = no answer (don't block)
// receiveAmountTzs: 10000,
// burnAmountTzs: 10382, // deducted from the user's balance
// payoutRail: "selcom", // the rail this quote is priced for
// fees: { platformFeeTzs: 52, pspFeeTzs: 300, nedaFeeTzs: 30, totalFeeTzs: 382 },
// balance: { availableTzs: 25000, sufficient: true },
// approval: { requiresApproval: false, secondApprovalThresholdTzs: 1000000 }
// }
//
// The PSP fee follows the serving rail's published tariff (amount-tiered) —
// NEVER hardcode fees; display the quote's figures. Bank quotes answer
// bankCode/bankName/accountNumber instead of recipientPhone.
//
// Approval warnings render from the 'approval' object, never a hardcoded number:
// requiresApproval=true means this withdrawal queues for an operator
// (status "requested") instead of paying instantly. The threshold is
// platform configuration — a number baked into your app WILL go stale.const res2 = await fetch('https://www.ntzs.co.tz/api/v1/withdrawals', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
amountTzs: 10000, // must match the quote
phoneNumber: '255712345678', // must match the quote
quoteId: quote.quoteId, // optional today — MANDATORY after the announced enforcement date
}),
})
const withdrawal = await res2.json()
// { id, status: "burned", receiveAmountTzs: 10000, recipientName: "JOHN DOE",
// totalFeeTzs: 382,
// payoutRail: "selcom", // the rail that served
// payoutReference: "16437765-…", // our reference (poll GET /:id with it)
// payoutReceipt: "SB0803…", // the rail's own receipt, when it returns one
// payoutStatus: "completed" | "pending",
// confirmationMessage: "TZS 10,000 sent to JOHN DOE (2557…) via Selcom — ref …",
// message: "Withdrawal processed: 10000 TZS on its way…" }
//
// PUSH confirmationMessage TO YOUR USER on completion — the rail's own
// confirmation SMS goes only to the platform's corporate account, so
// without it your user never sees a receipt. GET /api/v1/withdrawals/:id
// returns the same fields for status polling.
//
// Errors to handle (fetch a fresh quote and re-confirm):
// 400 invalid_quote — expired (> 5 min) or malformed
// 400 quote_mismatch — user/destination/amount differ from the quote
// 409 quote_stale — pricing changed since the quote was issued
// 400 quote_required — enforcement is on and no quoteId was sent
// 409 duplicate_withdrawal — identical withdrawal within 5 min still holding
// funds; do NOT retry (allowDuplicate: true to force)
// 503 bank_rail_unavailable — bank payouts off on this environment
//
// Large amounts (>= 1,000,000 TZS) queue instead (phones only — banks
// answer 400 bank_amount_unsupported at this size; split the withdrawal):
// { id, status: "requested", message: "…requires admin approval…" }Burn a user's nTZS and pay a merchant Lipa Namba on any network (M-Pesa, Tigo, Airtel, Selcom) or a biller (LUKU electricity, GEPG government control numbers, DSTV, airtime and more) directly from the reserve. Same quote → confirm → execute shape as Disbursements. amountTzs is always the PRINCIPAL the destination receives; fees are added on top. Quote-first by design — execution ALWAYS requires a quoteId.
POST /api/v1/lookup/qr, then use the returned payNumber in the ordinary quote → execute flow below. Same tariff as a typed till payment; scanning costs nothing extra.merchantName, not qrMerchantName. The realistic attack on printed QR is a person with a printer pasting their code over a merchant's, so the customer is in the right shop and pays a stranger. The name inside the QR is whatever the attacker wrote; merchantName comes from the acquirer's register and is not. When they disagree we return nameMatch: false — show both names and make the user confirm. We also verify the code's own checksum and refuse an altered one outright.recipientName + number), the fee (fees.totalFeeTzs) and the total burned (burnAmountTzs). When recipientName is null, show the raw number with an “unverified destination” caution. This is a Bank of Tanzania consumer-disclosure requirement.// The camera gives you a string, not a till number.
const res = await fetch('https://www.ntzs.co.tz/api/v1/lookup/qr', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ payload: scannedString }), // starts "000201"
})
// {
// payNumber: "123456", // ← pay this
// merchantName: "KARIAKOO HARDWARE LIMITED", // ← DISPLAY this
// qrMerchantName: "KARIAKOO HARDWARE", // printed in the QR
// nameMatch: true, // false → warn: possible swapped sticker
// amountTzs: null, // set on a dynamic QR — use it, don't retype
// dynamic: false,
// resolution: "resolved", // ONLY "resolved" is payable
// warnings: []
// }
if (data.resolution !== 'resolved') {
// "unresolved" — nothing in the code is a registered merchant
// "ambiguous" — more than one is; make the user choose
// Either way: do not pay. Ask for the printed till number.
}const res = await fetch('https://www.ntzs.co.tz/api/v1/spend/quote', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
kind: 'lipa', // 'lipa' (merchant till) | 'bill' (biller)
amountTzs: 1000, // PRINCIPAL the till receives, minimum 500
payNumber: '61115582', // merchant Lipa Namba (lipa)
// for kind: 'bill' send instead:
// utilityCode: 'LUKU', // from GET /api/v1/spend/billers
// utilityRef: '01234567890', // meter / control / smartcard number
}),
})
const quote = await res.json()
// {
// quoteId: "eyJ2IjoxLCJr…", // null if balance.sufficient is false
// expiresAt: "…", // valid 5 minutes
// target: { payNumber: "61115582", network: "…" }, // network auto-resolved
// // from the till registry when you
// // omit it; your explicit value wins
// recipientName: "ENZI COFFEE COMPANY LIMITED", // null = registry had no answer
// principalTzs: 1000,
// burnAmountTzs: 1035, // deducted from the user's balance
// fees: { selcomFeeTzs: 30, platformFeeTzs: 5, totalFeeTzs: 35 },
// balance: { availableTzs: 25000, sufficient: true }
// }const res2 = await fetch('https://www.ntzs.co.tz/api/v1/spend', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
kind: 'lipa',
amountTzs: 1000, // must match the quote
payNumber: '61115582', // must match the quote
quoteId: quote.quoteId, // ALWAYS required
}),
})
const spend = await res2.json()
// { id, status: "burned", payoutStatus: "completed", reference: "202607259999",
// recipientName: "ENZI COFFEE COMPANY LIMITED", principalTzs: 1000,
// fees: { totalFeeTzs: 35 }, message: "Payment of 1000 TZS dispatched…" }
//
// payoutStatus "pending" settles server-side within a minute; a failed
// payment AUTO-REVERTS the burn (balance restored) — no partner action.
// Subscribe to the spend.updated webhook to hear the final state.
//
// BILLS (kind: 'bill') RETURN A VOUCHER, AND IT IS THE PRODUCT.
// A LUKU or utility purchase settles with the meter token the customer
// types into their meter — a receipt is not a substitute for it:
// utilityToken: "5373 0001 9365 2741 2169" // display prominently
// utilityUnits: "2.8kWh"
// utilityReceipt: "…" // the biller's own receipt
// It is null until the biller issues it (usually seconds, occasionally
// later). Render it as soon as it appears — from the spend.updated
// webhook, or by polling GET /api/v1/spend/:id — and PERSIST it: the
// customer will come back for it, and we cannot reissue what the biller
// only sends once.
//
// Errors to handle (fetch a fresh quote and re-confirm):
// 400 quote_required — no quoteId sent (spend has no un-quoted path)
// 400 invalid_quote — expired (> 5 min) or malformed
// 400 quote_mismatch — user/destination/amount differ from the quote
// 409 quote_stale — pricing changed since the quote was issued
// 400 unknown_biller — utilityCode not in the catalogue (see supportedCodes)
// 400 invalid_utility_ref — reference fails the biller's format
// 503 spend_disabled / spend_kind_disabled — rail not enabled yet// Render your bill-payment picker from live data, never a hardcoded list.
const { categories } = await (await fetch(
'https://www.ntzs.co.tz/api/v1/spend/billers',
{ headers: { 'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx' } }
)).json()
// categories: [{ key, label, billers: [{ code, referenceLabel, referenceKind,
// referenceMinLength, referenceMaxLength, selcomFeeFreeUnder20k,
// feeFreeUnder20k, feeNote }] }]
// e.g. LUKU → referenceLabel "Meter No", 11 digits
//
// ⚠ Do NOT render selcomFeeFreeUnder20k as a "no fee" badge. It means Selcom
// charges nothing on that biller under 20,000 TZS — true, and worth showing —
// but a service fee applies to EVERY payment, so the payer is never charged
// nothing. A 1,000 TZS government bill costs 35 TZS. Use feeNote for wording,
// and always show the total from /spend/quote.Public endpoint — no API key required. Use this to show users a live quote before they confirm. Rates are valid for ~30 seconds.
expectedOutput and minOutput → call POST /api/v1/swap within the expiresAt window.true, the pool may not fill the full amount. Warn the user and consider reducing the swap size.SLIPPAGE_EXCEEDED.// Same-chain: USDT → nTZS on Base
const res = await fetch(
'https://www.ntzs.co.tz/api/v1/swap/rate?from=USDT&to=NTZS&amount=100'
)
const rate = await res.json()
// {
// from: "USDT", to: "NTZS", amount: 100,
// midRate: 3750, // market reference rate
// rate: 3744.375, // effective rate after LP spread
// expectedOutput: 374437.5,
// minOutput: 370993.1, // with 1% slippage guard
// expiresAt: "2026-04-27T10:00:30.000Z",
// lowLiquidity: false
// }
// Cross-chain: USDT on BNB → nTZS on Base
const crossRate = await fetch(
'https://www.ntzs.co.tz/api/v1/swap/rate' +
'?from=USDT&to=NTZS&fromChain=bnb&toChain=base&amount=50'
)Turn your customers into merchants: they collect payments by QR or link, watch their sales, control how much auto-settles to mobile money, cash out — and, where a lender is attached, draw working capital against their sales history. All under your UI, your brand. You hold no wallet, no key and no float.
biashara capability and approved KYB — it issues merchant wallets and moves merchant money. Ask us to enable it. A key without it gets 403.404, never 403 — we don't confirm that it exists.PATCH fail.ntzs_test_ key gets 501 here. The merchant rails run against live payment providers, so the meaningful test is a real one: activate a merchant, create a link, push a small payment (1,000 TZS) to a phone you control, and watch it land in /collections, /stats and /wallet, then withdraw it back out. Everything else on this page does have a full sandbox — build those against a test key while your Biashara account is being set up.// Provision the user first (Step 3), then:
const res = await fetch('https://www.ntzs.co.tz/api/v1/biashara/accounts', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id, // from POST /api/v1/users
email: 'shop@example.co.tz',
businessName: 'Duka la Asha',
settlementPhone: '0744277496',
}),
})
const merchant = await res.json()
// {
// merchantId: "…", // send as x-merchant-id on every call below
// handle: "dukalaasha", // PUBLIC payment identity — read it back!
// walletAddress: "0x…",
// }
// Idempotent per userId/email WITHIN YOUR OWN BOOK — calling again returns the
// same merchant with alreadyExists: true.
//
// handle is globally unique across the platform. If yours is taken we assign
// the next free variant and return it, so activation never fails on a
// collision — never assume the handle you asked for.const headers = {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'x-merchant-id': merchant.merchantId,
'Content-Type': 'application/json',
}
// Collect
POST /api/v1/biashara/links { amountTzs, label } // payment link + QR
GET /api/v1/biashara/links
DELETE /api/v1/biashara/links
// Show them their business
GET /api/v1/biashara/stats // sales today / this month
GET /api/v1/biashara/collections // history, ?limit= up to 50, cursor-paginated
GET /api/v1/biashara/wallet // balance
// Settlement + cash-out
GET /api/v1/biashara/settlement // auto-settle % + payout phone
PATCH /api/v1/biashara/settlement
POST /api/v1/biashara/withdraw { amountTzs: 10000 } // NET received, min 5,000
// Working capital
GET /api/v1/biashara/financing/status // facility, drawn, available
POST /api/v1/biashara/financing/withdraw // draw against itFor platforms serving mobile-money agents (wakalas). Each agent gets one nTZS float that pays any mobile wallet, any Lipa Namba on any network, and any biller — from a single balance, with no per-network float placement and no counterparty to find. You set the retail price; your margin is configured on your account.
wakala capability and approved KYB. And read Limits below before planning a rollout — each float is capped as one sandbox participant.| Sandbox limit | Value |
|---|---|
| Per transaction | 1,000,000 TZS |
| Per float, per day | 2,000,000 TZS |
| Per float, 30 days | 60,000,000 TZS |
| Participants total | 100 |
limit, requested and usedInPeriod so you can render “agent limit reached” properly.POST /api/v1/partners/sub-wallets
{ "label": "Agent 042 — Kariakoo" }
// → { id, address, label } ← keep id against your agent record// Pay a bill (LUKU, GEPG, water, TV…)
POST /api/v1/spend/quote
{ "subWalletId": "…", "kind": "bill",
"amountTzs": 20000, "utilityCode": "LUKU", "utilityRef": "01234567890" }
POST /api/v1/spend { …same…, "quoteId": "…" }
// Pay a merchant till on any network
POST /api/v1/spend/quote { "subWalletId": "…", "kind": "lipa",
"amountTzs": 5000, "payNumber": "61115582" }
// Pay out to a customer's mobile wallet
POST /api/v1/withdrawals/quote
{ "subWalletId": "…", "amountTzs": 50000, "phoneNumber": "0744277496" }
// Everything else is identical to the user-funded flow — same quotes,
// same fees, same errors. Quotes bind to the float they were priced for,
// and failures revert to the float, never to a user wallet.Execute a swap for any WaaS user. Supports nTZS, USDC, and USDT on Base — plus cross-chain USDT swaps via BNB Smart Chain. Streams status in real time over SSE.
slippageBps between rate fetch and execution, you receive FAILED / SLIPPAGE_EXCEEDED. Re-fetch the rate and let the user re-confirm before retrying.GET /api/v1/users/:id after FILLED — returns live on-chain balances for nTZS, USDC, and USDT with no caching.const res = await fetch('https://www.ntzs.co.tz/api/v1/swap', {
method: 'POST',
headers: {
'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
fromToken: 'USDT', // 'NTZS' | 'USDC' | 'USDT'
toToken: 'NTZS',
amount: 100,
slippageBps: 100, // optional, default 100 (1%)
}),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value).split('\n')) {
if (!line.startsWith('data: ')) continue
const event = JSON.parse(line.slice(6))
// CHECKING → balance check
// SENDING → user → solver transfer (txHash)
// FILLING → solver → user transfer (txHash)
// FILLED → complete (txHash)
// FAILED → event.error for code
if (event.status === 'FILLED' || event.status === 'FAILED') break
}
}body: JSON.stringify({
userId: user.id,
fromToken: 'USDT',
toToken: 'NTZS',
fromChain: 'bnb', // USDT sent from BNB Smart Chain
toChain: 'base', // nTZS received on Base
amount: 50,
slippageBps: 150,
})curl -N -X POST https://www.ntzs.co.tz/api/v1/swap \
-H "Authorization: Bearer ntzs_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"userId":"uuid...","fromToken":"USDT","toToken":"NTZS","amount":100}'
# data: {"status":"CHECKING","message":"Checking balance..."}
# data: {"status":"SENDING","message":"Sending 100 USDT to liquidity pool...","txHash":"0x..."}
# data: {"status":"FILLING","message":"Sending nTZS to your wallet...","txHash":"0x..."}
# data: {"status":"FILLED","message":"Swap complete!","txHash":"0x..."}Convert USDC ⇄ mobile money (TZS) over the API with no per-end-user wallets — or pay a merchant Lipa Namba or bill straight from USDC. You keep a USDC float with us; off-ramps debit it, on-ramps deliver USDC to you. nTZS is an internal rail you never touch.
GET /api/v1/ramp/[id] for one settlement, GET /api/v1/ramp/settlements to list. You also receive ramp.settlement.completed / ramp.settlement.failed webhooks.curl https://www.ntzs.co.tz/api/v1/ramp/balance \
-H "Authorization: Bearer ntzs_live_xxxxxxxxxxxx"
// { settlementAddress: "0x…", chain: "base",
// token: { symbol: "USDC", decimals: 6 }, usdcBalance: "2500.0",
// ntzsBalance: "0" }
//
// A non-zero ntzsBalance is value a REVERTED off-ramp returned to you —
// it is consumed automatically by your next off-ramp before any USDC is
// debited. No value is ever lost to a failed payout.// Off-ramp: pass tzsAmount (the EXACT net the recipient receives —
// recommended; your users think in shillings) OR usdcAmount. Exactly one.
// On-ramp: pass tzsAmount (what the payer pays).
fetch('https://www.ntzs.co.tz/api/v1/ramp/quote', {
method: 'POST',
headers: { Authorization: 'Bearer ntzs_live_…', 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: 'offramp', tzsAmount: 50000 }),
})
// { quoteId, usdcAmount: 19.459614, // what your float will be debited
// tzsAmount: 50000, // the recipient receives EXACTLY this
// feeTzs: 1207, rateUsdTzs: 2631.45, expiresAt }fetch('https://www.ntzs.co.tz/api/v1/ramp/offramp', {
method: 'POST',
headers: { Authorization: 'Bearer ntzs_live_…', 'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ quoteId, phoneNumber: '0744000000' }),
})
// Answers in ~10 seconds with a DEFINITIVE state:
// 201 { settlementId, status: "completed" } — settled inline
// 202 { settlementId, status: "paying_out" } — money captured, payout
// dispatched; completion arrives on the ramp.settlement.completed
// webhook and GET /api/v1/ramp/:id. A 202 is success-in-flight —
// NEVER re-execute on it.
//
// If a payout fails definitively the settlement REVERTS: full value back
// to your settlementAddress (as nTZS — see ntzsBalance above), webhook
// ramp.settlement.failed carries returnedAsNtzsTo. Re-quote + execute
// and the retry completes on the recovered value.// Pass a destination on the QUOTE — it's priced on the Selcom tariff and
// returns the merchant/biller's registered name to show before you confirm.
const q = await fetch('.../api/v1/ramp/quote', { method: 'POST', headers,
body: JSON.stringify({ direction: 'offramp', usdcAmount: 10,
destination: { kind: 'lipa', payNumber: '61115582' } }) }).then(r => r.json())
// { quoteId, tzsAmount, feeTzs, recipientName: 'ENZI COFFEE COMPANY LIMITED', … }
// bill example: destination: { kind: 'bill', utilityCode: 'LUKU', utilityRef: '<meter>' }
await fetch('.../api/v1/ramp/offramp', { method: 'POST', headers,
body: JSON.stringify({ quoteId: q.quoteId }) }) // no phoneNumber for lipa/bill
// 201/202 { settlementId, status, destination, recipientName }// Prompts the payer's phone; delivers USDC to destinationAddress once paid.
fetch('https://www.ntzs.co.tz/api/v1/ramp/onramp', {
method: 'POST',
headers: { Authorization: 'Bearer ntzs_live_…', 'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ quoteId, phoneNumber: '0744000000', destinationAddress: '0x…' }),
})
// 202 { settlementId, status: "minting" }Receive real-time POST notifications to your server when payment events complete. Configure your endpoint and signing secret in the partner dashboard.
timestamp.body — always verify the signature before processing.import crypto from 'crypto'
app.post('/webhooks/ntzs', express.raw({ type: 'application/json' }), (req, res) => {
// Verify signature — nTZS signs timestamp.body with HMAC-SHA256
const sig = req.headers['x-webhook-signature'] as string
const timestamp = req.headers['x-webhook-timestamp'] as string
const signedPayload = `${timestamp}.${req.body.toString()}`
const expected = crypto
.createHmac('sha256', process.env.NTZS_WEBHOOK_SECRET!)
.update(signedPayload)
.digest('hex')
if (sig !== expected) {
return res.status(400).send('Invalid signature')
}
const event = JSON.parse(req.body.toString())
switch (event.type) {
case 'deposit.completed':
// Fires when the deposit MINTS - for bank transfers that is after our
// statement sync matches the credit (minutes, not seconds).
// event.data: { depositId, userId, amountTzs, walletAddress, txHash,
// pspReference, livemode }
await creditUserAccount(event.data.userId, event.data.amountTzs)
break
// Withdrawal completion is answered on the execute response and
// GET /api/v1/withdrawals/:id (see confirmationMessage) - there is no
// withdrawal webhook event. Transfers settle synchronously.
case 'kyc.updated':
// event.data: { externalId, kycStatus, provider, jobId }
// kycStatus: 'approved' | 'rejected' | 'pending_review'
// On 'approved': re-call POST /api/v1/users (idempotent) — the
// response now carries the user's walletAddress.
break
case 'spend.updated':
// event.data: { spendId, externalId, reference, status, kind,
// recipientName, principalTzs, burnAmountTzs, actualChargesTzs,
// selcomReceipt, utilityToken, utilityUnits, utilityReceipt }
// status: 'completed' | 'reverted' | 'reconcile_required'
// Fires when a spend that returned payoutStatus 'pending' reaches its
// terminal state. 'reverted' = the burn was refunded (payment failed).
//
// For kind 'bill', this event may fire a SECOND time carrying
// utilityToken once the biller issues the voucher — the payment can
// complete before the token exists. Treat it as an update to the same
// spendId, not a new spend, and show the token the moment it arrives.
break
}
res.status(200).json({ received: true })
})All errors return a consistent JSON body. Match on the error field for programmatic handling.
| Error code | Status | Meaning |
|---|---|---|
| missing_required_fields | 400 | A required body field is absent |
| invalid_amount | 400 | Amount is zero, negative, or below minimum |
| invalid_transfer | 400 | fromUserId equals toUserId |
| wallet_not_provisioned | 400 | Wallet address is still being derived |
| insufficient_balance | 400 | Sender does not have enough nTZS |
| user_not_found | 404 | userId not found under your partner account |
| unauthorized | 401 | Missing or invalid API key |
| kyc_required | 400 | nidaNumber (and phone) missing on a TZ create-user — non-TZ signups send country instead |
| kyc_pending_review | 202 | Not an error — verification open; offer a capture session |
| identity_binding_failed | 400 | Phone registered to a different person (retro-KYC re-attempts); signups now soft-land to document capture instead |
| nida_already_registered | 409 | This NIDA already backs another wallet on your platform |
| invalid_country | 400 | country must be an ISO 3166-1 alpha-2 code |
| kyc_unavailable | 503 | Verification temporarily unavailable — retry shortly |
| relayer_unavailable | 503 | Gas relay temporarily offline — retry shortly |
| blockchain_error | 500 | On-chain transaction failed — see details.technicalError |
| network_error | 500 | RPC connection timed out — retry |
// HTTP 4xx/5xx response body:
{
"error": "insufficient_balance", // machine-readable code
"message": "Sender has insufficient nTZS balance",
"details": {
"available": 3200,
"requested": 5000,
"shortfall": 1800
}
}