Move money
in a few lines.

Composable money APIs for Tanzania — collect, disburse, swap, and settle. One key, real-time, on-chain.

REST + webhooks·Idempotent by default·www.ntzs.co.tz/api/v1
// 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

Composable money primitives

Insurance · Collections + TreasuryPayroll · Disbursements + TreasurySettlement · RampNeobank · Wallets + Collections + Disbursements + Transfers + Swap + SpendSuper-app · Wallets + Collections + Spend
Step 1

Authentication

Every request requires your partner API key as a Bearer token in the Authorization header. Keys are environment-scoped.

Building with an assistant? Do not paste this page into a chat — give it one of these instead. /llms-full.txt is the whole documentation as one file written for a model: capabilities, the rules that decide whether an integration works, runnable recipes, every endpoint and every error code, in about 6k tokens. /llms.txt is the short map for tools that follow links, and /openapi.json is the OpenAPI 3.1 contract to generate a typed client from. All three are generated from the same source as this page, so they cannot drift from the API.
Key format: Production keys start with ntzs_live_, test keys with ntzs_test_. Generate or rotate your key from the partner dashboard.
Security: Never expose your API key in client-side or mobile code. All nTZS API calls must originate from your backend server.
curl
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"}'
Step 2

Test mode

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.

What is real: the fee maths, the quote signatures and expiry, every validation rule and error code, and the webhooks (really delivered, really signed, carrying 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 13Fails — a payout reverts and the balance comes back
Destination ending 02reconcile_required — burned, unconfirmed, no refund
Amount or destination ending 99Stays pending forever — test your timeouts
Destination ending 00No registered name — the unverified-destination warning
Lipa till 61115582 / 70031820ENZI COFFEE COMPANY LIMITED / NEDA LABS LIMITED
NIDA ending 0000202 kyc_pending_review — clear it with the approve endpoint
Anything elseCompletes
One deliberate difference: test mode runs every rail, including ones not yet switched on in production.GET /api/v1/testmodereports which rails are actually live, so build ahead — but check there before you promise a launch date.
Get sandbox credentials — no contract, no waiting
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"
Drive the simulation
# 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/reset
Step 3

Create users

Register a user and provision an on-chain wallet in a single call. Wallets are deterministically derived from your partner seed — no blockchain transaction required. Requires the 'wallets' capability.

Issuing end-user wallets is a granted capability. Every account gets a treasury wallet at signup, plus sub-wallets — those need no grant. Issuing wallets to your own end users is different: each one is an identity the platform carries in its KYC and regulatory reporting perimeter, so it is enabled per partner rather than by default. Without the wallets capability this endpoint answers 403. Request it from the dashboard (Capabilities → Wallets); collections, disbursements, transfers and treasury are available meanwhile. Your test key mirrors your live grant, so the sandbox answers exactly as production will.
Store the 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.
Not every integration creates users. This endpoint needs the Wallets capability, which is granted per-partner (end-user wallets are a custody-bearing product). If you only pay bills or disburse from a balance you top up — no per-user wallets — you do not create users at all: fund your partner Treasury and pass fromTreasury: true to Spend and Disbursements instead of a userId.
Every wallet is backed by a verified identity (Bank of Tanzania requirement). By default, the user is created and you receive a 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.
Idempotent: Calling with the same externalId returns the existing user. Safe to call on every login.
Gas pre-funded: New wallets are automatically topped up with a small ETH amount for gas. You do not need to fund wallets yourself.
POST /api/v1/users — request
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
  }),
})
201 — verified instantly, wallet issued
{
  "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
}
202 — identity needs verification first
{
  "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.
Step 4

Identity verification (KYC)

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.

TierWhat happensSpeed
ANIDA + phone pair verified against a bank-grade registry at create-userinstant
BThe phone's telco SIM registration (NIDA + fingerprints by law) corroborates — a contradiction outranksinstant
B′You verified the customer yourself and attest the outcome to us — the wallet is issued on that callinstant
COur compliance team reviews the collected evidence< 1 business day
One approval, not two. When create-user returns 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.
Reliance is an agreement, not a setting: attesting a KYC outcome means you performed due diligence to our standard and can produce the underlying record on request. That is why every attestation carries reference, verifiedBy and verifiedAt. Talk to us to arrange it.
International documents: pass the document's 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.
Retro-KYC: users created before the KYC standard show kycStatus: "none" — attach an identity with POST /api/v1/users/:id/kyc (NIDA + phone, same outcomes as signup), or attest one you verified yourself.
POST /api/v1/users/:id/kyc/attestation
// 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
    }),
  }
)
200 — approved, wallet issued
{
  "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 users
Step 5

Get user profile & balance

Fetch a user's on-chain nTZS balance alongside their profile. The balance is read live from Base mainnet at request time.

balanceTzs — live nTZS balance read from the nTZS contract on Base mainnet. Increases on deposit, decreases on withdrawal or nTZSUSDC swap.
balanceUsdc — live USDC balance in the same wallet. Accumulates when the user swaps nTZSUSDC. Both balances are fetched in parallel in a single API call.
Both fields are read directly from Base mainnet at request time — no caching. Always use this endpoint before initiating a transfer or withdrawal to confirm the user has sufficient funds.
GET /api/v1/users/:id
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)
// }
Step 6

