# Disbursement Channel API

**Author**: Josh — Lead Backend Team
**Last Updated**: 2026-03-06
**Version**: v1.0

**Base URL**: `https://api.nextgate.co.tz/api/v1`

**Description**: Manages withdrawal destinations for NextGate users. A channel is a verified mobile money or bank account to which a user can withdraw their wallet balance. Channels require OTP verification to add and have a cooling period before first use.

**Key notes**:
- Adding a channel is a **three-step flow**: `/lookup` → `/add` → `/add/confirm`
- The `/lookup` step returns a signed `confirmationToken` required by `/add` — expires in 10 minutes
- Selcom name lookup is performed at both `/lookup` and `/add` — verified twice server-side
- The **first channel** on an account activates immediately — 24-hour cooling applies to all subsequent channels
- Deleting a channel requires OTP confirmation — two-step: `DELETE /{channelId}` → `DELETE /{channelId}/confirm`
- Deleted channels cannot be used for new withdrawals — in-flight disbursements snapshot channel data at initiation time and are unaffected

---

## Security & Client Guidelines

### Authentication

All endpoints require a valid JWT Bearer token:

```
Authorization: Bearer <your_token>
```

On `401 UNAUTHORIZED`, silently refresh the token and retry once. If refresh fails, redirect to login.

Never store the JWT in localStorage — use secure device storage on mobile, or HttpOnly cookies on web.

---

### OTP Token Handling

Both the add and delete flows are two-step. The first step returns an `otpToken`. Store this in memory and pass it to the confirm step along with the SMS OTP code. Do not log it or persist it beyond the confirmation flow.

The OTP expires after ~5 minutes. If the user is too slow, the confirm step returns an error — prompt them to **restart the entire flow from the beginning**. The server must clean up the previous pending channel record before a new one can be created.

If the wrong OTP is entered too many times, the OTP is locked and the flow is terminated. The user must start over.

---

### Name Lookup and Confirmation Token

The `/lookup` step returns a signed `confirmationToken` that encodes the destination, channel type, and account ID. It is valid for **10 minutes**.

The client **must** pass this token to `/add`. The server rejects the request if the token is missing, expired, or does not match the destination and channel type in the request body.

Always display `accountHolderName` prominently to the user and require explicit confirmation before calling `/add`. This protects users from accidentally adding a wrong destination.

---

### Cooling Period UX

After OTP confirmation, `isUsable` is `false` until `activatesAt`. Show a message like:

> *"This channel will be ready for withdrawals on {activatesAt date}"*

Visually distinguish usable channels from those still in the cooling period when listing.

**Exception**: The first channel added to an account activates immediately (`isUsable: true`, `activatesAt` set to now).

---

### Deleting a Channel

Deletion is irreversible. Show a clear confirmation dialog before initiating the delete flow.

If the deleted channel was the primary channel, the server automatically promotes another active channel as primary.

Pending disbursements that reference a deleted channel are not affected — the disbursement holds a snapshot of the channel data from initiation time.

---

### Network Error Handling

| HTTP Status | Meaning | Client action |
|-------------|---------|---------------|
| `400` | Business rule violation | Show `message` to user, do not retry |
| `401` | Token expired or invalid | Refresh token silently, retry once |
| `403` | Forbidden | Show error, do not retry |
| `422` | Validation error | Show field errors, fix and resubmit |
| `500` | Server error | Show generic error, allow manual retry |
| Network timeout | No response | Lookup and list: safe to retry. Initiate/confirm: check status first |

---

## Standard Response Format

### Success
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Operation completed successfully",
  "action_time": "2026-03-06T10:30:45",
  "data": {}
}
```

### Error
```json
{
  "success": false,
  "httpStatus": "BAD_REQUEST",
  "message": "Error description",
  "action_time": "2026-03-06T10:30:45",
  "data": "Error description"
}
```

---

## Flow Diagram

```
ADD CHANNEL FLOW
----------------
User
 |
 |--- POST /disbursement/channels/lookup -------> Selcom Name Lookup
 |       { channelType, destination, bankCode? }          |
 |<-- { accountHolderName, destinationDisplay,    Returns verified name
 |       channelType, confirmationToken }
 |                                            <-- store confirmationToken (expires 10 min)
 |   [User reviews name, confirms]
 |
 |--- POST /disbursement/channels/add ----------> Validate confirmationToken
 |       { channelType, destination,               Re-verify name with Selcom
 |         bankCode?, confirmationToken }           Check for duplicate
 |                                                  Save channel record
 |                                                  Generate + send OTP
 |<-- { otpToken }
 |
 |--- POST /disbursement/channels/add/confirm --> Verify OTP
 |       ?otpToken=...&otpCode=...                 Activate channel
 |<-- { channelId, isUsable, activatesAt, ... }    (cooling 24h, except first channel)
 |
 |   [Channel becomes usable after activatesAt]
 |
 |--- GET /disbursement/channels ---------------> List active channels
 |<-- [ { channelId, isUsable, activatesAt, ... } ]


