WifiBiz / API_DOCUMENTATION.md
Mbonea's picture
Require SMS number for manual sales
918afce

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
  2. Tenant — Devices
  3. Tenant — WiFi Plans
  4. Tenant — Dashboard
  5. Tenant — Sessions
  6. Tenant — Manual Sales
  7. Tenant — Payouts
  8. Tenant — Alerts
  9. Super Admin
  10. Captive Portal (Public)
  11. Error Format
  12. Enums & Constants
  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:

{
  "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:

{
  "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:

{
  "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_password minimum 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_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.

{
  "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_name
  • payment_phone
  • payment_provider

portal_payment_mode controls what the captive portal shows:

  • online_only
  • direct_only
  • both

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 · 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:

[
  {
    "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:

  • 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:

{
  "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/renew to 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_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:

[
  {
    "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: 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:

{ "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_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:

[
  {
    "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, 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:

{
  "message": "We are sorry for the outage. Your hotspot is being restored now."
}

Validation:

  • message required
  • 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:

  • minutes must be a positive integer
  • max minutes is 10080 (7 days)
  • reason optional, 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 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:

[
  {
    "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:

[
  {
    "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_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:

{
  "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:

  • amount minimum: 5,000 TZS
  • amount must not exceed balance
  • provider options: 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_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:

[
  {
    "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 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:

{
  "planId": 7,
  "phone":  "0712345678"
}

Response 200:

{
  "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:

{ "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 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:

{ "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. 36001h, 864001 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