Accept deposits (On-Ramp)

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.

userId must be the id returned from POST /api/v1/users — not your own externalId.
Minimum
500 TZS
Mobile providers
Vodacom (M-Pesa), Airtel (Airtel Money), Tigo (Tigo Pesa), Halotel (HaloPesa), TTCL (TTCL Pesa), Yass
Settlement
Real-time on Base mainnet after payment confirmation
Bank transfer
Any Tanzanian bank via TIPS — matched by the NTZ-XXXXXX reference + exact amount, mints in ~10 min. Requires the reference in the transfer description.
POST /api/v1/deposits — mobile money
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" }
POST /api/v1/deposits — card
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
POST /api/v1/deposits — bank transfer
// 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 MICROFINANCE…",  // 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.
POST /api/v1/deposits — Lipa Namba (user pays from their own phone)
// 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.
POST /api/v1/deposits — collect to treasury
// 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
})
Step 7

Transfers

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 vs toAddress: Provide exactly one. 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.
Platform fee: Configure your fee percentage and treasury wallet address in the dashboard. The fee is deducted from the sender and sent to your treasury in the same atomic operation.
Requirements: The sender must belong to your platform, their wallet must be provisioned, and they must have sufficient balance. For user-to-user transfers, the recipient must also belong to your platform. Gas is auto-managed — if the sender wallet is low on ETH, the relayer tops it up before sending.
POST /api/v1/transfers — user to user
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
// }
POST /api/v1/transfers — send to external address
// 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"
// }
POST /api/v1/transfers — USDC transfer
// 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.
// }
POST /api/v1/merchant/pay — pay a Biashara merchant from wallet balance
// 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
    externalReference: order.id, // optional — YOUR order id, ≤128 chars, opaque to us
  }),
})
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" },
//   externalReference: "order_8f3a…",  // echoed verbatim, null if you sent none
//   livemode: true
// }
// externalReference round-trips everywhere the sale shows up: this response,
// the merchant's GET /api/v1/biashara/collections rows, and — for the
// mobile-money tender (POST /api/merchant/pay accepts the same field) — the
// deposit.completed webhook. It is not unique: retrying a failed payment for
// the same order is a second sale with the same reference; order state is
// yours to own.
// 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.
Step 8

Cash out to mobile money (Off-Ramp)

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).

Required confirmation screen: before the user's final tap, show who they are paying (name + number/account), the fee (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”.
Minimum
5,000 TZS (recipient net)
Quote validity
5 minutes — fetch a fresh quote if the user dawdles
Bank payouts
Pass bankCode + accountNumber instead of phoneNumber — 38 banks via canonical FI codes (CRDB, NMB, NBC, …; full list in the API reference). Same fees, caps and flow; Selcom single-rail.
Large withdrawal threshold
>= 1,000,000 TZS requires admin approval and may take up to 1 business day (mobile money only)
Enforcement
quoteId is optional during the migration window and becomes mandatory on the announced enforcement date (quote_required)
Never recompute fees
The PSP fee is tiered by amount and serving rail — display the quote’s fees verbatim; hardcoded formulas will drift
1 · POST /api/v1/withdrawals/quote
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.
2 · POST /api/v1/withdrawals — execute with the quoteId
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…" }
Step 9

Spend — pay merchants & bills

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.

