API Overview
RESTful API for the Net Manager ISP customer portal. Built incrementally — sections are added as each flow is confirmed.
Response format
Every response follows a consistent envelope:
{
"success": true,
"data": { ... },
"message": "Success message"
}
Auth on protected routes
Send the access token from login: Authorization: Bearer {access_token}
Currency
Every amount (invoices, fees, totals, rewards) is a whole-taka integer — no decimals, no paisa. 800 means ৳800, not ৳8.00. Applies across every category in this doc.
HTTP status codes — the rule for every endpoint in this doc
Two different kinds of "failure" get two different treatments. Get this right and almost nothing else about error handling needs repeating per endpoint.
| Code | When | Body shape |
|---|---|---|
| 200 | Request was valid and processed — including business outcomes like a declined payment or an already-closed ticket. The request succeeded even if the underlying action didn't. | { "success": true, "data": {...} } |
| 401 | Missing, expired, or invalid bearer token | { "success": false, "error": { "code": "UNAUTHORIZED", "message": "..." } } |
| 403 | Valid token, but not allowed to touch this resource (e.g. someone else's invoice) | Same error shape, code: "FORBIDDEN" |
| 404 | Path has an {id} that doesn't exist or doesn't belong to this user | Same error shape, code: "NOT_FOUND" |
| 422 | Request body failed validation (missing field, wrong type, out of range) | Same error shape, code: "VALIDATION_ERROR", plus a details object naming the bad field(s) |
| 409 | Blocked by the resource's current state — already paid, already claimed today, a repeated idempotency key. The action never started, unlike a 200 with status: "failed" where it did start and then resolved unsuccessfully. | Same error shape, e.g. code: "INVOICE_ALREADY_PAID" |
| 429 | Rate limit exceeded — see the note on Login, Register, Forgot Password, and Daily Spin | Same error shape, code: "RATE_LIMITED" |
| 500 | Something broke on the backend, not the client's fault | Same error shape, code: "INTERNAL_ERROR" |
| 503 | A downstream dependency is down — a payment gateway, the mikrotik proxy, etc. Not the client's fault, and not the same as a 500. | Same error shape, e.g. code: "GATEWAY_UNAVAILABLE" |
What individual endpoints still document
Only their specific 409/422 error codes (what does "already claimed" mean for this resource) and any 200-with-status:"failed" business outcomes. The HTTP codes and envelope shape themselves aren't repeated per endpoint — this table is the one source of truth for that.
All app screens covered
Every screen in the client UI now has a corresponding endpoint, across 11 categories. Two deliberate exceptions, not gaps: Shop is handled by a third-party provider, and Movies/TV/Tutorials are third-party media — neither is in scope for this backend.
Authentication
Login is customer ID + password only — no email. Passwords are always system-generated (4 digits) and delivered by SMS; there is no user-chosen password field anywhere in this flow.
Why /user/auth/login has no user yet
Every other authenticated route lives under /user/, and that convention was extended here deliberately, even though login/register/forgot-password all run before any session exists. It's not standard REST (there's no "user" to scope to at this point), but it's a one-line explanation, not a design flaw — flagging it so it doesn't read as an oversight in review.
Request Body
{
"customer_id": "CK2206",
"password": "1234"
}
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| customer_id | string | Required | Customer ID issued by the ISP |
| password | string | Required | 4-digit SMS password |
Response (200)
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600
}
}
No profile in this response
Login only returns tokens. Call GET /user/home/summary right after — it returns profile, connection status, and badge counts in one call.
Error Codes
| Code | Meaning |
|---|---|
| INVALID_CREDENTIALS | Wrong customer ID or password |
| ACCOUNT_SUSPENDED | Account is suspended |
| ACCOUNT_LOCKED | Too many failed login attempts |
Rate limit this one
Bad-password brute forcing is the obvious risk. Suggest: 5 attempts per customer_id per 15 minutes before ACCOUNT_LOCKED kicks in (already modeled above), plus a separate IP-based 429 limit so the same attacker can't just rotate customer_ids.
Sign-up collects only the customer ID. The backend generates a 4-digit password and sends it by SMS to the mobile number on file — the same delivery mechanism as forgot-password.
Request Body
{
"customer_id": "CK2206"
}
Response (200)
{
"success": true,
"message": "Password sent by SMS to your registered mobile number"
}
Note
Customer ID must already exist as an ISP subscriber. This links app access to an existing account — it does not create a new subscriber.
Rate limit this one — it costs real money per call
Every request sends an SMS. Without a limit, someone can spam a customer_id (or sweep many) and run up the SMS bill. Suggest: 3 requests per customer_id per hour, generic 429 beyond that.
Same shape and delivery as sign-up: customer ID in, a fresh 4-digit password sent by SMS.
Request Body
{
"customer_id": "CK2206"
}
Response (200)
{
"success": true,
"message": "Password sent by SMS to your registered mobile number"
}
Same SMS rate limit as Register
3 requests per customer_id per hour — same reasoning, same cost per call.
Fired from the Profile screen's "Log out" button, after the confirm dialog. Invalidates the current access/refresh token pair server-side — a client-side token wipe alone isn't enough.
Response (200)
{
"success": true,
"message": "Logged out successfully"
}
Home
Everything the Home screen renders on first paint: hero card, badge counts, reward balance, and the announcements feed.
Prayer times are external — no backend endpoint
The Home screen's prayer times card fetches directly from api.aladhan.com client-side. Nothing to build here. If that fetch fails, the app hides the entire prayer section instead of showing stale/offline data — that's a client-side behavior, not something the API needs to support.
Called once, right after login
Returns profile, live connection status, bill/ticket badge counts, and reward balance in a single round trip. Announcements are not included here — see GET /user/announcements below.
Response (200)
{
"success": true,
"data": {
"profile": {
"name": "Arif Hossain",
"customer_id": "CK2206",
"photo_url": null
},
"connection": {
"status": "online",
"package_name": "Premium 50 Mbps",
"download_speed": 48.6,
"upload_speed": 49.1,
"uptime": "2d 14h 23m",
"days_remaining": 12,
"usage_renews_date": "2026-06-30"
},
"bills_pending": 1,
"tickets_open": 2,
"rewards": {
"points_balance": 240,
"spin_available": true
}
}
}
days_remaining drives the hero progress bar
Percentage shown in-app is computed client-side from days_remaining against the package's billing cycle length — backend just returns the raw day count.
Called separately from the bootstrap call. Powers both the single highlighted notice strip and the full announcements list on Home.
Response (200)
{
"success": true,
"data": [
{
"id": "anc_uuid",
"type": "maintenance",
"title": "Scheduled maintenance",
"body": "Banani zone · 2:00–4:00 AM, 24 Jun",
"date": "2026-06-20",
"pinned": true
},
{
"id": "anc_uuid2",
"type": "promotion",
"title": "Eid promotional offer",
"body": "Faster BDIX & OTT for ৳1500/mo",
"date": "2026-06-18",
"pinned": false
}
]
}
pinned
The item with pinned: true is the one shown in the top notice strip. If none are pinned, the client falls back to the most recent.
Fired when the user taps "Spin to earn points" in the daily reward modal. One successful claim per calendar day.
Response (200)
{
"success": true,
"data": {
"points_earned": 20,
"points_balance": 260,
"spin_available": false,
"next_spin_available": "2026-08-04T00:00:00Z"
},
"message": "Today's reward has been added to your balance"
}
Error (409)
{
"success": false,
"error": {
"code": "REWARD_ALREADY_CLAIMED",
"message": "Come back tomorrow for another spin"
}
}
points_earned is decided server-side
The spin-wheel animation is cosmetic only — the client does not choose or send a point value. Backend rolls the amount and returns it; UI just animates to whatever points_earned comes back.
The daily cap is the rate limit
REWARD_ALREADY_CLAIMED already stops repeat farming once a day's spin is used. Only extra protection needed: a short burst limit (e.g. 10 requests/minute) so retry loops or scripted spam can't hammer the endpoint before the daily cap even kicks in.
Bills
Invoice list with status filters, and the full invoice detail view. Paying an invoice is a separate flow (Pay method → Pay summary) — not included here yet.
Powers the Bills tab: the total-outstanding card at the top and the filterable invoice list below it.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | Optional | all, paid, pending, overdue, partial — matches the filter chips |
| page | number | Optional | Default 1 |
| limit | number | Optional | Default 20 |
Response (200)
{
"success": true,
"data": {
"total_outstanding": 2000,
"billing_cycle_day": 25,
"next_due_date": "2026-06-25",
"stats": {
"total_paid": 1600,
"on_time_payments": 2,
"reward_points": 240
},
"invoices": [
{
"invoice_id": "inv_uuid_1",
"number": "INV-2606-0482",
"month": "June 2026",
"amount": 800,
"due_date": "2026-06-25",
"status": "pending",
"payable": true
},
{
"invoice_id": "inv_uuid_2",
"number": "INV-2605-0391",
"month": "May 2026",
"amount": 800,
"due_date": "2026-05-25",
"status": "paid",
"payable": false
},
{
"invoice_id": "inv_uuid_3",
"number": "INV-2604-0277",
"month": "April 2026",
"amount": 400,
"due_date": "2026-04-25",
"status": "partial",
"payable": true
}
],
"pagination": { "page": 1, "limit": 20, "total": 5 }
}
}
total_outstanding
Sum of every unpaid/partial invoice, not just the current month. Shown as one number on the purple summary card — backend computes it, client does not sum locally.
status values
Five states: paid, pending, overdue, partial, plus processing — set the moment POST /user/billing/payments/initiate is called for that invoice, cleared back to paid or its prior state once GET /user/billing/payments/{payment_id} resolves. payable is false for both paid and processing — stops a second payment starting while one is already in flight.
billing_cycle_day / next_due_date
Backs the "Your billing date is 25th, pay by 25 Jun 2026" reminder banner at the top of the Bills screen. next_due_date is the due date of the earliest payable invoice — same value the client would otherwise have derived itself, computed here instead so the banner and the invoice list can never disagree.
stats
Backs the 3-tile row under the summary card. total_paid sums every invoice with status paid — lifetime, not just this cycle. on_time_payments counts invoices paid on or before their due date; a payment made after due_date (even if the invoice never went overdue in the UI) does not count. reward_points is the same rewards.points_balance already returned by GET /user/home/summary — duplicated here so the Bills screen doesn't need a second call.
Opened via "View" on any invoice row. Full breakdown, customer info, and package snapshot at time of billing.
Response (200)
{
"success": true,
"data": {
"invoice_id": "inv_uuid_1",
"number": "INV-2606-0482",
"amount": 800,
"status": "pending",
"month": "June 2026",
"generated_date": "2026-06-01",
"due_date": "2026-06-25",
"package_name": "Premium 50 Mbps",
"customer": {
"name": "Arif Hossain",
"customer_id": "NM-48217",
"joining_date": "2024-01-12",
"mobile": "+880 1712-345678",
"zone": "Dhaka-North",
"sub_zone": "Banani-3"
},
"breakdown": [
{ "label": "Monthly package", "amount": 800 },
{ "label": "Static IP", "amount": 0 },
{ "label": "OTT subscription", "amount": 0 },
{ "label": "VAT (incl.)", "amount": 0 },
{ "label": "Discount", "amount": 0 }
],
"total_payable": 800,
"pdf_url": "https://nm.apnanetwork.net/backend/invoices/inv_uuid_1.pdf"
}
}
pdf_url
Backs the PDF download button next to "Pay now" on this screen. Return a signed/expiring URL if invoices are stored privately.
Payments
Pay method → Pay summary → success/failed. Real gateways (bKash, Nagad, SSLCommerz, card) don't confirm synchronously — they redirect the user out and confirm asynchronously. This is documented as a redirect + confirm flow, not a fake instant response.
Why not synchronous
The app cannot know a payment succeeded just because a request returned — the gateway confirms out-of-band (redirect callback, sometimes a webhook that lands seconds later). Treat initiate as "started," never as "done."
Powers the method-picker on the Pay screen. Each method carries its own gateway fee, added on top of the invoice amount.
Response (200)
{
"success": true,
"data": [
{ "method_id": "bkash", "name": "bKash", "type": "mobile_wallet", "fee": 12 },
{ "method_id": "nagad", "name": "Nagad", "type": "mobile_wallet", "fee": 10 },
{ "method_id": "rocket", "name": "Rocket", "type": "mobile_wallet", "fee": 14 },
{ "method_id": "ssl", "name": "SSLCommerz", "type": "gateway", "fee": 0 },
{ "method_id": "visa", "name": "Visa", "type": "card", "fee": 16 }
]
}
fee
Flat currency amount, not a percentage — matches how the Pay Summary screen shows "Invoice amount + Gateway fee = Total." If fees ever become percentage-based, add a fee_type field rather than overloading fee.
Fired when the user taps "Pay ৳X" on the Pay Summary screen, or "Pay all dues" on the Bills screen — same endpoint, one or many invoices. Returns a gateway URL — the app opens it in an in-app browser / webview, it is not rendered inline.
Request Body
{
"invoice_ids": ["inv_uuid_1"],
"method_id": "bkash",
"return_url": "netmanager://payment/callback",
"idempotency_key": "a1b2c3d4-e5f6-7890-client-generated"
}
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| invoice_ids | string[] | Required | One ID for a single "Pay now", every pending/overdue/partial ID for "Pay all dues" |
| method_id | string | Required | From GET /user/billing/payment-methods |
| return_url | string | Required | App deep link the gateway redirects back to |
| idempotency_key | string | Required | Client-generated UUID, one per checkout attempt |
Response (200)
{
"success": true,
"data": {
"payment_id": "pay_uuid",
"status": "pending",
"gateway_url": "https://checkout.bkash.com/pay/pay_uuid",
"invoice_ids": ["inv_uuid_1"],
"amount": 800,
"fee": 12,
"total": 812
}
}
Errors
| HTTP | Code | Meaning |
|---|---|---|
| 409 | INVOICE_ALREADY_PAID | One or more invoice_ids is already settled |
| 503 | GATEWAY_UNAVAILABLE | Selected method is temporarily down — client should offer another method |
| 409 | DUPLICATE_REQUEST | idempotency_key was already used — return the original payment_id/status instead of starting a new charge |
idempotency_key prevents double-charging
A double-tap or retry-on-bad-network sends the same key twice. Backend returns the original payment session both times rather than opening two gateway charges. Generate a new key per checkout attempt, not per request.
return_url
A deep link back into the app (not a web URL). The gateway redirects here after the user finishes on their site, with payment_id attached as a query param. The app must not trust that redirect alone — see the confirm step below.
Always call this after the redirect back
The deep link landing is a hint, not proof. The app calls this endpoint to get the authoritative status before showing the Success or Failed screen — the query string on the redirect is never trusted directly. If the gateway's own webhook hasn't landed yet, return pending and let the client poll every 2–3s for up to ~30s.
Response (200) — success
{
"success": true,
"data": {
"payment_id": "pay_uuid",
"status": "success",
"transaction_id": "TXN90441823",
"amount": 800,
"fee": 12,
"total": 812,
"invoice_ids": ["inv_uuid_1"],
"invoice_numbers": ["INV-2606-0482"],
"paid_at": "2026-06-22T12:25:00Z"
}
}
Response (200) — failed
{
"success": true,
"data": {
"payment_id": "pay_uuid",
"status": "failed",
"error_code": "INSUFFICIENT_BALANCE",
"error_message": "Insufficient wallet balance",
"invoice_ids": ["inv_uuid_1"]
}
}
Response (200) — still pending
{
"success": true,
"data": {
"payment_id": "pay_uuid",
"status": "pending"
}
}
status values
pending · success · failed. The Success screen's receipt (transaction ID, date, amount, invoice number) reads directly from this response — no separate receipt endpoint needed. The Failed screen's error banner reads error_message.
Reachable from the Profile screen's account menu. Every past transaction attempt — including failed ones, not just successful payments.
Response (200)
{
"success": true,
"data": [
{
"payment_id": "pay_uuid_1",
"method_id": "bkash",
"method_name": "bKash",
"amount": 800,
"status": "success",
"paid_at": "2026-05-25T10:00:00Z"
},
{
"payment_id": "pay_uuid_2",
"method_id": "nagad",
"method_name": "Nagad",
"amount": 800,
"status": "failed",
"paid_at": "2026-04-25T14:20:00Z"
}
]
}
Support
Support home (stats, FAQ, ticket list), the 3-step new-ticket wizard, and ticket detail chat.
WhatsApp / call numbers live in Settings, not here
The Contact Support buttons need a phone number and WhatsApp number — those come from GET /user/settings (see the Settings category) so support can update them without an app release. Nothing to build in this category for that.
The three tiles at the top of the Support screen.
Response (200)
{
"success": true,
"data": {
"open_count": 1,
"resolved_count": 12,
"avg_reply_minutes": 18
}
}
Populates the category dropdown on step 1 of New Ticket. Fixed set of 8 in the current UI.
Response (200)
{
"success": true,
"data": [
{ "category_id": "noint", "label": "No Internet" },
{ "category_id": "billing", "label": "Billing Issue" },
{ "category_id": "package", "label": "Package Change" },
{ "category_id": "reloc", "label": "Relocation" },
{ "category_id": "router", "label": "Router Problem" },
{ "category_id": "fiber", "label": "Fiber Problem" },
{ "category_id": "device", "label": "Device Issue" },
{ "category_id": "other", "label": "Other" }
]
}
Powers the collapsible FAQ accordion on the Support home screen.
Response (200)
{
"success": true,
"data": [
{
"faq_id": "faq_1",
"question": "How do I restart my router?",
"answer": "Turn it off for 30 seconds, then power it back on and wait 2 minutes."
},
{
"faq_id": "faq_2",
"question": "Why is my internet slow?",
"answer": "Check router placement, connected devices, and run diagnostics before opening a ticket."
}
]
}
Bangla text
The app shows English and Bangla for every question/answer. Either return both language fields (question_bn, answer_bn) or handle translation client-side — confirm which with the app team before building.
"Your tickets" list on the Support home screen.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| page | number | Optional | Default 1 |
| limit | number | Optional | Default 20 |
Response (200)
{
"success": true,
"data": {
"tickets": [
{
"ticket_id": "tkt_uuid",
"number": "TKT-2606-118",
"category": "Slow Internet",
"status": "in_progress",
"priority": "high",
"created_at": "2026-06-22T09:12:00Z"
}
],
"pagination": { "page": 1, "limit": 20, "total": 13 }
}
}
Paginated — a customer's ticket history only grows
Same shape as invoice pagination. The Support home screen only needs the first page for its list preview; older tickets stay reachable but never come back unbounded in one response.
status / priority values
status: open, in_progress, resolved, closed. priority: low, medium, high — drives the colored dot next to each ticket row.
Submitted at the end of the 3-step wizard (category → description → optional photo). multipart/form-data when a photo is attached.
Form Data
| Field | Type | Required | Description |
|---|---|---|---|
| category_id | string | Required | From GET /user/support/categories |
| description | string | Required | Max 500 characters — matches the app's counter |
| attachment | file | Optional | PNG/JPG, max 10 MB |
Response (200)
{
"success": true,
"data": {
"ticket_id": "tkt_uuid",
"number": "TKT-2608-203",
"status": "open",
"eta_minutes": 18
},
"message": "Ticket created"
}
eta_minutes
Backs "An engineer will respond within 18 minutes" on the success screen. If this can't be computed live, a static category-based default is fine — just don't hardcode 18 in the client.
Polled, not pushed
No WebSocket — this is a slow back-and-forth, not live chat. The app re-calls this endpoint (e.g. on screen focus / a short interval) to pick up new engineer replies. A short delay before a reply appears is acceptable.
Response (200)
{
"success": true,
"data": {
"ticket_id": "tkt_uuid",
"number": "TKT-2606-118",
"category": "Slow Internet",
"status": "in_progress",
"priority": "high",
"engineer": {
"name": "Rakib Khan",
"role": "Field Engineer",
"expected_resolution": "2026-06-22T16:00:00Z"
},
"messages": [
{ "message_id": "msg_1", "from": "customer", "text": "My internet has been very slow since this morning.", "sent_at": "2026-06-22T11:58:00Z" },
{ "message_id": "msg_2", "from": "engineer", "text": "Checking the OLT port for your zone now.", "sent_at": "2026-06-22T12:04:00Z" }
]
}
}
Fired from the chat input at the bottom of Ticket Detail.
Request Body
{
"text": "Still no internet after restarting the router."
}
Response (200)
{
"success": true,
"data": {
"message_id": "msg_3",
"from": "customer",
"text": "Still no internet after restarting the router.",
"sent_at": "2026-06-22T12:10:00Z"
}
}
Profile
Personal Info (view + inline edit, one field at a time) and Network Info (read-only device/session snapshot). Change Password lives in Authentication; Log Out too — see that category.
Push / language / dark mode / brand color
These toggles on the Profile screen route through GET/PATCH /user/settings — see the Settings category, not duplicated here.
Full detail for the Personal Info screen.
Response (200)
{
"success": true,
"data": {
"name": "Arif Hossain",
"joining_date": "2024-01-12",
"nid_number": "1990 1234 5678",
"mobile": "+880 1712-345678",
"email": "arif.hossain@gmail.com",
"address": "House 14, Road 7, Banani",
"zone": "Dhaka-North",
"sub_zone": "Banani-3",
"photo_url": null
}
}
Editable vs read-only
name, joining_date, and nid_number are ISP/KYC-controlled — the UI shows them plain, no edit affordance. Only mobile, email, address, zone, sub_zone are user-editable.
The UI edits one field at a time (tap → inline edit → save) — the client sends only the field that changed, backend merges it into the existing record.
Request Body
{
"mobile": "+880 1712-345678"
}
Response (200)
{
"success": true,
"data": {
"mobile": "+880 1712-345678"
},
"message": "Profile updated"
}
PATCH, not PUT
Partial by design — matches the one-field-at-a-time save. A PUT would force the client to resend every field on every save, risking a stale overwrite of fields it isn't touching.
Requirements
Content-Type: multipart/form-data · max file size 5MB · JPG or PNG
Response (200)
{
"success": true,
"data": {
"photo_url": "https://nm.apnanetwork.net/media/profile/user_uuid.jpg"
},
"message": "Photo updated"
}
Read-only device/session snapshot for the Network Info screen. Not the same data as /user/mikrotik-api/status — that's speed and package, this is device identity and session.
Response (200)
{
"success": true,
"data": {
"ip_address": "103.108.42.7",
"mac_address": "A4:5E:60:C2:1B:9F",
"device_vendor": "TP-Link AX23",
"current_session": "6h 12m",
"last_login": "2026-06-22T06:13:00Z",
"connection_status": "active"
}
}
Settings
The toggles on the Profile screen: push notifications, dark mode, language, brand color. Support's contact numbers (WhatsApp/phone) also live here.
Response (200)
{
"success": true,
"data": {
"push_enabled": true,
"dark_mode": false,
"language": "en",
"brand_color_primary": "#862BAE",
"brand_color_secondary": "#FFE128",
"support_contact": {
"phone": "+8801712345678",
"whatsapp": "+8801712345678"
}
}
}
support_contact
Backs the Contact Support buttons in the Support tab. Changeable here without an app release.
Same partial-update pattern as PATCH /user/profile — each toggle fires its own request with just the one field that changed.
Request Body
{
"dark_mode": true
}
Response (200)
{
"success": true,
"data": { "dark_mode": true },
"message": "Settings updated"
}
Network
Realtime traffic, diagnostics, and speed test — the three action screens on top of the connection status already covered in Home.
Polled, not pushed
The home hero graph and the Realtime Traffic screen redraw continuously — call this roughly once a second and let the client keep its own rolling window. A WebSocket push at /user/mikrotik-api/traffic/stream is fine too if that's easier on your side, but polling is an acceptable default.
Response (200)
{
"success": true,
"data": {
"timestamp": "2026-08-03T10:30:12Z",
"download_mbps": 42.7,
"upload_mbps": 8.3
}
}
The 7-check diagnostic run, offered before a support ticket is raised.
Response (200)
{
"success": true,
"data": {
"overall": "healthy",
"checks": [
{ "name": "Router reachability", "status": "pass" },
{ "name": "ISP gateway", "status": "pass" },
{ "name": "DNS resolution", "status": "pass" },
{ "name": "Internet connectivity", "status": "pass" },
{ "name": "Bandwidth test", "status": "pass" },
{ "name": "Packet loss", "status": "warn", "detail": "0.2%" },
{ "name": "Network stability", "status": "pass" }
]
}
}
Synchronous is fine here — the app already shows a "Testing…" state while it waits.
Response (200)
{
"success": true,
"data": {
"download_mbps": 48.6,
"upload_mbps": 49.1,
"ping_ms": 12,
"jitter_ms": 3,
"server": "Banani-3 node"
}
}
Packages
Package list, detail, and the upgrade request flow — reachable from Bills.
Response (200)
{
"success": true,
"data": [
{
"package_id": "pkg_30",
"name": "30 Mbps",
"tag": "Starter",
"price": 600,
"is_current": false,
"features": ["BDIX & FTP access", "SD / HD streaming", "Email support"]
},
{
"package_id": "pkg_50",
"name": "50 Mbps",
"tag": "Premium",
"price": 800,
"is_current": true,
"features": ["BDIX + all caches", "HD streaming", "IPTV access"]
}
]
}
No pagination — deliberately unbounded
This is the ISP's plan catalog, not user data. It's a short, operator-controlled list (a handful of tiers), not something that grows per-user. Return the full list every time.
Response (200)
{
"success": true,
"data": {
"package_id": "pkg_50",
"name": "50 Mbps",
"tag": "Premium",
"price": 800,
"is_current": true,
"features": ["BDIX + all caches", "HD streaming", "IPTV access"],
"details": {
"bdix_speed": "500 Mbps",
"youtube": "Cached",
"ott": "1 service",
"priority": "Standard"
}
}
}
Upgrade Review screen's confirm action. This is a request, not an instant switch — an ISP typically actions it within a business day.
Request Body
{
"package_id": "pkg_80"
}
Response (200)
{
"success": true,
"data": {
"request_id": "req_uuid",
"package_id": "pkg_80",
"status": "pending_approval"
},
"message": "Package change requested"
}
Notifications
The bell icon on Home. Distinct from Announcements — these are personal (your invoice, your ticket reply), not broadcast.
Query Parameters
| Parameter | Description |
|---|---|
| category | all, billing, support, package, promotion — matches the filter chips |
| page | Default 1 |
| limit | Default 20 |
Response (200)
{
"success": true,
"data": {
"notifications": [
{
"id": "ntf_uuid",
"category": "billing",
"title": "Invoice due in 3 days",
"body": "Your June invoice of ৳800 is due 25 Jun.",
"read": false,
"created_at": "2026-06-22T08:00:00Z"
},
{
"id": "ntf_uuid2",
"category": "support",
"title": "Engineer replied to TKT-118",
"body": "Rakib: I've rebalanced the node, please restart.",
"read": false,
"created_at": "2026-06-22T06:00:00Z"
}
],
"unread_count": 2,
"pagination": { "page": 1, "limit": 20, "total": 47 }
}
}
Paginated — same reasoning as tickets
unread_count is a global total, not scoped to the current page — it always reflects every unread notification, not just the ones in this response.
Response (200)
{
"success": true,
"data": { "id": "ntf_uuid", "read": true }
}
Response (200)
{
"success": true,
"message": "All notifications marked read"
}
Response (200)
{
"success": true,
"message": "Notification deleted"
}
Referral
One screen, one endpoint. Code, stats, and the 3-step how-it-works copy.
Response (200)
{
"success": true,
"data": {
"referral_code": "ARIF200",
"successful_referrals": 3,
"credit_earned": 600,
"credit_per_referral": 200
}
}
The 3-step "how it works" copy is static
Share code → friend activates → credit applied. Fixed marketing copy, not per-user data — safe to keep client-side, no fields needed for it here.