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 beContent-Type: application/json.
Table of Contents
- Authentication
- Tenant — Devices
- Tenant — WiFi Plans
- Tenant — Dashboard
- Tenant — Sessions
- Tenant — Manual Sales
- Tenant — Payouts
- Tenant — Alerts
- Super Admin
- Captive Portal (Public)
- Error Format
- Enums & Constants
- 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:
{
"business_name": "Sunset Hostel",
"contact_name": "John Doe",
"email": "john@sunset.co.tz",
"phone": "0712345678",
"password": "mypassword"
}
Validation:
- All fields required
passwordminimum 8 charactersemailandphonemust be unique
Response 201:
{
"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
phoneon success.
POST /api/auth/login
Login with email and password.
Body:
{
"email": "john@sunset.co.tz",
"password": "mypassword"
}
Response 200:
{
"token": "eyJhbGci...",
"client": { ...same as register... }
}
POST /api/auth/forgot-password
Request a password reset OTP via SMS.
Body:
{ "phone": "0712345678" }
Response 200:
{ "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:
{
"phone": "0712345678",
"otp": "482910",
"new_password": "newsecurepassword"
}
Validation:
new_passwordminimum 8 characters- OTP must be unused and not expired
Response 200:
{ "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:
{
"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:
{
"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_renewaldevice_suspendeddevice_pausedbilling_reminderpayout_completedpayout_failedtrial_grantedpurchase_alertssystem_alerts
PATCH /api/auth/notification-preferences
Update one or more tenant notification settings.
Auth: Required
Body: Send only the fields you want to change.
{
"device_renewal": false,
"billing_reminder": false,
"payout_failed": true,
"purchase_alerts": false
}
Validation:
- each provided field must be boolean
Response 200:
{
"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:
{
"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_namepayment_phonepayment_provider
portal_payment_mode controls what the captive portal shows:
online_onlydirect_onlyboth
Auth: Required
Body:
{
"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:
{
"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:
{
"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:
{
"current_password": "OldPass123",
"new_password": "NewPass456"
}
Response 200:
{ "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:
{ "new_phone": "0754000111" }
Response 200:
{ "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:
{
"new_phone": "0754000111",
"otp": "847291"
}
Response 200:
{ "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:
[
{
"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 · removedbilling_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:
[
{
"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:
{
"name": "Main Building",
"mac": "24:2F:D0:DF:19:9C",
"location": "Floor 1",
"device_username": "admin",
"device_password": "MyCustomPass@123"
}
Notes:
nameandmacare required. All other fields are optional.- MAC is auto-normalized to
AA-BB-CC-DD-EE-FFformat. device_username/device_passwordare 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
trialbilling status (default 14-day trial).
Response 201:
{
"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:
{
"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/renewto reactivate.
The captive portal will show a "Renew Subscription" page to guests.
Tenant receives an SMS with the renewal amount.
Response 200:
{
"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:
{
"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_atby 30 days and setsbilling_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:
[
{
"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:
{
"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):
{
"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: 0to 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:
{ "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:
{ "message": "Plan permanently deleted" }
Error 409:
{ "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:
{
"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:
[
{
"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_salesgross=total_grossnet=online_net
online_net/net= onlinegrossminus Snippe fee (0.5%) minus platform commission.
Manual/direct sales are not payout-eligible and are not included innet.
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:
[
{
"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:
[
{
"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, andlast_seen_atmay benullif the customer paid but never successfully connected before the AP issue.- Use
client_mac, falling back totoken_codeorpayment_reference, as the customer identifier in the UI. - Use
payment_phoneto 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:
{
"message": "We are sorry for the outage. Your hotspot is being restored now."
}
Validation:
messagerequired- max length 320 characters
Response 200:
{
"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:
{
"minutes": 120,
"reason": "ISP outage compensation"
}
Validation:
minutesmust be a positive integer- max
minutesis10080(7 days) reasonoptional, max 255 characters
Response 200:
{
"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 devicelimit(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:
limitoptional, default50, max100include_voided=1optional to include voided manual test sales
Response 200:
[
{
"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:
limitoptional, default50, max100include_voided=1optional to include voided manual test sales
Response 200:
[
{
"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:
{
"device_id": 3,
"plan_id": 7,
"customer_phone": "0712345678",
"notes": "Paid via tenant Selcom QR"
}
Validation:
device_idrequiredplan_idrequiredcustomer_phonerequired 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
trialoractive - tenant account must have manual sales enabled in payment settings
Response 201:
{
"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:
{
"payment_id": 41,
"reference": "MAN-1A2B3C4D5E6F7G8H9J0K",
"token_id": 88,
"token_code": "ABCD-1234",
"status": "voided",
"removable": false,
"message": "Manual sale removed successfully"
}
Error 409:
{ "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:
[
{
"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:
{
"amount": 50000,
"phone": "0712345678",
"provider": "mpesa"
}
Validation:
amountminimum: 5,000 TZSamountmust not exceedbalanceprovideroptions:mpesa·airtel·tigo·halopesa(default:mpesa)
Response 201:
{
"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_alertspurchase_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:
[
{
"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:
{ "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:
{
"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:
[
{
"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:
[
{
"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:
[
{
"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:
[
{
"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:
[
{
"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:
{ "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:
{
"note": "Paid cash at office - 20,000 TZS" // optional
}
Response 200:
{
"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 addressapMac— AP MAC addressssidName— 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:
{
"planId": 7,
"phone": "0712345678"
}
Response 200:
{
"reference": "abc123def456ghi789jkl",
"status": "pending"
}
An M-Pesa payment prompt is sent to
phone. Poll/check/:referenceto track completion.
GET /portal/:siteId/check/:reference
Poll payment status after purchase.
Response — pending:
{ "status": "pending" }
Response — paid:
{
"status": "paid",
"code": "ABCD-1234",
"plan_name": "1 Hour Browsing"
}
Response — failed:
{ "status": "failed" }
POST /portal/:siteId/auth
Authorize a guest on the WiFi using their access code.
Body:
{
"code": "ABCD-1234",
"clientMac": "2a:61:d9:9b:90:7c"
}
Response 200:
{
"success": true,
"code": "ABCD-1234",
"expiresIn": 3600,
"expiresAt": "2026-04-15T13:00:00.000Z",
"speedDown": "5Mbps",
"speedUp": "2Mbps"
}
Error responses:
404— code not found403— code locked to a different device MAC410— code expired503— 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:
{ "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
onlineas green,offlineas red,adoptingas yellow spinner,pendingas grey billing_status = 'suspended'should show a prominent "Renew" CTA regardless ofstatus- Show
billing_expires_atwith a warning if within 3 days
Balance display:
balance= available to withdraw right nowtotal_earned - total_withdrawnshould equalbalance(use for sanity check display)- Show earnings chart from
revenue/weeklyon dashboard home
Payout flow:
- Show current
balancefromGET /api/auth/me POST /api/payouts— deducts balance immediately on submit- Poll or refresh
GET /api/payoutsto show updated status - Show
net_amount(after fee) prominently, notamount
Device renewal flow:
POST /api/devices/:id/renew- Inform user to check their phone for M-Pesa prompt
- Poll
GET /api/devices/:iduntilbilling_status = 'active'
Alert badge:
- Use
alert_countfromGET /api/dashboard/summaryfor 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_upare raw bytes — convert to MB/GB for displayduration_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 →
wifiicon, stroke = primary color - Success / authorized →
check-circleicon, stroke =#22c55e - Suspended / locked →
lockicon, stroke =#f59e0b - Error / failed →
x-circleicon, stroke =#ef4444 - Offline / disconnected →
wifi-officon, stroke =#9ca3af - Device / AP →
radioorroutericon - Payout →
sendorcredit-cardicon - Alert →
alert-triangleicon, stroke =#f59e0b
- WiFi / connected →