Scanning a QR? There is nothing extra to enable. A TANQR code is a QR containing a merchant's Lipa Namba — the same till number a customer could read off the counter and type. Decode it with 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.
Display 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.
Whose nTZS is burned — pick exactly one funding source. userId burns an end-user's wallet (needs the Wallets capability). fromTreasury: true burns your own partner treasury float — no per-user wallet — which is the path when you simply pay bills from a balance you top up (needs the Treasury capability and a funded treasury). subWalletId burns an agent float (needs Agent float). All three use the same quote → execute shape below.
Required confirmation screen: before the user's final tap, show who they are paying (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.
Minimum
500 TZS (principal)
Networks
M-Pesa, Tigo, Airtel, Selcom tills. Some tills require their network: on error till_network_required (burn auto-reverted), retry with the network field
Government bills
GEPG, DAWASA, NHC, Traffic Fine, water bills are FREE up to 20,000 TZS
Quote
Always mandatory — there is no un-quoted spend path
Settlement
Usually seconds; failures auto-revert the burn
Large spend threshold
>= 1,000,000 TZS burn total is refused (amount_too_large)
0 · POST /api/v1/lookup/qr — scan → till number
// 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.
}
1 · POST /api/v1/spend/quote
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 }
// }
2 · POST /api/v1/spend — execute with the quoteId
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
Biller catalogue — GET /api/v1/spend/billers
// 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.
Capability

Name confirmation — show who is being paid

Resolve the registered name behind a phone number, a Lipa Namba or a bill account, so the user can confirm the destination before any money moves. Both endpoints are fail-soft: a null name means no confirmation was available, never that the payment should be blocked.

Why this is separate from the quote. A quote already discloses the name, but a quote is a priced, single-use, expiring commitment — and a QR scan is not. A user points a camera at a code long before choosing an amount. Minting and discarding quotes just to render a confirmation screen is wasteful and a worse contract, so validation is its own step. The merchant lookup is deliberately not behind the spend or ramp payment flags either: it moves no money, so you can finish and test your confirmation UX while the rail is still pending approval.
Null is a normal outcome — never block on it. A number can be unregistered, or the upstream enquiry can be down, and neither means the payment would fail. Blocking on null turns a cosmetic feature into an outage. A malformedrequest is different and still returns 400: invalid_phone, unknown_biller (the response lists every supported code), or invalid_utility_ref — those are bugs in the caller.

Rate limit

Recipient 30/min, merchant 60/min per partner. Both return 429 with a Retry-After header — these resolve PII and must not be usable for bulk enumeration.

Audited

Every lookup writes an audit row. Expect enumeration patterns to be noticed.

Test mode

Both return deterministic names with no upstream call and no quota use, so you can build the whole confirmation screen against a sandbox key.

Latency

Merchant lookups go upstream to the utility and can take up to ~25s. Do not put one on a keystroke — debounce, and show a spinner.
POST /api/v1/lookup/recipient-name — who owns this mobile number
const res = await fetch('https://www.ntzs.co.tz/api/v1/lookup/recipient-name', {
  method: 'POST',
  headers: { Authorization: 'Bearer ntzs_live_xxx', 'Content-Type': 'application/json' },
  body: JSON.stringify({ phoneNumber: '255712345678' }),
})

// 200 — confirmed
{ "phone": "255712345678", "network": "tigo", "name": "JOHN DOE" }

// 200 — no confirmation available. NOT an error: show the raw number and continue.
{ "phone": "255712345678", "network": "tigo", "name": null }
POST /api/v1/lookup/merchant-name — who owns this till or bill account
// A Lipa Namba (a TANQR scan resolves to one of these)
{ "kind": "lipa", "payNumber": "012345" }

// A bill account. Biller validation is amount-aware — send amountTzs when you
// know it for an exact answer; the default is the biller's own stated floor.
{ "kind": "bill", "utilityCode": "LUKU", "utilityRef": "01234567890", "amountTzs": 10000 }

// 200 — confirmed
{ "kind": "lipa", "target": "lipa:012345", "payNumber": "012345",
  "name": "KARIAKOO HARDWARE LIMITED" }

// 200 — unconfirmed, with the reason. Show the raw number and a warning.
{ "kind": "lipa", "target": "lipa:012345", "payNumber": "012345",
  "name": null, "reason": "lookup_unavailable" }
Advanced

Swap Rate — fetch before you swap

Public endpoint — no API key required. Use this to show users a live quote before they confirm. Rates are valid for ~30 seconds.