DELETE CHANNEL FLOW
-------------------
User
 |
 |--- DELETE /disbursement/channels/{channelId} ---------> Generate + send OTP
 |<-- { otpToken }
 |
 |--- DELETE /disbursement/channels/{channelId}/confirm --> Verify OTP
 |       ?otpToken=...&otpCode=...                          Soft delete channel
 |<-- 200 OK                                                Reassign primary if needed
```

---

## Channel Status Reference

| Status | Description |
|--------|-------------|
| `PENDING_ACTIVATION` | Channel OTP confirmed, within 24-hour cooling period — not yet usable |
| `ACTIVE` | Channel verified and activated |
| `DELETED` | Channel removed by user |

> A cleanup job runs nightly and soft-deletes any channel records whose OTP was never verified (i.e. the add flow was abandoned before confirm).

---

## Endpoints

---

## 1. Lookup Account Name

**Purpose**: Verifies a destination account and returns the registered account holder name from Selcom, along with a signed `confirmationToken` required for the next step. Always call this before `/add`.

**Endpoint**: `POST /disbursement/channels/lookup`

**Access**: 🔒 Protected — Bearer Token required

**Request Body**:
```json
{
  "channelType": "MPESA",
  "destination": "255712345678",
  "bankCode": null
}
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `channelType` | string | Yes | `MPESA`, `AIRTEL`, `TIGOPESA`, `HALOPESA`, `SELCOM_PESA`, `BANK` |
| `destination` | string | Yes | Phone number (`255XXXXXXXXX`) or bank account number |
| `bankCode` | string | Conditional | Required when `channelType` is `BANK` |

**Success Response**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Account verified successfully",
  "action_time": "2026-03-06T10:30:45",
  "data": {
    "accountHolderName": "JOHN DOE",
    "destinationDisplay": "2557****678",
    "channelType": "MPESA",
    "confirmationToken": "<signed-token>"
  }
}
```

| Field | Description |
|-------|-------------|
| `accountHolderName` | Verified name from Selcom — display to user for confirmation |
| `destinationDisplay` | Masked destination |
| `channelType` | The channel type |
| `confirmationToken` | Signed token required by `/add`. Valid for 10 minutes. Store in memory |

**Errors**:

| Scenario | Message |
|----------|---------|
| Account not found | `"Account not found. Please check the number and try again."` |
| Phone not verified | `"Your phone number must be verified before adding a withdrawal channel."` |
| Destination already added | `"This destination is already added as a withdrawal channel."` |
| Bank code missing | `"Bank code is required for bank channels."` |
| Selcom lookup failed | `"Could not verify account. Please check the details and try again."` |

---

## 2. Initiate Add Channel

**Purpose**: Initiates adding a new channel. Validates the `confirmationToken` from `/lookup`, re-verifies the name with Selcom server-side, saves the channel record, and sends an OTP to the user's verified phone.

**Endpoint**: `POST /disbursement/channels/add`

**Access**: 🔒 Protected — Bearer Token required

**Request Body (Mobile Money)**:
```json
{
  "channelType": "MPESA",
  "destination": "255712345678",
  "bankCode": null,
  "confirmationToken": "<token from /lookup>"
}
```

**Request Body (Bank)**:
```json
{
  "channelType": "BANK",
  "destination": "0012345678901",
  "bankCode": "CRDB",
  "confirmationToken": "<token from /lookup>"
}
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `channelType` | string | Yes | `MPESA`, `AIRTEL`, `TIGOPESA`, `HALOPESA`, `SELCOM_PESA`, `BANK` |
| `destination` | string | Yes | Phone or account number |
| `bankCode` | string | Conditional | Required when `channelType` is `BANK` |
| `confirmationToken` | string | Yes | Token from `/lookup`. Must match destination, channel type, and account |

**Success Response**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "OTP sent to your verified phone number",
  "action_time": "2026-03-06T10:30:45",
  "data": {
    "otpToken": "otp-token-uuid-here"
  }
}
```

| Field | Description |
|-------|-------------|
| `otpToken` | Pass to `/add/confirm` with the SMS OTP code |

**Errors**:

| Scenario | Message |
|----------|---------|
| Token expired | `"Confirmation token expired. Please look up the account again."` |
| Token invalid/mismatch | `"Invalid confirmation token."` |
| Channel already active | `"This destination is already an active withdrawal channel."` |
| Max channels reached | `"Maximum of {n} withdrawal channels allowed."` |
| Add window closed | `"Withdrawal channel add window has closed."` |

---

## 3. Confirm Add Channel

**Purpose**: Confirms adding a new channel by verifying the OTP. On success the channel is activated with a cooling period before first use (first channel activates immediately).

**Endpoint**: `POST /disbursement/channels/add/confirm`

**Access**: 🔒 Protected — Bearer Token required

**Query Parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `otpToken` | string | Yes | Token returned from `/add` |
| `otpCode` | string | Yes | 6-digit OTP received via SMS |

**Success Response**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Channel added successfully",
  "action_time": "2026-03-06T10:30:45",
  "data": {
    "channelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "channelType": "MPESA",
    "destinationDisplay": "2557****678",
    "accountHolderName": "JOHN DOE",
    "bankName": null,
    "isPrimary": false,
    "status": "ACTIVE",
    "isUsable": false,
    "activatesAt": "2026-03-07T10:31:00"
  }
}
```

