Disbursement API Author : Josh Lead Backend Team Last Updated : 2026-03-06 Version : v1.0 Base URL : https://api.nextgate.co.tz/api/v1 Short Description : The Disbursement API handles wallet withdrawals for NextGate users. Users can withdraw their wallet balance to a verified mobile money account or bank account. Each withdrawal requires OTP confirmation and deducts a platform fee plus Selcom transfer fee on top of the requested amount. Hints : The withdrawal channel must be added and fully active (cooling period passed) before use — see Disbursement Channel API Total wallet debit = requestedAmount + platformFee + selcomFee The recipient receives exactly requestedAmount — fees are charged on top If Selcom returns INPROGRESS , the polling job resolves the final status automatically every 3 minutes — no frontend action needed Always provide a unique idempotencyKey per withdrawal request Poll /status/{id} after confirming to track final delivery Security & Client Guidelines Authentication All endpoints in this API are protected. The client must include a valid JWT Bearer token in every request header: Authorization: Bearer The token is obtained from the NextGate authentication API after login. When a request returns 401 UNAUTHORIZED , the client must silently refresh the token and retry the original request once. If the refresh also fails, redirect the user to the login screen. Never store the JWT token in a place accessible to third-party scripts. On mobile, use secure device storage. On web, prefer memory or HttpOnly cookies over localStorage. Idempotency Key The idempotencyKey field protects against duplicate withdrawals caused by network retries. This is critical for financial operations — without it, a network timeout could cause the user's wallet to be debited twice if the app retries blindly. How to generate and use it correctly: Generate the key exactly once at the moment the user initiates the withdrawal action — for example, when they tap the "Withdraw" button. Store this key in memory for the duration of that withdrawal flow. If the /initiate request fails due to a network error and your app retries, send the exact same key. The server will detect the duplicate and return the existing request rather than creating a new one. Never generate a new key on each retry attempt. Never reuse a key from a previous completed or failed withdrawal. A new key must be generated fresh for each new withdrawal the user starts. The key only protects /initiate . The /confirm step is protected by the otpToken and the pessimistic database lock on the disbursement record — a duplicate confirm call on the same request will be rejected because the status will no longer be PENDING_OTP . OTP Token Handling After calling /initiate , you receive an otpToken . This token must be held in memory and passed to /confirm along with the OTP code the user receives via SMS. Do not ask the user to copy or type the token — it is handled entirely by the app. The OTP code expires after a short window (typically 5 minutes). If the user does not confirm in time, the confirm step will return an error and the withdrawal request will remain in PENDING_OTP status — no money has moved at this point. The user can restart the flow with a new idempotency key. If the user enters the wrong OTP code too many times, the OTP is locked. The disbursement will be marked FAILED and the flow is terminated. No money has moved because the wallet is only debited after OTP verification. The user must start a new withdrawal request with a fresh idempotency key. What Confirmation Actually Means A 200 OK response from /confirm means the OTP was verified, the wallet was debited, and the Selcom API was called. It does not mean the money has arrived at the recipient. Selcom processes transfers asynchronously and may take seconds to minutes to confirm delivery. Always redirect the user to a status screen after confirm and poll /status/{id} to show the final outcome. Do not show a "withdrawal successful" message based solely on the confirm response. Status Polling After Confirm After a successful /confirm , begin polling /status/{id} to track delivery. Recommended polling behavior: Poll every 5 seconds for the first minute. If still not in a terminal status, slow down to every 15 seconds for the next 5 minutes. After 6 minutes total with no terminal status, show the user a message explaining that the transfer is being processed and may take a little longer — this is the AWAITING_CONFIRMATION state where the server polling job is working in the background every 3 minutes. Stop polling when status reaches any of these terminal states: COMPLETED , REFUNDED , FAILED , MANUAL_REVIEW . Handling Each Terminal Status Status What to show the user COMPLETED Success — show amount sent, recipient name, transaction reference REFUNDED Transfer failed — show failure reason, confirm wallet has been refunded, suggest they try again FAILED OTP was locked — no money moved, suggest they start a new withdrawal MANUAL_REVIEW Show a message that the transfer is under review and they will be notified. Do not show this as a failure or a success. Provide the supportRef for the user to reference if they contact support Fee Transparency Before the user confirms the withdrawal, always display the full fee breakdown clearly so they know exactly how much will leave their wallet. Never show only the requestedAmount without also showing the fees. A clear display should show: amount to recipient, platform fee, transfer fee, and total to be deducted from wallet. This prevents confusion when the user checks their wallet balance after the withdrawal and sees more deducted than they expected. Network Error Handling HTTP Status What it means Client action 400 Invalid request or business rule violation Show message to user, do not retry automatically 401 Token expired or invalid Refresh token silently, retry once 403 Forbidden — permission issue Show error, do not retry 422 Validation error Show field errors to user, fix and resubmit 500 Server error Show generic error, allow user to retry manually Network timeout on /initiate No response received Retry with the same idempotency key — server handles duplicates safely Network timeout on /confirm No response received Do not retry automatically — check /status/{id} first to see if the confirm was already processed before retrying Network timeout on /status No response received Safe to retry on next poll interval Standard Response Format Success Response { "success": true, "httpStatus": "OK", "message": "Operation completed successfully", "action_time": "2026-03-06T10:30:45", "data": {} } Error Response { "success": false, "httpStatus": "BAD_REQUEST", "message": "Error description", "action_time": "2026-03-06T10:30:45", "data": "Error description" } Money Flow Diagram User requests withdrawal of 10,000 TZS | POST /disbursement/initiate | .-------+----------. | | Validate Calculate fees channel platformFee = 500 TZS balance selcomFee = 1,500 TZS totalDebited = 12,000 TZS disbursedAmt = 10,000 TZS | Save DisbursementRequest Status: PENDING_OTP | Send OTP to verified phone | Return { otpToken } | POST /disbursement/confirm (otpToken + otpCode) | Verify OTP | Wallet debited 12,000 TZS Status: PROCESSING | Call Selcom API (sends 10,000 TZS to recipient) | .--------+-------------------. | | | SUCCESS INPROGRESS FAIL | | | COMPLETED | REFUNDED AWAITING wallet credited CONFIRMATION 12,000 back | Polling job (every 3 min) | .---------+---------. | | SUCCESS FAIL | | COMPLETED REFUNDED wallet credited 12,000 back Fee Structure Component Amount Description requestedAmount User defined What the recipient receives platformFee 500 TZS NextGate service charge selcomFee 1,500 TZS Selcom per-disbursement fee totalDebited requestedAmount + 2,000 TZS Actual amount leaving wallet disbursedAmount = requestedAmount Amount Selcom sends to recipient Disbursement Status Reference Status Description PENDING_OTP Initiated, waiting for OTP confirmation — wallet not debited yet PROCESSING OTP confirmed, wallet debited, Selcom call in progress COMPLETED Selcom confirmed successful transfer AWAITING_CONFIRMATION Selcom returned INPROGRESS — polling job resolving every 3 min REFUNDED Selcom failed — wallet refunded full totalDebited automatically FAILED Failed due to OTP lock — no wallet debit occurred MANUAL_REVIEW Polling exhausted max retries — admin review required Endpoints 1. Initiate Withdrawal Purpose : Initiates a wallet withdrawal request. Validates channel, calculates fees, checks wallet balance, and sends OTP to user's verified phone number. No money moves at this step. Endpoint : POST /disbursement/initiate Access Level : 🔒 Protected — Requires Bearer Token Authentication : Authorization: Bearer Request Headers : Header Type Required Description Authorization string Yes Bearer JWT token Content-Type string Yes application/json Request JSON Sample : { "channelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "amount": 10000, "idempotencyKey": "usr-123-withdraw-1741234567" } Request Body Parameters : Parameter Type Required Description Validation channelId UUID Yes ID of the verified withdrawal channel Must belong to authenticated user and be active (cooling period passed) amount number Yes Amount to send to recipient in TZS Min: 1000 TZS idempotencyKey string Yes Unique key to prevent duplicate withdrawals Max 200 chars, unique per request Success Response JSON Sample : { "success": true, "httpStatus": "OK", "message": "OTP sent to your verified phone number", "action_time": "2026-03-06T10:30:45", "data": { "otpToken": "dfe7e16f-cd8f-4c00-b0d0-8cff09201936" } } Success Response Fields : Field Description data.otpToken OTP token string — pass this along with OTP code to /confirm Error Responses : Insufficient balance (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "Insufficient balance. You need 12000 TZS (10000 + 500 platform fee + 1500 transfer fee).", "action_time": "2026-03-06T10:30:45", "data": "Insufficient balance. You need 12000 TZS (10000 + 500 platform fee + 1500 transfer fee)." } Channel still in cooling period (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "This withdrawal channel is not yet active.", "action_time": "2026-03-06T10:30:45", "data": "This withdrawal channel is not yet active." } Phone not verified (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "Your phone number must be verified before withdrawing.", "action_time": "2026-03-06T10:30:45", "data": "Your phone number must be verified before withdrawing." } Duplicate request (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "Duplicate request — this withdrawal is already being processed.", "action_time": "2026-03-06T10:30:45", "data": "Duplicate request — this withdrawal is already being processed." } Amount below minimum (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "Minimum withdrawal amount is 1000 TZS.", "action_time": "2026-03-06T10:30:45", "data": "Minimum withdrawal amount is 1000 TZS." } 2. Confirm Withdrawal Purpose : Confirms the withdrawal by verifying the OTP. Debits the wallet and calls Selcom to initiate the transfer. This is when money actually leaves the wallet. Endpoint : POST /disbursement/confirm Access Level : 🔒 Protected — Requires Bearer Token Authentication : Authorization: Bearer Query Parameters : Parameter Type Required Description otpToken string Yes Token returned from /initiate otpCode string Yes 6-digit OTP received via SMS Success Response JSON Sample : { "success": true, "httpStatus": "OK", "message": "Withdrawal processed successfully", "action_time": "2026-03-06T10:30:45", "data": null } Important : A 200 OK here means the Selcom call was made. It does not guarantee the money has arrived at the recipient yet. Poll /status/{id} to confirm final delivery status. Error Responses : Invalid OTP (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "Invalid OTP code.", "action_time": "2026-03-06T10:30:45", "data": "Invalid OTP code." } OTP locked — max attempts exceeded (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "OTP locked — max attempts exceeded.", "action_time": "2026-03-06T10:30:45", "data": "OTP locked — max attempts exceeded." } Already processing (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "This withdrawal is already being processed.", "action_time": "2026-03-06T10:30:45", "data": "This withdrawal is already being processed." } 3. Get Disbursement Status Purpose : Returns the current status and full details of a withdrawal request. Poll this after confirm to track final delivery. Endpoint : GET /disbursement/status/{disbursementRequestId} Access Level : 🔒 Protected — Requires Bearer Token Authentication : Authorization: Bearer Path Parameters : Parameter Type Required Description disbursementRequestId UUID Yes ID of the disbursement request Success Response JSON Sample : { "success": true, "httpStatus": "OK", "message": "Disbursement status retrieved", "action_time": "2026-03-06T10:30:45", "data": { "disbursementRequestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "requestedAmount": 10000, "platformFee": 500, "selcomFee": 1500, "totalDebited": 12000, "disbursedAmount": 10000, "currency": "TZS", "destination": "255712****78", "accountHolderName": "JOHN DOE", "status": "COMPLETED", "failureReason": null, "transactionRef": "NXT-TXN-20260306-ABCD1234", "supportRef": null, "createdAt": "2026-03-06T10:30:00", "completedAt": "2026-03-06T10:31:45" } } Success Response Fields : Field Description disbursementRequestId UUID of this withdrawal request requestedAmount Amount sent to recipient in TZS platformFee NextGate fee charged on top selcomFee Selcom transfer fee charged on top totalDebited Total amount debited from wallet disbursedAmount Amount Selcom sent to recipient — equals requestedAmount currency Always TZS destination Masked destination e.g. 255712****78 accountHolderName Verified recipient name status Current status — see status table above failureReason Populated if FAILED or REFUNDED transactionRef Wallet transaction reference supportRef Support reference for escalations — populated on MANUAL_REVIEW createdAt When withdrawal was initiated completedAt When transfer was confirmed — null if not yet completed Error Responses : Not found or not owned by user (400): { "success": false, "httpStatus": "BAD_REQUEST", "message": "Disbursement request not found", "action_time": "2026-03-06T10:30:45", "data": "Disbursement request not found" } Quick Reference Method Endpoint Description POST /disbursement/initiate Start withdrawal, sends OTP POST /disbursement/confirm Confirm with OTP, debits wallet, calls Selcom GET /disbursement/status/{id} Check withdrawal status