Recommended flow: fetch rate → show expectedOutput and minOutput → call POST /api/v1/swap within the expiresAt window.
lowLiquidity — when true, the pool may not fill the full amount. Warn the user and consider reducing the swap size. liquidityUnknown — the balance behind that flag could not be read within the time budget of the quote. The price is unaffected, so treat it as not verified rather than insufficient.
Rate expiry — re-fetch if the user takes >30 s to confirm. Stale rates raise the chance of SLIPPAGE_EXCEEDED.
Rate limit — public, so throttled per source address: 60 requests/min by default, then 429 with a Retry-After header. Cache a quote for its 30-second validity rather than re-fetching per keystroke.
GET /api/v1/swap/rate — no auth required
// 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,
//   fromChain: "base", toChain: "base",
//   midRate: 2639.8663,       // pair mid-rate (Bank of Tanzania mean, synced 4×/day)
//   bidBps: 57, askBps: 38,   // the quoted LP's spread
//   spreadBps: 38,            // the side this swap pays
//   protocolFeeBps: 20,       // platform toll, charged on top of the spread
//   tzsBuyRate: 2629.8348,    // nTZS a user RECEIVES per 1 stablecoin (spread only)
//   tzsSellRate: 2654.9133,   // nTZS a user PAYS for 1 stablecoin   (spread only)
//   rate: 2624.5751,          // effective rate after spread AND fee
//   expectedOutput: 262457.51,// what the user will receive
//   minOutput: 259832.93,     // expectedOutput × (1 − 1%) — pass it to POST /swap as the floor
//   expiresAt: "2026-09-03T10:00:30.000Z",
//   lowLiquidity: false,
//   liquidityUnknown: false   // true when the solver balance could not be read in time — price unaffected
// }

// 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'
)
Step 10

Biashara — a merchant product inside your app

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.

Access: Biashara requires the biashara capability and approved KYB — it issues merchant wallets and moves merchant money. Ask us to enable it. A key without it gets 403.
Tenant isolation: your key only ever sees merchants it created. Another partner's merchant id returns 404, never 403 — we don't confirm that it exists.
Lender-controlled settlement: when a lender funds the merchant, the merchant's own settlement controls go read-only. Surface that in your UI rather than letting the PATCH fail.
No sandbox — deliberately. A 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.
1 · Activate a customer as a merchant
// 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.
2 · Every other call carries the merchant id
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 it
Step 11

Agent float — one balance, every rail

For 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.

Access: requires the wakala capability and approved KYB. And read Limits below before planning a rollout — each float is capped as one sandbox participant.
What this is not: moving float between networks is already free and instant via TIPS, and we don't compete with that. The value is the transactions an agent turns away today — bills, bank transfers, any-network tills — out of one balance. Price for new revenue, not cheaper revenue.
Sandbox limitValue
Per transaction1,000,000 TZS
Per float, per day2,000,000 TZS
Per float, 30 days60,000,000 TZS
Participants total100
Each float counts as one participant — a second sub-wallet is another participant, not extra headroom. A busy agent turns over more than 2,000,000 a day, so these limits do not yet support production volume: phase one is a named pilot cohort, and that cohort's evidence is what supports raising them. Limit errors carry limit, requested and usedInPeriod so you can render “agent limit reached” properly.
1 · Provision a float per agent
POST /api/v1/partners/sub-wallets
{ "label": "Agent 042 — Kariakoo" }
// → { id, address, label }   ← keep id against your agent record
2 · Disburse — pass subWalletId instead of userId
// 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.
Advanced

Swap — nTZS / USDC / USDT

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.

Supported pairs
nTZS ↔ USDC, nTZS ↔ USDT (Base), USDT BNB ↔ nTZS cross-chain
Settlement
Two on-chain ERC-20 transfers, ~5–10 seconds same-chain
Gas
Auto-managed — user wallet pre-funded via relayer
What the user receives: exactly expectedOutput, re-priced at the mid-rate in force when the swap runs. Slippage is never deducted — it is a floor. Pass the quote's minOutput and, if the price has moved below it, the request is refused with 409 { error: "SLIPPAGE_EXCEEDED" } before any funds move. Without minOutput, slippageBps bounds how far below the best available liquidity provider a fallback provider may be. Re-fetch the rate and let the user re-confirm before retrying.
Retries: send an Idempotency-Key. A key that has already been used answers 409 instead of swapping twice; a second swap for the same wallet while one is in flight also answers 409. Throttling answers 429 with Retry-After; a halted service or a stale rate answers 503.
Balance after swap: Call GET /api/v1/users/:id after FILLED — returns live on-chain balances for nTZS, USDC, and USDT with no caching.
POST /api/v1/swap — SSE stream
const res = await fetch('https://www.ntzs.co.tz/api/v1/swap', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ntzs_live_xxxxxxxxxxxx',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),  // recommended — one key, one swap, ever
  },
  body: JSON.stringify({
    userId:      user.id,
    fromToken:   'USDT',   // 'NTZS' | 'USDC' | 'USDT'
    toToken:     'NTZS',
    amount:      100,
    minOutput:   rate.minOutput, // recommended — the floor from your quote
    slippageBps: 100,      // optional, default 100 (1%); max 500
  }),
})

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
  }
}
POST /api/v1/swap — cross-chain (USDT BNB → nTZS Base)
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 — raw SSE
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..."}
Capability · Ramp

