| # WiFi Platform β Backend API Documentation |
|
|
| > For frontend engineers building the Tenant Dashboard and Super Admin Panel. |
| > Base URL: `http://{SERVER_HOST}:3000` |
| > All API responses are JSON. All request bodies must be `Content-Type: application/json`. |
| |
| --- |
| |
| ## Table of Contents |
| |
| 1. [Authentication](#1-authentication) |
| 2. [Tenant β Devices](#2-tenant--devices) |
| 3. [Tenant β WiFi Plans](#3-tenant--wifi-plans) |
| 4. [Tenant β Dashboard](#4-tenant--dashboard) |
| 5. [Tenant β Sessions](#5-tenant--sessions) |
| 6. [Tenant β Manual Sales](#6-tenant--manual-sales) |
| 7. [Tenant β Payouts](#7-tenant--payouts) |
| 8. [Tenant β Alerts](#8-tenant--alerts) |
| 9. [Super Admin](#9-super-admin) |
| 10. [Captive Portal (Public)](#10-captive-portal-public) |
| 11. [Error Format](#11-error-format) |
| 12. [Enums & Constants](#12-enums--constants) |
| 13. [UI Building Guide](#13-ui-building-guide) |
| |
| --- |
| |
| ## 1. Authentication |
| |
| All tenant endpoints require a `Bearer` token in the `Authorization` header: |
| |
| ``` |
| Authorization: Bearer <jwt_token> |
| ``` |
| |
| The token is returned on login and registration. It expires after **30 days**. |
| |
| --- |
| |
| ### POST `/api/auth/register` |
| Register a new tenant account. |
| |
| **Body:** |
| ```json |
| { |
| "business_name": "Sunset Hostel", |
| "contact_name": "John Doe", |
| "email": "john@sunset.co.tz", |
| "phone": "0712345678", |
| "password": "mypassword" |
| } |
| ``` |
| |
| **Validation:** |
| - All fields required |
| - `password` minimum 8 characters |
| - `email` and `phone` must be unique |
| |
| **Response `201`:** |
| ```json |
| { |
| "token": "eyJhbGci...", |
| "client": { |
| "id": 1, |
| "business_name": "Sunset Hostel", |
| "contact_name": "John Doe", |
| "email": "john@sunset.co.tz", |
| "phone": "0712345678", |
| "balance": 0, |
| "total_earned": 0, |
| "total_withdrawn": 0, |
| "is_active": 1, |
| "created_at": "2026-04-15T10:00:00.000Z" |
| } |
| } |
| ``` |
| |
| > A welcome SMS is sent to `phone` on success. |
|
|
| --- |
|
|
| ### POST `/api/auth/login` |
| Login with email and password. |
|
|
| **Body:** |
| ```json |
| { |
| "email": "john@sunset.co.tz", |
| "password": "mypassword" |
| } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { |
| "token": "eyJhbGci...", |
| "client": { ...same as register... } |
| } |
| ``` |
|
|
| --- |
|
|
| ### POST `/api/auth/forgot-password` |
| Request a password reset OTP via SMS. |
|
|
| **Body:** |
| ```json |
| { "phone": "0712345678" } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { "message": "If that number is registered, an OTP has been sent." } |
| ``` |
|
|
| > Always returns 200 regardless of whether the phone exists β prevents enumeration. |
| > OTP is a 6-digit code, valid for **10 minutes**, single-use. |
| > Rate limited to 3 requests per 15 minutes per IP. |
|
|
| --- |
|
|
| ### POST `/api/auth/reset-password` |
| Set a new password using the OTP received via SMS. |
|
|
| **Body:** |
| ```json |
| { |
| "phone": "0712345678", |
| "otp": "482910", |
| "new_password": "newsecurepassword" |
| } |
| ``` |
|
|
| **Validation:** |
| - `new_password` minimum 8 characters |
| - OTP must be unused and not expired |
|
|
| **Response `200`:** |
| ```json |
| { "message": "Password reset successfully. Please log in." } |
| ``` |
|
|
| **Error `400`:** `"Invalid or expired OTP"` |
|
|
| --- |
|
|
| ### GET `/api/auth/me` |
| Get the authenticated tenant's profile. |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| { |
| "id": 1, |
| "business_name": "Sunset Hostel", |
| "contact_name": "John Doe", |
| "email": "john@sunset.co.tz", |
| "phone": "0712345678", |
| "balance": 45000.00, |
| "total_earned": 320000.00, |
| "total_withdrawn": 275000.00, |
| "created_at": "2026-04-15T10:00:00.000Z" |
| } |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/auth/notification-preferences` |
| Get the tenant's configurable notification settings. |
|
|
| These settings control tenant SMS notifications and in-app alerts. |
| Security/authentication SMS messages such as password reset OTPs and phone change verification are not configurable. |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| { |
| "preferences": { |
| "device_renewal": true, |
| "device_suspended": true, |
| "device_paused": true, |
| "billing_reminder": true, |
| "payout_completed": true, |
| "payout_failed": true, |
| "trial_granted": true, |
| "purchase_alerts": true, |
| "system_alerts": true |
| }, |
| "options": [ |
| { |
| "key": "device_renewal", |
| "label": "Device renewal", |
| "description": "Sent when a device subscription payment succeeds and the device is renewed.", |
| "default_enabled": true, |
| "enabled": true |
| } |
| ] |
| } |
| ``` |
|
|
| **Configurable notification options:** |
| - `device_renewal` |
| - `device_suspended` |
| - `device_paused` |
| - `billing_reminder` |
| - `payout_completed` |
| - `payout_failed` |
| - `trial_granted` |
| - `purchase_alerts` |
| - `system_alerts` |
|
|
| --- |
|
|
| ### PATCH `/api/auth/notification-preferences` |
| Update one or more tenant notification settings. |
|
|
| **Auth:** Required |
|
|
| **Body:** Send only the fields you want to change. |
| ```json |
| { |
| "device_renewal": false, |
| "billing_reminder": false, |
| "payout_failed": true, |
| "purchase_alerts": false |
| } |
| ``` |
|
|
| **Validation:** |
| - each provided field must be boolean |
|
|
| **Response `200`:** |
| ```json |
| { |
| "preferences": { |
| "device_renewal": false, |
| "device_suspended": true, |
| "device_paused": true, |
| "billing_reminder": false, |
| "payout_completed": true, |
| "payout_failed": true, |
| "trial_granted": true, |
| "purchase_alerts": false, |
| "system_alerts": true |
| }, |
| "options": [ |
| { |
| "key": "device_renewal", |
| "label": "Device renewal", |
| "description": "Sent when a device subscription payment succeeds and the device is renewed.", |
| "default_enabled": true, |
| "enabled": false |
| } |
| ], |
| "message": "Notification preferences updated" |
| } |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/auth/payment-settings` |
| Get the tenant's direct-payment receiving settings used for manual token sales. |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| { |
| "payment_display_name": "Gidion Mathayo Chiyao", |
| "payment_phone": "070024519", |
| "payment_provider": "selcom", |
| "payment_instructions": "Customer pays directly, then attendant confirms.", |
| "payment_qr_image_url": null, |
| "manual_sales_enabled": true, |
| "portal_payment_mode": "both" |
| } |
| ``` |
|
|
| --- |
|
|
| ### PATCH `/api/auth/payment-settings` |
| Update the tenant's direct-payment receiving settings. |
|
|
| If `manual_sales_enabled` is `true`, the account must have: |
| - `payment_display_name` |
| - `payment_phone` |
| - `payment_provider` |
|
|
| `portal_payment_mode` controls what the captive portal shows: |
| - `online_only` |
| - `direct_only` |
| - `both` |
|
|
| **Auth:** Required |
|
|
| **Body:** |
| ```json |
| { |
| "payment_display_name": "Gidion Mathayo Chiyao", |
| "payment_phone": "070024519", |
| "payment_provider": "selcom", |
| "payment_instructions": "Pay this number, then confirm from the dashboard.", |
| "payment_qr_image_url": null, |
| "manual_sales_enabled": true, |
| "portal_payment_mode": "both" |
| } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { |
| "payment_display_name": "Gidion Mathayo Chiyao", |
| "payment_phone": "070024519", |
| "payment_provider": "selcom", |
| "payment_instructions": "Pay this number, then confirm from the dashboard.", |
| "payment_qr_image_url": null, |
| "manual_sales_enabled": true, |
| "portal_payment_mode": "both", |
| "message": "Payment settings updated" |
| } |
| ``` |
|
|
| --- |
|
|
| ### PATCH `/api/auth/profile` |
| Update contact name, business name, or email. Send only the fields you want to change. |
|
|
| **Auth:** Required |
|
|
| **Request body:** |
| ```json |
| { |
| "contact_name": "Jane Doe", |
| "business_name": "Sunset Hotel", |
| "email": "jane@sunset.co.tz" |
| } |
| ``` |
|
|
| **Response `200`:** Updated client object (same shape as `GET /api/auth/me`). |
| **Error `409`:** Email already in use by another account. |
|
|
| --- |
|
|
| ### POST `/api/auth/change-password` |
| Change password while logged in. Requires the current password as proof. |
|
|
| **Auth:** Required |
|
|
| **Request body:** |
| ```json |
| { |
| "current_password": "OldPass123", |
| "new_password": "NewPass456" |
| } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { "message": "Password changed successfully" } |
| ``` |
|
|
| **Error `401`:** Current password is incorrect. |
|
|
| --- |
|
|
| ### POST `/api/auth/phone/request` |
| Send a 6-digit OTP to a new phone number to verify the tenant owns it. |
| Rate limited: 3 requests per 30 minutes. |
|
|
| **Auth:** Required |
|
|
| **Request body:** |
| ```json |
| { "new_phone": "0754000111" } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { "message": "Verification code sent to new phone number" } |
| ``` |
|
|
| **Error `409`:** Phone already in use by another account. |
|
|
| --- |
|
|
| ### POST `/api/auth/phone/confirm` |
| Verify the OTP and update the phone number. Sends a security notice SMS to the old number. |
|
|
| **Auth:** Required |
|
|
| **Request body:** |
| ```json |
| { |
| "new_phone": "0754000111", |
| "otp": "847291" |
| } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { "message": "Phone number updated successfully" } |
| ``` |
|
|
| **Error `400`:** Invalid or expired OTP. |
|
|
| --- |
|
|
| ## 2. Tenant β Devices |
|
|
| A **device** represents one WiFi access point (AP) registered to a tenant. Each device maps to one Omada site and has its own captive portal, WiFi plans, billing, and guest sessions. |
|
|
| --- |
|
|
| ### GET `/api/devices` |
| List all devices for the authenticated tenant. |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 3, |
| "name": "Main Building", |
| "location": "Floor 1", |
| "mac": "24-2F-D0-DF-19-9C", |
| "model": "EAP110-Outdoor", |
| "ip": "192.168.1.137", |
| "status": "online", |
| "billing_status": "active", |
| "billing_expires_at": "2026-05-15T00:00:00.000Z", |
| "monthly_fee": 20000.00, |
| "cpu_util": 12, |
| "mem_util": 63, |
| "guest_count": 4, |
| "client_count": 5, |
| "last_seen_at": "2026-04-15T11:30:00.000Z", |
| "registered_at": "2026-03-01T08:00:00.000Z", |
| "omada_site_id": "69dcc0a73044367bfbc43692" |
| } |
| ] |
| ``` |
|
|
| **`status` values:** `pending` Β· `adopting` Β· `online` Β· `offline` Β· `removed` |
| **`billing_status` values:** `trial` Β· `active` Β· `suspended` Β· `cancelled` |
| |
| --- |
| |
| ### GET `/api/devices/paid-inactive` |
| List devices that have a paid subscription but are not currently connected to WiFi. |
| |
| This is the direct endpoint for "paid but not connected" devices: |
| - `billing_status = 'active'` |
| - `status != 'online'` |
| |
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 3, |
| "name": "Main Building", |
| "location": "Floor 1", |
| "mac": "24-2F-D0-DF-19-9C", |
| "model": "EAP110-Outdoor", |
| "ip": "192.168.1.137", |
| "status": "offline", |
| "billing_status": "active", |
| "billing_expires_at": "2026-05-15T00:00:00.000Z", |
| "monthly_fee": 20000.00, |
| "cpu_util": 12, |
| "mem_util": 63, |
| "guest_count": 0, |
| "client_count": 0, |
| "last_seen_at": "2026-04-15T11:30:00.000Z", |
| "registered_at": "2026-03-01T08:00:00.000Z", |
| "omada_site_id": "69dcc0a73044367bfbc43692", |
| "last_payment_amount": 20000.00, |
| "last_payment_at": "2026-04-15T09:00:00.000Z" |
| } |
| ] |
| ``` |
|
|
| --- |
|
|
| ### POST `/api/devices` |
| Register a new WiFi access point. |
|
|
| **Auth:** Required |
|
|
| **Body:** |
| ```json |
| { |
| "name": "Main Building", |
| "mac": "24:2F:D0:DF:19:9C", |
| "location": "Floor 1", |
| "device_username": "admin", |
| "device_password": "MyCustomPass@123" |
| } |
| ``` |
|
|
| **Notes:** |
| - `name` and `mac` are required. All other fields are optional. |
| - MAC is auto-normalized to `AA-BB-CC-DD-EE-FF` format. |
| - `device_username` / `device_password` are the credentials the AP was configured with. If not supplied, Omada's auto-generated site credentials are used. Supply these if the AP already has custom credentials set before registration. |
| - An Omada site and captive portal are created automatically. |
| - Device starts in `trial` billing status (default 14-day trial). |
|
|
| **Response `201`:** |
| ```json |
| { |
| "device": { ...full device object... }, |
| "message": "Device registered. Plug in your AP and wait for it to come online." |
| } |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/devices/:id` |
| Get a single device with today's stats and active guest sessions. |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| { |
| "id": 3, |
| "name": "Main Building", |
| "status": "online", |
| "billing_status": "active", |
| "sales_today": 12, |
| "earned_today": 11400.00, |
| "active_sessions": [ |
| { |
| "client_mac": "2A-61-D9-9B-90-7C", |
| "started_at": "2026-04-15T09:00:00.000Z", |
| "bytes_down": 4321769, |
| "bytes_up": 6990620, |
| "expires_at": "2026-04-15T11:00:00.000Z", |
| "code": "ABCD-1234", |
| "plan_name": "2 Hour Plan" |
| } |
| ] |
| } |
| ``` |
|
|
| --- |
|
|
| ### DELETE `/api/devices/:id` |
| Pause a device (suspends billing, kicks all connected guests, revokes active tokens). |
|
|
| **Auth:** Required |
|
|
| > This is reversible β use `POST /api/devices/:id/renew` to reactivate. |
| > The captive portal will show a "Renew Subscription" page to guests. |
| > Tenant receives an SMS with the renewal amount. |
|
|
| **Response `200`:** |
| ```json |
| { |
| "message": "Device paused. Renew your subscription to reactivate." |
| } |
| ``` |
|
|
| --- |
|
|
| ### POST `/api/devices/:id/renew` |
| Initiate a monthly subscription payment for a device via M-Pesa. |
|
|
| **Auth:** Required |
|
|
| **Body:** *(empty)* |
|
|
| **Response `200`:** |
| ```json |
| { |
| "reference": "abc123def456ghi789", |
| "amount": 20000, |
| "message": "Check your phone for M-Pesa prompt" |
| } |
| ``` |
|
|
| > Payment is processed via M-Pesa. On success the webhook automatically extends `billing_expires_at` by 30 days and sets `billing_status = 'active'`. |
| |
| --- |
| |
| ## 3. Tenant β WiFi Plans |
| |
| Plans define what guests can purchase on the captive portal. Each plan belongs to a specific device. |
| |
| --- |
| |
| ### GET `/api/plans?deviceId=:id` |
| List all plans (active and inactive) for a device. |
| |
| **Auth:** Required |
| **Query:** `deviceId` (required) |
| |
| Only plans belonging to the authenticated tenant and the selected device are returned. |
| |
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 7, |
| "client_id": 1, |
| "device_id": 3, |
| "name": "1 Hour Browsing", |
| "duration_seconds": 3600, |
| "price": 500.00, |
| "down_limit": 5, |
| "down_unit": 2, |
| "up_limit": 2, |
| "up_unit": 2, |
| "display_order": 0, |
| "is_active": 1, |
| "created_at": "2026-03-01T08:00:00.000Z" |
| } |
| ] |
| ``` |
| |
| **Speed units:** `1` = Kbps Β· `2` = Mbps |
|
|
| --- |
|
|
| ### POST `/api/plans` |
| Create a new WiFi plan. |
|
|
| **Auth:** Required |
|
|
| **Body:** |
| ```json |
| { |
| "device_id": 3, |
| "name": "1 Hour Browsing", |
| "duration_seconds": 3600, |
| "price": 500, |
| "down_limit": 5, |
| "down_unit": 2, |
| "up_limit": 2, |
| "up_unit": 2, |
| "display_order": 0 |
| } |
| ``` |
|
|
| **Required:** `device_id`, `name`, `duration_seconds`, `price` |
| **Defaults:** `down_limit: 2`, `down_unit: 2`, `up_limit: 2`, `up_unit: 2`, `display_order: 0` |
|
|
| **Response `201`:** Full plan object. |
|
|
| --- |
|
|
| ### PUT `/api/plans/:id` |
| Update a plan. All fields are optional (partial update supported). |
|
|
| **Auth:** Required |
|
|
| **Body (all optional):** |
| ```json |
| { |
| "name": "1 Hour Plan", |
| "duration_seconds": 3600, |
| "price": 600, |
| "down_limit": 10, |
| "down_unit": 2, |
| "up_limit": 5, |
| "up_unit": 2, |
| "display_order": 1, |
| "is_active": 1 |
| } |
| ``` |
|
|
| **Response `200`:** Updated plan object. |
|
|
| > Set `is_active: 0` to hide a plan from the portal without deleting it. |
| |
| --- |
| |
| ### DELETE `/api/plans/:id` |
| Deactivate a plan (soft delete β it stops appearing on the portal). |
| |
| **Auth:** Required |
| |
| **Response `200`:** |
| ```json |
| { "message": "Plan deactivated" } |
| ``` |
| |
| --- |
| |
| ### DELETE `/api/plans/:id/permanent` |
| Permanently delete a plan when it has no sales or tokens attached. |
| |
| **Auth:** Required |
| |
| This is intended for cleaning up unused test plans. |
| |
| **Response `200`:** |
| ```json |
| { "message": "Plan permanently deleted" } |
| ``` |
| |
| **Error `409`:** |
| ```json |
| { "error": "Plan has sales or tokens attached and cannot be permanently deleted" } |
| ``` |
| |
| --- |
| |
| ## 4. Tenant β Dashboard |
| |
| --- |
| |
| ### GET `/api/dashboard/summary` |
| Main dashboard overview for the authenticated tenant. |
| |
| **Auth:** Required |
| |
| **Response `200`:** |
| ```json |
| { |
| "balance": 45000.00, |
| "total_earned": 320000.00, |
| "total_withdrawn": 275000.00, |
| "alert_count": 2, |
| "devices": [ |
| { |
| "id": 3, |
| "name": "Main Building", |
| "location": "Floor 1", |
| "status": "online", |
| "billing_status": "active", |
| "billing_expires_at": "2026-05-15T00:00:00.000Z", |
| "model": "EAP110-Outdoor", |
| "cpu_util": 12, |
| "mem_util": 63, |
| "guest_count": 4, |
| "last_seen_at": "2026-04-15T11:30:00.000Z", |
| "sales_today": 12, |
| "earned_today": 11400.00, |
| "guests_online": 4 |
| } |
| ] |
| } |
| ``` |
| |
| --- |
|
|
| ### GET `/api/dashboard/revenue/weekly` |
| Last 7 days of revenue broken down by day. |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "day": "2026-04-15", |
| "online_sales": 12, |
| "online_gross": 12000.00, |
| "online_net": 11340.00, |
| "manual_sales": 3, |
| "manual_gross": 2500.00, |
| "total_sales": 15, |
| "total_gross": 14500.00, |
| "sales": 15, |
| "gross": 14500.00, |
| "net": 11340.00 |
| } |
| ] |
| ``` |
|
|
| Definitions: |
| - `online_*` = platform-collected payments (`payment_channel = 'snippe'`) |
| - `manual_*` = tenant direct/manual sales (`payment_channel = 'tenant_direct_manual'`) |
| - `total_*` = combined totals across both channels |
|
|
| Compatibility fields: |
| - `sales` = `total_sales` |
| - `gross` = `total_gross` |
| - `net` = `online_net` |
|
|
| > `online_net` / `net` = online `gross` minus Snippe fee (0.5%) minus platform commission. |
| > Manual/direct sales are not payout-eligible and are not included in `net`. |
| > Days with no sales are omitted β fill gaps in the frontend. |
| |
| --- |
| |
| ## 5. Tenant β Sessions |
| |
| --- |
| |
| ### GET `/api/sessions/active` |
| All currently active guest sessions across all devices. |
| |
| **Auth:** Required |
| |
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 201, |
| "client_mac": "2A-61-D9-9B-90-7C", |
| "client_ip": "192.168.1.156", |
| "ssid": "Sunset Hostel WiFi", |
| "started_at": "2026-04-15T09:00:00.000Z", |
| "last_seen_at": "2026-04-15T11:25:00.000Z", |
| "bytes_down": 4321769, |
| "bytes_up": 6990620, |
| "duration_seconds": 8700, |
| "rssi": -42, |
| "signal_level": 80, |
| "device_name": "Main Building", |
| "token_code": "ABCD-1234", |
| "expires_at": "2026-04-15T11:00:00.000Z", |
| "plan_name": "2 Hour Plan" |
| } |
| ] |
| ``` |
| |
| --- |
|
|
| ### GET `/api/sessions/paid-disconnected` |
| All paid clients who still have valid access time remaining and are not currently connected to WiFi. |
|
|
| This list is backend-owned and is based on valid purchased tokens, not just session history. It includes: |
| - tokens linked to completed payments |
| - tokens that have not yet expired |
| - clients with no active WiFi session right now |
| - excludes suspended/cancelled device-billing cases |
|
|
| The related device may be `online`, `offline`, `pending`, or `adopting`. |
| The `status` field is still returned so the UI can show the AP state, but AP `online` no longer removes the client from this list. |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "session_id": 201, |
| "token_id": 88, |
| "client_mac": "2A-61-D9-9B-90-7C", |
| "token_code": "ABCD-1234", |
| "plan_name": "2 Hour Plan", |
| "expires_at": "2026-04-15T13:00:00.000Z", |
| "remaining_seconds": 3600, |
| "device_id": 3, |
| "device_name": "Main Building", |
| "status": "offline", |
| "payment_phone": "0712345678", |
| "payment_reference": "abc123def456ghi789jkl", |
| "started_at": "2026-04-15T09:00:00.000Z", |
| "last_seen_at": "2026-04-15T10:15:00.000Z" |
| } |
| ] |
| ``` |
|
|
| Notes: |
| - `session_id`, `started_at`, and `last_seen_at` may be `null` if the customer paid but never successfully connected before the AP issue. |
| - Use `client_mac`, falling back to `token_code` or `payment_reference`, as the customer identifier in the UI. |
| - Use `payment_phone` to display the phone number associated with the original payment. |
|
|
| --- |
|
|
| ### POST `/api/sessions/paid-disconnected/:tokenId/message` |
| Send a custom SMS to a paid client who is not currently connected, using the phone number from the original payment. |
|
|
| The token must still belong to the authenticated tenant, still be valid, and must not currently have an active WiFi session. |
|
|
| **Auth:** Required |
|
|
| **Body:** |
| ```json |
| { |
| "message": "We are sorry for the outage. Your hotspot is being restored now." |
| } |
| ``` |
|
|
| **Validation:** |
| - `message` required |
| - max length 320 characters |
|
|
| **Response `200`:** |
| ```json |
| { |
| "success": true, |
| "token_id": 88, |
| "token_code": "ABCD-1234", |
| "payment_phone": "0712345678", |
| "payment_reference": "abc123def456ghi789jkl", |
| "device_id": 3, |
| "device_name": "Main Building", |
| "status": "offline", |
| "message": "SMS sent successfully" |
| } |
| ``` |
|
|
| --- |
|
|
| ### POST `/api/sessions/paid-disconnected/:tokenId/compensate` |
| Add free compensation time to an affected paid client by extending the original purchased token. |
|
|
| This is a goodwill extension only: |
| - no new payment is created |
| - revenue, balance, payouts, and ledgers are unchanged |
| - the original token code stays the same |
| - an audit record is written server-side |
|
|
| The token must belong to the authenticated tenant, still be valid, and must not currently have an active WiFi session. |
|
|
| **Auth:** Required |
|
|
| **Body:** |
| ```json |
| { |
| "minutes": 120, |
| "reason": "ISP outage compensation" |
| } |
| ``` |
|
|
| **Validation:** |
| - `minutes` must be a positive integer |
| - max `minutes` is `10080` (7 days) |
| - `reason` optional, max 255 characters |
|
|
| **Response `200`:** |
| ```json |
| { |
| "success": true, |
| "token_id": 88, |
| "token_code": "ABCD-1234", |
| "device_id": 3, |
| "device_name": "Main Building", |
| "status": "active", |
| "granted_minutes": 120, |
| "granted_seconds": 7200, |
| "old_expires_at": "2026-05-10T17:35:00.000Z", |
| "new_expires_at": "2026-05-10T19:35:00.000Z", |
| "remaining_seconds": 7200, |
| "reason": "ISP outage compensation", |
| "message": "Compensation time added successfully" |
| } |
| ``` |
|
|
| **Notes:** |
| - Use this from the Sessions page for outage support. |
| - After success, refresh `GET /api/sessions/paid-disconnected`. |
| - If the client is still not connected, the row will remain in the list with a longer time left. |
|
|
| --- |
|
|
| ### GET `/api/sessions` |
| Session history (active and ended). |
|
|
| **Auth:** Required |
| **Query params:** |
| - `deviceId` *(optional)* β filter by device |
| - `limit` *(optional, default 50)* β max records to return |
|
|
| **Response `200`:** Array of session objects including `ended_at` and `is_active`. |
|
|
| --- |
|
|
| ## 6. Tenant β Manual Sales |
|
|
| Manual sales are tenant-confirmed direct payments. The customer pays the tenant outside WifiBiz, the tenant confirms from the dashboard, and WifiBiz generates a token. |
|
|
| Important accounting rule: |
| - manual sales generate tokens |
| - manual sales are recorded in `payments` |
| - manual sales do **not** increase withdrawable platform balance |
| - manual sales do **not** enter payout calculations |
|
|
| --- |
|
|
| ### GET `/api/sales` |
| Unified sales feed for the authenticated tenant. |
|
|
| This includes: |
| - online/platform-collected sales |
| - tenant direct/manual sales |
|
|
| Manual/direct sales are marked as removable when still in `completed` state. Online sales are never removable. |
|
|
| **Auth:** Required |
|
|
| **Query params:** |
| - `limit` optional, default `50`, max `100` |
| - `include_voided=1` optional to include voided manual test sales |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "payment_id": 41, |
| "reference": "MAN-1A2B3C4D5E6F7G8H9J0K", |
| "customer_phone": "0769590766", |
| "phone_provider": "selcom", |
| "gross_amount": 500.00, |
| "client_credit": 0.00, |
| "payment_channel": "tenant_direct_manual", |
| "verification_source": "tenant_confirmed", |
| "counts_toward_balance": 0, |
| "notes": "Paid via tenant direct payment", |
| "status": "completed", |
| "completed_at": "2026-05-11T09:10:00.000Z", |
| "confirmed_at": "2026-05-11T09:10:00.000Z", |
| "access_token_code": "ABCD-1234", |
| "device_id": 1, |
| "device_name": "Gaza", |
| "plan_id": 1, |
| "plan_name": "Siku Unlimited", |
| "duration_seconds": 86400, |
| "sale_type": "manual", |
| "removable": true |
| }, |
| { |
| "payment_id": 55, |
| "reference": "abc123def456ghi789", |
| "customer_phone": "0712345678", |
| "phone_provider": "mpesa", |
| "gross_amount": 1000.00, |
| "client_credit": 945.00, |
| "payment_channel": "snippe", |
| "verification_source": "webhook", |
| "counts_toward_balance": 1, |
| "notes": null, |
| "status": "completed", |
| "completed_at": "2026-05-11T10:00:00.000Z", |
| "confirmed_at": null, |
| "access_token_code": "WXYZ-5678", |
| "device_id": 1, |
| "device_name": "Gaza", |
| "plan_id": 4, |
| "plan_name": "Mwezi - Unlimited", |
| "duration_seconds": 2592000, |
| "sale_type": "online", |
| "removable": false |
| } |
| ] |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/manual-sales` |
| Recent manual token sales for the authenticated tenant. |
|
|
| **Auth:** Required |
|
|
| **Query params:** |
| - `limit` optional, default `50`, max `100` |
| - `include_voided=1` optional to include voided manual test sales |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 41, |
| "reference": "MAN-1A2B3C4D5E6F7G8H9J0K", |
| "customer_phone": null, |
| "phone_provider": "selcom", |
| "gross_amount": 1000.00, |
| "payment_channel": "tenant_direct_manual", |
| "verification_source": "tenant_confirmed", |
| "counts_toward_balance": 0, |
| "notes": "Confirmed from counter phone", |
| "confirmed_at": "2026-05-11T08:15:00.000Z", |
| "completed_at": "2026-05-11T08:15:00.000Z", |
| "status": "completed", |
| "access_token_code": "ABCD-1234", |
| "device_id": 3, |
| "device_name": "Gaza", |
| "plan_id": 7, |
| "plan_name": "Siku Unlimited", |
| "duration_seconds": 86400, |
| "removable": true |
| } |
| ] |
| ``` |
|
|
| --- |
|
|
| ### POST `/api/manual-sales` |
| Create a tenant-confirmed manual sale and generate a WiFi token from the selected plan. |
|
|
| Plan selection is mandatory. The backend copies the selected plan's real price, duration, and speed limits into the payment/token. |
|
|
| **Auth:** Required |
|
|
| **Body:** |
| ```json |
| { |
| "device_id": 3, |
| "plan_id": 7, |
| "customer_phone": "0712345678", |
| "notes": "Paid via tenant Selcom QR" |
| } |
| ``` |
|
|
| **Validation:** |
| - `device_id` required |
| - `plan_id` required |
| - `customer_phone` required so the generated token can be sent by SMS |
| - selected plan must belong to the selected device |
| - selected plan must belong to the authenticated tenant |
| - selected plan must be active |
| - device billing must be `trial` or `active` |
| - tenant account must have manual sales enabled in payment settings |
|
|
| **Response `201`:** |
| ```json |
| { |
| "payment_id": 41, |
| "reference": "MAN-1A2B3C4D5E6F7G8H9J0K", |
| "payment_channel": "tenant_direct_manual", |
| "verification_source": "tenant_confirmed", |
| "counts_toward_balance": false, |
| "token_id": 88, |
| "token_code": "ABCD-1234", |
| "customer_phone": "0712345678", |
| "amount": 1000, |
| "device_id": 3, |
| "device_name": "Gaza", |
| "plan_id": 7, |
| "plan_name": "Siku Unlimited", |
| "duration_seconds": 86400, |
| "notes": "Paid via tenant Selcom QR", |
| "sms_sent": true, |
| "expires_at_preview": "12 May 2026, 5:27 PM EAT", |
| "message": "Token generated successfully" |
| } |
| ``` |
|
|
| --- |
|
|
| ### DELETE `/api/manual-sales/:paymentId` |
| Remove a completed manual sale for testing. |
|
|
| This only applies to `tenant_direct_manual` sales. It does not affect platform balance or payouts. |
|
|
| Behavior: |
| - payment status becomes `voided` |
| - linked token is removed if unused and unreferenced |
| - otherwise linked token is revoked |
| - sale no longer appears in default completed sales lists |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| { |
| "payment_id": 41, |
| "reference": "MAN-1A2B3C4D5E6F7G8H9J0K", |
| "token_id": 88, |
| "token_code": "ABCD-1234", |
| "status": "voided", |
| "removable": false, |
| "message": "Manual sale removed successfully" |
| } |
| ``` |
|
|
| **Error `409`:** |
| ```json |
| { "error": "Cannot remove a manual sale with an active session" } |
| ``` |
|
|
| --- |
|
|
| ## 7. Tenant β Payouts |
|
|
| Tenants withdraw their earned balance via M-Pesa. |
|
|
| --- |
|
|
| ### GET `/api/payouts` |
| Payout history for the authenticated tenant (last 50). |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 5, |
| "amount": 50000.00, |
| "payout_fee": 1500.00, |
| "net_amount": 48500.00, |
| "destination_phone": "0712345678", |
| "destination_provider": "mpesa", |
| "status": "completed", |
| "failure_reason": null, |
| "created_at": "2026-04-10T14:00:00.000Z", |
| "completed_at": "2026-04-10T14:02:00.000Z" |
| } |
| ] |
| ``` |
|
|
| **`status` values:** `pending` Β· `processing` Β· `completed` Β· `failed` |
|
|
| --- |
|
|
| ### POST `/api/payouts` |
| Request a withdrawal to M-Pesa. |
|
|
| **Auth:** Required |
|
|
| **Body:** |
| ```json |
| { |
| "amount": 50000, |
| "phone": "0712345678", |
| "provider": "mpesa" |
| } |
| ``` |
|
|
| **Validation:** |
| - `amount` minimum: 5,000 TZS |
| - `amount` must not exceed `balance` |
| - `provider` options: `mpesa` Β· `airtel` Β· `tigo` Β· `halopesa` (default: `mpesa`) |
|
|
| **Response `201`:** |
| ```json |
| { |
| "id": 6, |
| "amount": 50000, |
| "payout_fee": 1500, |
| "net_amount": 48500, |
| "status": "processing", |
| "message": "Payout initiated. Funds will arrive shortly." |
| } |
| ``` |
|
|
| > Balance is deducted immediately on request. If the payout fails, balance is automatically restored. Tenant receives an SMS on completion or failure. |
|
|
| --- |
|
|
| ## 8. Tenant β Alerts |
|
|
| Alerts are shown in-app from two sources: |
| - Omada/system alerts such as device offline, disconnected, or client-block events |
| - purchase alerts created when a guest buys a WiFi bundle |
|
|
| Tenants can toggle future alert creation with: |
| - `system_alerts` |
| - `purchase_alerts` |
|
|
| These preferences affect newly created alerts only. Existing alert rows remain until resolved. |
|
|
| --- |
|
|
| ### GET `/api/alerts/unresolved` |
| All unresolved alerts for the tenant (last 50, newest first). |
|
|
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 12, |
| "alert_key": "DEV_DISCONN", |
| "level": "error", |
| "content": "[ap:24-2F-D0-DF-19-9C] was disconnected.", |
| "device_mac": "24-2F-D0-DF-19-9C", |
| "alert_time": "2026-04-15T08:00:00.000Z", |
| "fetched_at": "2026-04-15T08:15:00.000Z", |
| "device_name": "Main Building" |
| } |
| ] |
| ``` |
|
|
| **`level` values:** `info` Β· `warning` Β· `error` Β· `critical` |
| **Common `alert_key` values:** `DEV_DISCONN` Β· `DEV_CONN` Β· `CLIENT_BLOCK` |
| |
| --- |
| |
| ### PATCH `/api/alerts/:id/resolve` |
| Mark an alert as resolved. |
| |
| **Auth:** Required |
|
|
| **Response `200`:** |
| ```json |
| { "message": "Alert resolved" } |
| ``` |
|
|
| --- |
|
|
| ## 9. Super Admin |
|
|
| All admin endpoints require a static admin token passed as a Bearer token. |
| This is a **separate secret** from tenant JWTs β set via `ADMIN_TOKEN` environment variable. |
|
|
| ``` |
| Authorization: Bearer <ADMIN_TOKEN> |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/admin/overview` |
| Platform-wide stats for the admin dashboard. |
|
|
| **Response `200`:** |
| ```json |
| { |
| "tenants": 24, |
| "devices_online": 18, |
| "devices_offline": 3, |
| "devices_suspended": 2, |
| "guests_online": 47, |
| "sales_today": 312, |
| "gross_today": 312000.00, |
| "commission_today": 15600.00, |
| "snippe_fees_today": 1560.00, |
| "pending_payouts": 5, |
| "pending_payout_total": 230000.00 |
| } |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/admin/tenants` |
| List all tenants with device counts. |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 1, |
| "business_name": "Sunset Hostel", |
| "contact_name": "John Doe", |
| "email": "john@sunset.co.tz", |
| "phone": "0712345678", |
| "balance": 45000.00, |
| "total_earned": 320000.00, |
| "total_withdrawn": 275000.00, |
| "is_active": 1, |
| "created_at": "2026-03-01T08:00:00.000Z", |
| "device_count": 2, |
| "devices_online": 1 |
| } |
| ] |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/admin/devices` |
| List all devices across all tenants. |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 3, |
| "name": "Main Building", |
| "location": "Floor 1", |
| "mac": "24-2F-D0-DF-19-9C", |
| "model": "EAP110-Outdoor", |
| "status": "online", |
| "billing_status": "active", |
| "billing_expires_at": "2026-05-15T00:00:00.000Z", |
| "monthly_fee": 20000.00, |
| "cpu_util": 12, |
| "mem_util": 63, |
| "guest_count": 4, |
| "last_seen_at": "2026-04-15T11:30:00.000Z", |
| "business_name": "Sunset Hostel", |
| "client_phone": "0712345678" |
| } |
| ] |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/admin/devices/paid-inactive` |
| List all devices across all tenants that are paid but not currently connected to WiFi. |
|
|
| This returns devices where: |
| - `billing_status = 'active'` |
| - `status != 'online'` |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 3, |
| "name": "Main Building", |
| "location": "Floor 1", |
| "mac": "24-2F-D0-DF-19-9C", |
| "model": "EAP110-Outdoor", |
| "status": "offline", |
| "billing_status": "active", |
| "billing_expires_at": "2026-05-15T00:00:00.000Z", |
| "monthly_fee": 20000.00, |
| "cpu_util": 12, |
| "mem_util": 63, |
| "guest_count": 0, |
| "last_seen_at": "2026-04-15T11:30:00.000Z", |
| "registered_at": "2026-03-01T08:00:00.000Z", |
| "business_name": "Sunset Hostel", |
| "client_phone": "0712345678", |
| "last_payment_amount": 20000.00, |
| "last_payment_at": "2026-04-15T09:00:00.000Z" |
| } |
| ] |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/admin/payouts/pending` |
| All payouts in `pending` or `processing` state across all tenants. |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "id": 6, |
| "amount": 50000.00, |
| "payout_fee": 1500.00, |
| "net_amount": 48500.00, |
| "destination_phone": "0712345678", |
| "destination_provider": "mpesa", |
| "status": "processing", |
| "created_at": "2026-04-15T10:00:00.000Z", |
| "business_name": "Sunset Hostel", |
| "contact_name": "John Doe" |
| } |
| ] |
| ``` |
|
|
| --- |
|
|
| ### GET `/api/admin/revenue/daily?days=30` |
| Daily revenue breakdown across the entire platform. |
|
|
| **Query:** `days` *(optional, default 30)* |
|
|
| **Response `200`:** |
| ```json |
| [ |
| { |
| "day": "2026-04-15", |
| "sales": 312, |
| "gross": 312000.00, |
| "commission": 15600.00, |
| "tenant_credit": 294840.00 |
| } |
| ] |
| ``` |
|
|
| --- |
|
|
| ### POST `/api/admin/devices/:id/reboot` |
| Send a reboot command to a specific AP via Omada. |
|
|
| **Response `200`:** |
| ```json |
| { "message": "Reboot command sent" } |
| ``` |
|
|
| --- |
|
|
| ### POST `/api/admin/devices/:id/activate` |
| Manually activate or renew a device subscription β used for **cash payments**. |
| Sets `billing_status = 'active'` and extends `billing_expires_at` by 30 days from the current expiry (or now if already expired). Records the payment in `device_payments` for audit. Sends the tenant an SMS confirmation. |
|
|
| **Request body:** |
| ```json |
| { |
| "note": "Paid cash at office - 20,000 TZS" // optional |
| } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { |
| "message": "Device activated successfully", |
| "billing_expires_at": "2026-05-15T09:00:00.000Z" |
| } |
| ``` |
|
|
| **Error `400`:** Device is cancelled (hard-cancelled devices cannot be reactivated). |
| **Error `404`:** Device not found. |
|
|
| --- |
|
|
| ## 10. Captive Portal (Public) |
|
|
| These endpoints are hit by the AP firmware redirect and the guest's browser. No authentication required. |
|
|
| --- |
|
|
| ### GET `/portal/:siteId?clientMac=&apMac=&ssidName=` |
| Render the captive portal page for a guest. |
|
|
| **Query params (injected by Omada redirect):** |
| - `clientMac` β guest's MAC address |
| - `apMac` β AP MAC address |
| - `ssidName` β SSID name |
|
|
| **Behavior:** |
| - If device is suspended/cancelled β renders **"Subscription Required"** page |
| - If guest MAC has a valid active token β **auto-reconnects** silently and renders **"Welcome Back"** page |
| - Otherwise β renders the **portal** with available plans |
|
|
| > This is a server-rendered EJS page, not a JSON API. |
|
|
| --- |
|
|
| ### POST `/portal/:siteId/purchase` |
| Guest initiates a WiFi plan purchase. |
|
|
| **Body:** |
| ```json |
| { |
| "planId": 7, |
| "phone": "0712345678" |
| } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { |
| "reference": "abc123def456ghi789jkl", |
| "status": "pending" |
| } |
| ``` |
|
|
| > An M-Pesa payment prompt is sent to `phone`. Poll `/check/:reference` to track completion. |
|
|
| --- |
|
|
| ### GET `/portal/:siteId/check/:reference` |
| Poll payment status after purchase. |
|
|
| **Response β pending:** |
| ```json |
| { "status": "pending" } |
| ``` |
|
|
| **Response β paid:** |
| ```json |
| { |
| "status": "paid", |
| "code": "ABCD-1234", |
| "plan_name": "1 Hour Browsing" |
| } |
| ``` |
|
|
| **Response β failed:** |
| ```json |
| { "status": "failed" } |
| ``` |
|
|
| --- |
|
|
| ### POST `/portal/:siteId/auth` |
| Authorize a guest on the WiFi using their access code. |
|
|
| **Body:** |
| ```json |
| { |
| "code": "ABCD-1234", |
| "clientMac": "2a:61:d9:9b:90:7c" |
| } |
| ``` |
|
|
| **Response `200`:** |
| ```json |
| { |
| "success": true, |
| "code": "ABCD-1234", |
| "expiresIn": 3600, |
| "expiresAt": "2026-04-15T13:00:00.000Z", |
| "speedDown": "5Mbps", |
| "speedUp": "2Mbps" |
| } |
| ``` |
|
|
| **Error responses:** |
| - `404` β code not found |
| - `403` β code locked to a different device MAC |
| - `410` β code expired |
| - `503` β AP offline, authorization failed |
|
|
| > The code is locked to the guest's MAC on first use. The same guest can reconnect later using the same code β they'll be re-authorized automatically without re-entering it. |
|
|
| --- |
|
|
| ## 11. Error Format |
|
|
| All errors follow this shape: |
|
|
| ```json |
| { "error": "Human-readable message" } |
| ``` |
|
|
| | Status | Meaning | |
| |--------|---------| |
| | `400` | Bad request β missing or invalid fields | |
| | `401` | Not authenticated or invalid token | |
| | `403` | Forbidden β resource belongs to another user | |
| | `404` | Resource not found | |
| | `409` | Conflict β duplicate email, phone, or MAC | |
| | `410` | Gone β token expired or revoked | |
| | `500` | Server error | |
| | `502` | Upstream error (Omada or Snippe unreachable) | |
| | `503` | Service unavailable (AP offline) | |
|
|
| --- |
|
|
| ## 12. Enums & Constants |
|
|
| ### Device `status` |
| | Value | Meaning | |
| |-------|---------| |
| | `pending` | Registered, AP not yet visible to Omada | |
| | `adopting` | Omada adoption in progress | |
| | `online` | AP connected and serving guests | |
| | `offline` | AP disconnected | |
| | `removed` | Decommissioned | |
|
|
| ### Device `billing_status` |
| | Value | Meaning | |
| |-------|---------| |
| | `trial` | Free trial period (default 14 days) | |
| | `active` | Subscription paid and active | |
| | `suspended` | Billing expired or manually paused β portal blocked | |
| | `cancelled` | Permanently decommissioned | |
| |
| ### Access token `status` |
| | Value | Meaning | |
| |-------|---------| |
| | `unused` | Paid for, not yet activated | |
| | `active` | In use β locked to a MAC | |
| | `expired` | Time ran out | |
| | `revoked` | Cancelled (device suspended/paused) | |
| |
| ### Speed `unit` |
| | Value | Meaning | |
| |-------|---------| |
| | `1` | Kbps | |
| | `2` | Mbps | |
| |
| ### Alert `level` |
| | Value | Meaning | |
| |-------|---------| |
| | `info` | Informational | |
| | `warning` | Non-critical issue | |
| | `error` | AP disconnected or serious event | |
| | `critical` | Requires immediate action | |
| |
| ### Payout / Payment `status` |
| | Value | Meaning | |
| |-------|---------| |
| | `pending` | Created, waiting for M-Pesa | |
| | `processing` | M-Pesa prompt sent | |
| | `completed` | Funds transferred | |
| | `failed` | Payment/payout failed | |
| |
| --- |
| |
| ## 13. UI Building Guide |
| |
| ### Tenant Dashboard β Recommended Pages |
| |
| | Page | Key endpoints | |
| |------|--------------| |
| | Login / Register | `POST /api/auth/login`, `POST /api/auth/register` | |
| | Dashboard Home | `GET /api/dashboard/summary`, `GET /api/dashboard/revenue/weekly` | |
| | Devices List | `GET /api/devices` | |
| | Device Detail | `GET /api/devices/:id`, `GET /api/plans?deviceId=`, `GET /api/sessions/active` | |
| | Add Device | `POST /api/devices` | |
| | Manage Plans | `POST /api/plans`, `PUT /api/plans/:id`, `DELETE /api/plans/:id`, `DELETE /api/plans/:id/permanent` | |
| | Session History | `GET /api/sessions` | |
| | Notification Settings | `GET /api/auth/notification-preferences`, `PATCH /api/auth/notification-preferences` | |
| | Payment Settings | `GET /api/auth/payment-settings`, `PATCH /api/auth/payment-settings` | |
| | Manual Token Sales | `GET /api/manual-sales`, `POST /api/manual-sales`, `GET /api/plans?deviceId=` | |
| | Sales | `GET /api/sales`, `DELETE /api/manual-sales/:paymentId` | |
| | Payouts | `GET /api/payouts`, `POST /api/payouts` | |
| | Alerts | `GET /api/alerts/unresolved`, `PATCH /api/alerts/:id/resolve` | |
| | Profile | `GET /api/auth/me` | |
| |
| ### Super Admin Panel β Recommended Pages |
| |
| | Page | Key endpoints | |
| |------|--------------| |
| | Admin Overview | `GET /api/admin/overview` | |
| | Tenants List | `GET /api/admin/tenants` | |
| | Devices List | `GET /api/admin/devices` | |
| | Pending Payouts | `GET /api/admin/payouts/pending` | |
| | Revenue Chart | `GET /api/admin/revenue/daily?days=30` | |
| | Device Actions | `POST /api/admin/devices/:id/reboot`, `POST /api/admin/devices/:id/activate` | |
| |
| ### Key UX Notes for Frontend |
| |
| **Device status indicators:** |
| - Show `online` as green, `offline` as red, `adopting` as yellow spinner, `pending` as grey |
| - `billing_status = 'suspended'` should show a prominent "Renew" CTA regardless of `status` |
| - Show `billing_expires_at` with a warning if within 3 days |
|
|
| **Balance display:** |
| - `balance` = available to withdraw right now |
| - `total_earned - total_withdrawn` should equal `balance` (use for sanity check display) |
| - Show earnings chart from `revenue/weekly` on dashboard home |
|
|
| **Payout flow:** |
| 1. Show current `balance` from `GET /api/auth/me` |
| 2. `POST /api/payouts` β deducts balance immediately on submit |
| 3. Poll or refresh `GET /api/payouts` to show updated status |
| 4. Show `net_amount` (after fee) prominently, not `amount` |
|
|
| **Device renewal flow:** |
| 1. `POST /api/devices/:id/renew` |
| 2. Inform user to check their phone for M-Pesa prompt |
| 3. Poll `GET /api/devices/:id` until `billing_status = 'active'` |
|
|
| **Alert badge:** |
| - Use `alert_count` from `GET /api/dashboard/summary` for the sidebar badge |
| - Refresh count after `PATCH /api/alerts/:id/resolve` |
|
|
| **Data formatting:** |
| - All amounts are TZS (Tanzanian Shilling) β display with `.toLocaleString()` |
| - `bytes_down` / `bytes_up` are raw bytes β convert to MB/GB for display |
| - `duration_seconds` β convert to human readable (e.g. `3600` β `1h`, `86400` β `1 day`) |
| - All timestamps are UTC ISO strings β convert to local time for display |
|
|
| **Icons and visuals:** |
| - **Do not use emojis** anywhere in the UI. Use SVG icons exclusively (e.g. Lucide, Heroicons, Feather, or inline SVG). |
| - Emojis render inconsistently across operating systems, fonts, and browsers β SVGs are predictable and styleable. |
| - Icon stroke color should use the active theme's primary color or semantic colors (green for success, amber for warning, red for error). |
| - Recommended semantic icon mappings: |
| - WiFi / connected β `wifi` icon, stroke = primary color |
| - Success / authorized β `check-circle` icon, stroke = `#22c55e` |
| - Suspended / locked β `lock` icon, stroke = `#f59e0b` |
| - Error / failed β `x-circle` icon, stroke = `#ef4444` |
| - Offline / disconnected β `wifi-off` icon, stroke = `#9ca3af` |
| - Device / AP β `radio` or `router` icon |
| - Payout β `send` or `credit-card` icon |
| - Alert β `alert-triangle` icon, stroke = `#f59e0b` |
|
|