| Field | Description |
|-------|-------------|
| `channelId` | UUID — use this when initiating withdrawals |
| `channelType` | Channel type |
| `destinationDisplay` | Masked destination |
| `accountHolderName` | Verified name from Selcom |
| `bankName` | Bank name — only for `BANK` channels, null otherwise |
| `isPrimary` | True if this is now the user's primary channel |
| `status` | `ACTIVE` or `PENDING_ACTIVATION` |
| `isUsable` | `false` if still in cooling period |
| `activatesAt` | When the channel becomes usable for withdrawals |

**Errors**:

| Scenario | Message |
|----------|---------|
| Invalid OTP | `"Invalid OTP code."` |
| OTP locked | `"OTP locked — max attempts exceeded."` |
| Channel no longer valid | `"This channel is no longer valid. Please add it again."` |

---

## 4. List My Channels

**Purpose**: Returns all active withdrawal channels for the authenticated user.

**Endpoint**: `GET /disbursement/channels`

**Access**: 🔒 Protected — Bearer Token required

> Only `ACTIVE` channels are returned. Deleted and pending-activation channels that have not yet been OTP-confirmed are excluded.

**Success Response**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Channels retrieved successfully",
  "action_time": "2026-03-06T10:30:45",
  "data": [
    {
      "channelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "channelType": "MPESA",
      "destinationDisplay": "2557****678",
      "accountHolderName": "JOHN DOE",
      "bankName": null,
      "isPrimary": true,
      "status": "ACTIVE",
      "isUsable": true,
      "activatesAt": "2026-03-05T10:31:00"
    },
    {
      "channelId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
      "channelType": "BANK",
      "destinationDisplay": "0012****789",
      "accountHolderName": "JOHN DOE",
      "bankName": "CRDB Bank",
      "isPrimary": false,
      "status": "ACTIVE",
      "isUsable": false,
      "activatesAt": "2026-03-07T08:00:00"
    }
  ]
}
```

| Field | Description |
|-------|-------------|
| `channelId` | UUID of the channel |
| `channelType` | Channel type |
| `destinationDisplay` | Masked destination |
| `accountHolderName` | Verified name from Selcom |
| `bankName` | Bank name — only for `BANK` channels, null otherwise |
| `isPrimary` | Whether this is the user's primary channel |
| `status` | `ACTIVE` |
| `isUsable` | `true` if cooling period has passed and channel can be used now |
| `activatesAt` | When the cooling period ends |

---

## 5. Initiate Delete Channel

**Purpose**: Initiates deletion of a withdrawal channel. Sends OTP to user's verified phone for confirmation.

**Endpoint**: `DELETE /disbursement/channels/{channelId}`

**Access**: 🔒 Protected — Bearer Token required

**Path Parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `channelId` | UUID | Yes | ID of the channel to delete |

**Success Response**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "OTP sent to your verified phone number",
  "action_time": "2026-03-06T10:30:45",
  "data": {
    "otpToken": "otp-token-uuid-here"
  }
}
```

**Errors**:

| Scenario | Message |
|----------|---------|
| Channel not found / not owned | `"Channel not found."` |
| Channel not active | `"Only active channels can be deleted."` |
| Phone not verified | `"Your phone number must be verified to delete a withdrawal channel."` |

---

## 6. Confirm Delete Channel

**Purpose**: Confirms channel deletion by verifying the OTP. Channel is permanently removed. If it was the primary channel, another active channel is automatically promoted.

**Endpoint**: `DELETE /disbursement/channels/{channelId}/confirm`

**Access**: 🔒 Protected — Bearer Token required

**Path Parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `channelId` | UUID | Yes | ID of the channel to delete |

**Query Parameters**:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `otpToken` | string | Yes | Token returned from initiate delete |
| `otpCode` | string | Yes | 6-digit OTP received via SMS |

**Success Response**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Channel deleted successfully",
  "action_time": "2026-03-06T10:30:45",
  "data": null
}
```

**Errors**:

| Scenario | Message |
|----------|---------|
| Invalid OTP | `"Invalid OTP code."` |
| OTP does not match channel | `"OTP does not match this channel."` |
| Channel not active | `"Only active channels can be deleted."` |

---

## Quick Reference

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/disbursement/channels/lookup` | Verify account name, get `confirmationToken` |
| `POST` | `/disbursement/channels/add` | Start add channel flow (requires `confirmationToken`) |
| `POST` | `/disbursement/channels/add/confirm` | Complete add channel with OTP |
| `GET` | `/disbursement/channels` | List all active user channels |
| `DELETE` | `/disbursement/channels/{channelId}` | Start delete channel flow |
| `DELETE` | `/disbursement/channels/{channelId}/confirm` | Complete delete with OTP |