Ramp — wallet-less settlement

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.

Access: Ramp requires the ramp capability + approved KYB. Enable it from your dashboard.
Track: 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.
GET /api/v1/ramp/balance — your USDC settlement float
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.
POST /api/v1/ramp/quote — lock a rate (60s)
// 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 }
POST /api/v1/ramp/offramp — USDC → mobile money
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.
Off-ramp straight to a merchant or bill — USDC → Lipa / bill
// 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 }
POST /api/v1/ramp/onramp — mobile money → USDC
// 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" }
Reference

Reconciliation — supply integrity check

Compares the nTZS total supply on Base against the sum of every wallet balance the platform tracks. Use it as an independent integrity signal alongside your own ledger.

This is a platform-wide figure, not your statement. It answers “is total supply backed by the balances we know about” across every wallet on the platform — not “does my account balance.” To reconcile your own books, sum the resources you created: deposits, transfers, withdrawals and spends, each read back by id.
Reading the result. isReconciled allows under 1 TZS of rounding. A positive difference means supply exists that is not in a tracked wallet — expected if tokens are held at an address the platform does not manage (a treasury or an external transfer out). A sustained negative difference is the one that warrants a conversation with us.
Cost. This reads every tracked wallet from chain in batches, so latency grows with walletsChecked and it can take tens of seconds. Call it on a schedule — daily, or when your own reconciliation disagrees — never per request or in a user-facing path.
GET /api/v1/reconcile
const res = await fetch('https://www.ntzs.co.tz/api/v1/reconcile', {
  headers: { Authorization: 'Bearer ntzs_live_xxx' },
})

{
  "onChainSupplyTzs": 4820000,   // totalSupply() on Base
  "dbTotalBalanceTzs": 4820000,  // sum of balanceOf() over every tracked wallet
  "difference": 0,               // onChain − sum, in whole TZS
  "isReconciled": true,          // |difference| < 1 TZS
  "walletsChecked": 1284,
  "contractAddress": "0x…",
  "chain": "base"
}
Events

Webhooks

Receive real-time POST notifications to your server when payment events complete. Configure your endpoint and signing secret in the partner dashboard.

Configure your webhook URL in the partner dashboard under Settings. Your webhook secret is returned once when you create your account — store it securely (contact support to rotate it if lost). Events are signed with HMAC-SHA256 over timestamp.body — always verify the signature before processing.
webhook-handler.ts (Express)
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, collectionId, externalReference }
      // collectionId / externalReference are set when the deposit is a
      // Biashara mobile-money sale: externalReference is whatever YOU sent
      // as externalReference on the pay call (e.g. your orderId), echoed
      // verbatim — match it to the order instead of keeping a depositId map.
      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 })
})
Reference

Error reference

Every code the API can return, what it means, and whether retrying is safe. Read the shape note first — error responses come in three forms, and only one of them puts the machine-readable code in the error field.

