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.

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.

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.
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 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.
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
  }),
})
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.
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.
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.
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.
Rate expiry — re-fetch if the user takes >30 s to confirm. Stale rates raise the chance of SLIPPAGE_EXCEEDED.
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,
//   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'
)
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
Slippage errors: If the price moves beyond slippageBps between rate fetch and execution, you receive FAILED / SLIPPAGE_EXCEEDED. Re-fetch the rate and let the user re-confirm before retrying.
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',
  },
  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
  }
}
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" }
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 }
      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

All errors return a consistent JSON body. Match on the error field for programmatic handling.

Error codeStatusMeaning
missing_required_fields400A required body field is absent
invalid_amount400Amount is zero, negative, or below minimum
invalid_transfer400fromUserId equals toUserId
wallet_not_provisioned400Wallet address is still being derived
insufficient_balance400Sender does not have enough nTZS
user_not_found404userId not found under your partner account
unauthorized401Missing or invalid API key
kyc_required400nidaNumber (and phone) missing on a TZ create-user — non-TZ signups send country instead
kyc_pending_review202Not an error — verification open; offer a capture session
identity_binding_failed400Phone registered to a different person (retro-KYC re-attempts); signups now soft-land to document capture instead
nida_already_registered409This NIDA already backs another wallet on your platform
invalid_country400country must be an ISO 3166-1 alpha-2 code
kyc_unavailable503Verification temporarily unavailable — retry shortly
relayer_unavailable503Gas relay temporarily offline — retry shortly
blockchain_error500On-chain transaction failed — see details.technicalError
network_error500RPC connection timed out — retry
Error response shape
// HTTP 4xx/5xx response body:
{
  "error": "insufficient_balance",   // machine-readable code
  "message": "Sender has insufficient nTZS balance",
  "details": {
    "available": 3200,
    "requested": 5000,
    "shortfall": 1800
  }
}