Three response shapes — parse defensively. Most endpoints put the machine-readable code in error. The identity/KYC paths put a human sentence there and the code in code. Authentication failures return a sentence and no code at all. Reading error alone will silently mis-handle roughly a third of the catalogue, so branch on code ?? error and always keep the HTTP status.
Retry column. no — deterministic, fix the request and resend; retrying unchanged returns the same answer. backoff — transient, retry with exponential backoff. verify — the outcome is genuinely unknown; query the resource before retrying or you risk paying twice. Every money-moving endpoint accepts an idempotency key: send one, and a verify retry is safe.
Request validation
CodeStatusFieldRetryMeaning
missing_required_fields400errornoA required body field is absent
invalid_amount400errornoAmount is zero, negative, or below the minimum
amount_too_large400errornoAmount is above the per-transaction ceiling for this rail
invalid_address400errornotoAddress is not a valid Ethereum address
invalid_token400errornoUnsupported token symbol for this transfer
invalid_transfer400errornoProvide either toUserId or toAddress, not both
invalid_phone400bothnoNot a valid Tanzanian mobile number — note this code appears in both shapes
invalid_external_reference400errornoexternalReference is not a string, is over 128 characters, or contains control characters — send your order id as a plain string
phone_required400codenoA phone number is required for this operation
phone_invalid400codenoPhone failed validation on the create-user path
invalid_country400codenocountry must be an ISO 3166-1 alpha-2 code
payload_required400codenoQR lookup called without a payload
invalid_entry400codenoIP allowlist entry is not a valid address or CIDR
invalid_utility_ref400errornoUtility reference is not valid for this biller
unknown_biller400errornoNo biller matches the supplied code
till_network_required502errorverifyTill accepted but its mobile network is unknown — the burn was reverted and the balance restored. Retry with the network field
Authentication and access
CodeStatusFieldRetryMeaning
(no code)401prosenoMissing, empty, or invalid API key — match on the status, there is no machine code
ip_not_allowed403codenoRequest source IP is not on your allowlist
capability_required403errornoYour account does not have the capability this endpoint needs — request it in the dashboard
kyb_required403codenoThe capability is granted but your KYB is not yet approved
capability_not_held403codenoYou tried to delegate a capability your own account does not hold — an agent credential can never out-scope its issuer
agent_credential_revoked403codenoThis agent credential was revoked. Issue a new one; revocation is not reversible
agent_credential_expired403codenoThis agent credential passed its expiry. Issue a new one
agent_credential_partner_inactive403codenoThe partner account behind this agent credential is deactivated
agent_per_txn_cap_exceeded403codenoAbove this agent credential's per-transaction ceiling. Split the movement or use a credential with a higher limit
agent_daily_cap_exceeded403codenoAbove what is left of this agent credential's rolling 24h ceiling. The window is rolling, so allowance returns gradually rather than at midnight
merchant_unavailable403errornoThis merchant cannot accept payments right now
wallet_frozen403errornoThe wallet is frozen and cannot pay
endpoint_retired410codenoThis endpoint has been withdrawn — see the migration note in its response
not_available_in_test_mode501errornoThis product is not simulated in the sandbox; use a live key
test_mode_only400errornoThe inverse — a test-mode-only endpoint called with a live key
already_test400errornoThis account is already a sandbox account
Identity and KYC
CodeStatusFieldRetryMeaning
kyc_required400codenonidaNumber (and phone) missing on a TZ create-user — non-TZ signups send country instead
kyc_pending_review202codenoNot an error — verification is open. Offer a capture session and wait for the webhook
kyc_attestation_required202codenoThe platform does not verify for you — report the outcome via the attestation endpoint
kyc_failed400codenoVerification returned a negative result
kyc_already_decided409codenoThis case already has a terminal outcome and cannot be re-attested
kyc_reliance_not_granted403codenoYour account may not attest KYC outcomes — this is granted per partner
identity_already_registered409codenoThis identity already backs another wallet on your platform
nida_already_registered409codenoThis NIDA already backs another wallet on your platform
identity_mismatch409codenoThe attested identity does not match the one on the open case
kyc_unavailable503codebackoffVerification is temporarily unavailable — retry shortly
Quotes
CodeStatusFieldRetryMeaning
quote_required400errornoThis endpoint will not price itself: call the quote endpoint, show the user the name and fees, then send the quote
quote_mismatch400errornoQuote was issued for different terms (user, destination, or amount)
invalid_quote400errornoQuote is malformed or already expired — request a new one
quote_stale409errornoPricing moved since the quote was issued — request a new one. Never recompute fees yourself
Balance and wallet state
CodeStatusFieldRetryMeaning
insufficient_balance400 / 402errornoNot enough nTZS — details carries available, requested and shortfall
wallet_not_provisioned400errorbackoffWallet address is still being derived — retry shortly
treasury_not_provisioned400errornoProvision your partner treasury before funding disbursements from a sub-wallet
funding_source_required400errornoSay which source funds this disbursement
not_provisioned503codenoThe feature is not set up on this account yet
user_not_found404bothnouserId not found under your partner account — appears in both shapes
token_paused503errorbackoffnTZS transfers are paused by the issuer — withdrawals and spends are refused before anything is debited; retry later
Conflicts and duplicates
CodeStatusFieldRetryMeaning
duplicate_withdrawal409errornoA withdrawal with this idempotency key already exists — read it back rather than resending
Self-custody makers (market-maker API)
CodeStatusFieldRetryMeaning
pooled_capital_active409codenoSelf-custody can only be enabled after deactivating: your capital is still in the shared pool
NOT_SELF_CUSTODY409codenoSubmit orders only after enabling self-custody (PUT /api/v1/mm/custody)
BAD_ORDER400codenoThe RfqOrder struct is malformed — `error` names the field
BAD_SIGNATURE400codenoThe signature is malformed, does not recover to your registered maker wallet, or the 0x contract rejects it
UNSUPPORTED_CHAIN400codenoRFQ orders are accepted on Base only
UNKNOWN_TOKEN400codenomakerToken and takerToken must be registry tokens on Base
SAME_TOKEN400codenomakerToken and takerToken must differ
NOT_NTZS_PAIR400codenoOne side of every order must be nTZS
WRONG_MAKER400codenoorder.maker must be your registered maker wallet
WRONG_TAKER400codenoorder.taker must be the SimpleFX solver wallet — GET /api/v1/mm/rfq/info
WRONG_TX_ORIGIN400codenoorder.txOrigin must be the SimpleFX solver wallet
EXPIRES_TOO_SOON400codenoexpiry must be at least 60 seconds ahead
EXPIRES_TOO_LATE400codenoexpiry must be within 24 hours
ORDER_NOT_FILLABLE409codenoThe 0x contract reports the order as filled, cancelled, expired or invalid — `orderStatus` says which
CHAIN_UNAVAILABLE503codebackoffThe order could not be read on-chain from any endpoint — retry shortly
Compliance screening
CodeStatusFieldRetryMeaning
compliance_screening_blocked403errornoThe destination address cannot be paid from the platform
compliance_screening_unavailable503errorbackoffScreening is down, so nothing was sent. Nothing moved — retry shortly
Rails and availability
CodeStatusFieldRetryMeaning
rate_limited429errorbackoffToo many requests — back off exponentially and respect any Retry-After
relayer_unavailable503errorbackoffGas relay is temporarily offline — retry shortly
network_error500errorbackoffRPC connection timed out
database_error500errorbackoffThe transfer record could not be written — nothing moved
configuration_error500errornoServer-side configuration is missing — contact support, retrying will not help
initiation_failed502codeverifyThe provider rejected the collection outright
initiation_uncertain502codeverifyWe could not confirm whether the prompt was delivered and the collection may still be taken — query the deposit before retrying
bank_rail_unavailable503errorbackoffBank payouts are not enabled on this environment yet
bank_amount_unsupported400errornoThis amount cannot be sent over the bank rail
spend_disabled503errorbackoffSpend rails are not enabled on this environment yet
spend_kind_disabled503errornoThis particular spend destination is not enabled
ramp_unavailable502 / 503errorbackoffThe ramp service errored pricing this quote — nothing was charged
ramp_not_provisioned503errornoRamp is not set up on this account
ramp_spend_disabled503errornoLipa/bill off-ramp destinations are pending regulatory approval
wakala_float_disabled503errornoSub-wallet funded disbursements are not enabled on this environment
signup_disabled503errornoSelf-serve sandbox signup is closed on this deployment
migration_pending503errorbackoffThe deployment is ahead of its database migration — retry shortly
Error response shape
// 1. Most endpoints — machine code in `error`
{ "error": "insufficient_balance",
  "message": "Sender has insufficient nTZS balance",
  "details": { "available": 3200, "requested": 5000, "shortfall": 1800 } }

// 2. Identity / KYC paths — sentence in `error`, machine code in `code`
{ "error": "A NIDA number is required to create a wallet — identity verification is a prerequisite.",
  "code": "kyc_required" }

// 3. Authentication — sentence only, no machine code (match on HTTP 401)
{ "error": "Missing or invalid Authorization header. Expected: Bearer <api_key>" }

// Handle all three:
const body   = await res.json()
const code   = body.code ?? body.error          // machine code, whichever shape
const detail = body.message ?? body.error       // human text, whichever shape
if (res.status === 401) { /* credentials — never retry */ }