Waste Management ( D'FAYER COMPANY)
← Back to site
Contents

WasteBN API v1#

The mobile-app interface to WasteBN. Three audiences: customers (the portal as JSON), drivers (the driver panel as JSON), and website shop customers (the /shop website as JSON, under /webshop — see section 7). Staff have no API — the admin panel is a session-and-CSRF browser application, and issuing it tokens would create a second, weaker way in.

Everything here goes through the same models, services and scoping rules as the browser surfaces. An API is a second door onto one application, not a second application: if a rule holds on the website it holds here, and where the two differ it is written down below and the reason is given.

  • Base URL — https://your-domain/api/v1
  • Format — JSON in, JSON out. Send Accept: application/json on every request; without it Laravel may answer a validation failure with a redirect instead of a 422.
  • Auth — bearer tokens (Laravel Sanctum).
  • Postman — the collection in docs/postman/ has a request for every endpoint below. Set baseUrl, run a login request, and the token is saved for the rest of the folder.

Contents#

  1. Getting a token
  2. Conventions — errors, pagination, dates, money, language
  3. Account
  4. Customer endpoints
  5. Driver endpoints — round sheet, home, schedule, history, vehicle and fault reports, profile, user guide
  6. Push notifications
  7. Website shop — its own accounts, catalogue and orders
  8. Enumerations
  9. Endpoint index

1. Getting a token#

POST /login#

Phone number or email in the login field — customers know one or the other and should not have to guess which we hold.

{
  "login": "+673 8123456",
  "password": "their-password",
  "device_name": "Awang's iPhone"
}

device_name is optional but worth sending: it is what the customer sees if they ever sign out everywhere, and "app-x7f2q1" tells them nothing.

200

{
  "token": "17|OoZ3k…",
  "user": {
    "id": 42,
    "name": "Awang bin Ahmad",
    "email": "awang@example.com",
    "phone": "+673 8123456",
    "role": "customer",
    "customer_code": "CUST-00042",
    "profile_status": "approved",
    "driver_id": null
  }
}

role is customer or driver, and it decides which half of this document applies. driver_id is set only for crew; customer_code and profile_status only for customers (see Profile completion and approval).

Send the token on every later request:

Authorization: Bearer 17|OoZ3k…

Throttling — 10 requests per minute per IP, and separately per login identifier, so one attacker hammering an address cannot lock everyone out. A 422 with login in errors means wrong credentials, an inactive account, or a staff account (staff cannot use the app at all).

POST /refresh#

Exchanges the current token for a new one and deletes the old one. A leaked token therefore stops working the moment the real device refreshes. Store the new token before the response leaves your handler; the old one is already dead. The response has the same { token, user } shape as login.

POST /logout / POST /logout-everywhere#

The first revokes the calling token. The second revokes every token the user has — the "I lost my phone" button. Both answer { "message": "…" }.

When the office deactivates an account#

Setting a customer or driver to inactive in the admin panel revokes every token the account holds at that moment. The next request — including POST /refresh — gets 401, and signing in again gets the 422 for an inactive account. Treat a 401 as "back to the login screen", whatever the reason.


2. Conventions#

Errors#

Status Meaning What the app should do
401 No token, or the token has been revoked Send the user to the login screen
403 Wrong audience — a customer token on a driver route, or an inactive driver Do not retry; this will never succeed
403 + profile_status A customer the office has not approved yet (see Profile completion and approval) Show where the review stands; retry after approval
404 Not found or not yours Treat as "gone"; see the note below
422 Validation failed, or the action is not allowed in this state Show message, or field errors from errors
429 Throttled Back off; Retry-After says how long

A 422 carries Laravel's standard shape:

{
  "message": "The description field is required.",
  "errors": { "description": ["The description field is required."] }
}

On 404 vs 403 for other people's records: asking for an invoice that belongs to somebody else returns 404, not 403. A 403 would confirm the record exists, which is itself a leak — an id that is not yours simply does not exist as far as the API is concerned.

Pagination#

Paginated endpoints return Laravel's standard paginator. The rows are in data; the rest is navigation.

{
  "data": [ … ],
  "current_page": 1,
  "last_page": 4,
  "per_page": 20,
  "total": 74,
  "next_page_url": "https://…/api/v1/bills?page=2",
  "prev_page_url": null
}

Pass ?page=2. Page size is fixed at 20 (products, orders and notifications included).

A few endpoints nest the paginator under a key when they also return a summary — /rewards and /notifications do. Those are called out below.

Dates, money, language#

  • Timestamps are ISO 8601 with an offset: 2026-08-11T09:15:00+08:00.
  • Dates without a time — a collection day, a billing month — are plain YYYY-MM-DD. They are dates, not midnight in some timezone.
  • Money is a JSON number in Brunei dollars: 45.5 means BND 45.50. Never a formatted string, so the app can do arithmetic on it and format it in the user's locale.
  • Language — send Accept-Language: ms (or en). Server-generated messages, validation errors and status words come back translated. There is no session or cookie on a token request, so the header is the only signal; an unknown language falls back to English rather than failing.

3. Account#

POST /register#

Opens a customer account and returns a token, so the app never has to ask for the password twice.

{
  "name": "Awang bin Ahmad",
  "phone": "+673 8123456",
  "email": "awang@example.com",
  "password": "at-least-8-characters",
  "password_confirmation": "at-least-8-characters",
  "customer_type": "municipal",
  "district": "brunei_muara",
  "address_line_1": "No. 12, Simpang 45, Jalan Muara",
  "address_line_2": null,
  "mukim": "Mukim Gadong",
  "postcode": "BB1234",
  "terms": true,
  "device_name": "Awang's iPhone"
}

email, address_line_2, mukim, postcode and device_name are optional; the rest are required. phone and email must not already be on an account (a 422 says to sign in instead), and terms must be true. 201 with { token, user }, the same shape as login. Throttled to 5 per minute per IP.

The rules are not the same as the web form's any more — see below.

Profile completion and approval#

Every customer profile carries a profile_status, and it is worth understanding before building a sign-up screen, because the website and the API open accounts in two different shapes.

incomplete An account exists; the office does not yet have everything it needs.
pending The customer has finished and is waiting to be checked. This is the office's queue.
approved Checked and accepted.
rejected Something was wrong. The customer is shown a reason and can correct it.

The website registers with a phone number and a password, and nothing else. No SMS is sent. The name, IC number, address, mukim, postcode, district and account type are collected afterwards, on a two-step completion screen — the details, then an optional pair of photographs of the customer's identity card, taken with the device camera and cropped in the browser. Sending that second step is what moves the profile to pending.

This API still registers the old way: POST /register takes the details up front and creates the profile in one call. What it cannot do is finish one. There is no endpoint that submits a profile for review, and the IC photographs are a browser feature — they need a camera, a crop and a canvas, none of which this API models.

So an account opened through POST /register stays incomplete, and stays out of the office's queue, until somebody signs into the web portal with it and finishes there. If your app opens accounts, send the customer to the portal once to complete the profile, or expect the office to be chasing them.

profile_status gates the account. Until it is approved, a customer token reaches only GET /me, GET /dashboard, the notification endpoints and GET|PUT /profile. Everything else — bills, payments, bins, collections, recycling, rewards, tickets, service requests, the shop, orders and quotes — answers 403:

{
  "message": "Your account is waiting for approval by our team. You can use this once it is approved.",
  "profile_status": "pending"
}

The status is returned as profile_status on the user object (login, register, GET /me), on customer in GET /dashboard, and on GET /profile, which also carries rejection_reason — show it, it is the office telling the customer what to fix.

An approved account goes back to pending — and is locked again — when the customer changes their details: see PUT /profile below.

Accounts the office creates in the admin panel start approved.

PUT /password#

{
  "current_password": "…",
  "password": "…",
  "password_confirmation": "…"
}

Every other token is revoked; the caller's token survives. A password change is usually a reaction to "somebody else may have my password", and leaving the other sessions alive would answer that with nothing.

POST /forgot-password / POST /reset-password#

{ "email": "awang@example.com" }

Answers 200 with the same message whether or not the address is on an account — a differing response is a way to find out who our customers are. The one exception is 429 when a link was asked for that address too recently. The email contains a token; post it back with the new password:

{
  "token": "from-the-email-link",
  "email": "awang@example.com",
  "password": "…",
  "password_confirmation": "…"
}

A successful reset revokes every token for that account: the link went to their email, so anything still signed in with the old password should not stay that way. An expired or wrong token is a 422 with a message.

GET /me#

{ "user": { … } } — the same user object as the login response. Cheap; use it to check a stored token is still good on app start.

GET /profile · PUT /profile#

{
  "name": "Awang bin Ahmad",
  "email": "awang@example.com",
  "ic_number": "01-234567",
  "address_line_1": "No. 12, Simpang 45",
  "address_line_2": null,
  "mukim": "Mukim Gadong",
  "postcode": "BB1234"
}

District, customer type and phone are not editable here. They affect billing and collection routing, so they go through support — the same rule as the web portal.

GET also returns customer_code, profile_status, rejection_reason, customer_type, district and ic_front_url / ic_back_url (null when no photograph was sent). ic_number and email may be left out of PUT, which keeps the stored value; when sent, neither may be empty — an email address is required on the account, because that is where the review emails go. The IC photographs are read-only here — they are taken on the portal's camera screen (My Profile → Update IC photos).

If anything actually changes on an approved profile, it goes back to pending for the office to review, the customer is emailed, and the account is locked (see above) until it is approved again. The response message says so. Saving the same values again changes nothing. A profile that is already pending stays where it is in the queue.


4. Customer endpoints#

All require a customer token. A driver token gets 403, and so does a customer whose profile is not approved — except the dashboard, notification and profile endpoints.

Dashboard — GET /dashboard#

One call for the home screen: outstanding balance, reward points, active bin count, open tickets, the current plan, and the next collection.

{
  "customer": { "name": "…", "customer_code": "CUST-00042",
                "profile_status": "approved", "rejection_reason": null, "district": "brunei_muara",
                "outstanding_balance": 45.0, "reward_points": 320 },
  "stats": { "active_bins": 2, "open_tickets": 1, "unpaid_invoices": 1 },
  "plan": { "name": "Standard", "monthly_price": 25.0, "bin_quota": 2 },
  "next_collection": { "id": 918, "scheduled_date": "2026-08-13",
                       "time_window": "6:00am – 12:00pm", "bin_number": "BIN-00031" }
}

plan and next_collection are null when there is none. A customer with no plan is billed per bin — that is normal, not an error.

Bills — GET /bills#

Paginated, newest billing month first. Optional ?status=pending|partial|paid|overdue|cancelled.

{ "id": 311, "invoice_number": "INV-2026-09-0042", "billing_month": "2026-09-01",
  "total_amount": 45.0, "paid_amount": 0.0, "balance_due": 45.0,
  "due_date": "2026-09-30", "status": "pending" }

balance_due is included so the app never has to subtract two decimals itself.

One bill — GET /bills/{invoice}#

The list row plus items[] (description, amount) and payments[] against that invoice, newest first, plus what the bill page needs for a bank transfer:

{
  "id": 311,
  "invoice_number": "INV-2026-09-0042",
  "billing_month": "2026-09-01",
  "total_amount": 45.0,
  "paid_amount": 0.0,
  "balance_due": 45.0,
  "due_date": "2026-09-30",
  "status": "pending",
  "items": [{ "description": "Standard plan — September", "amount": 45.0 }],
  "payments": [],
  "bank_transfer": { "bank_name": "BIBD", "account_name": "Waste Management Sdn Bhd",
                     "account_number": "00-123-456789", "note": "" },
  "pending_transfer": null
}

bank_transfer is null when the office is not taking transfers online — show no transfer form then. pending_transfer is the claim already waiting to be checked (a payment row, below), or null.

A payment row, here and in GET /payments:

{ "id": 77, "reference": "CLM-20260925101500-311-AB12", "amount": 45.0,
  "gateway": "bank_transfer", "status": "pending", "completed_at": null,
  "bank_reference": "FT2609250001", "paid_on": "2026-09-25", "rejection_reason": null }

bank_reference and paid_on are only set on transfer claims. rejection_reason is the office's note when it sent a claim back (status rejected) — show it, so the customer knows what to fix.

Printable invoice — GET /bills/{invoice}/document#

The A4 invoice with the company logo and signature — the portal's View invoice button, and the same document the office prints.

200 → { "url": "https://your-domain/invoice/311?expires=…&signature=…", "expires_at": "2026-09-25T10:45:00+08:00" }

Open url in the system browser (or a webview), where the customer can print it or save it as a PDF. It needs no token: the signature is the credential, so it only opens that one invoice, and it expires after 30 minutes — call this each time the button is tapped rather than storing the link.

Telling us about a bank transfer — POST /bills/{invoice}/transfer#

After paying from their banking app, the customer tells us so the office can match it against the bank statement. Send multipart/form-data when attaching a receipt, JSON otherwise:

Field
bank_reference Required. The transfer reference from their bank, max 100
paid_on Required. YYYY-MM-DD, not in the future
customer_note Optional, max 500
receipt Optional. JPG, PNG, WebP or PDF, up to 5 MB

There is no amount field. The claim is always for what is still owed on the bill (balance_due); anything sent as amount is ignored.

201 → { "message": "Thank you — reference CLM-…", "payment": { …payment row, "status": "pending" } }

Nothing is credited by this call. The bill stays open, and the balance does not change, until the office confirms the transfer — then the payment becomes completed and the customer gets a notification. If the office cannot find it, it becomes rejected with a rejection_reason, and the customer may send a new claim.

422 — transfers are switched off, the bill is already paid or cancelled, or a claim for this bill is still waiting to be checked (one at a time).

Paying — GET /payment-gateways, POST /bills/{invoice}/pay#

{ "data": [{ "key": "bibd", "label": "BIBD Online" }] }

The list is empty until online payments are switched on and a bank driver is registered. Show no Pay button when it is empty rather than a button that leads nowhere.

{ "gateway": "bibd" }

200 → { "gateway": "bibd", "amount": 45.0, "redirect_url": "https://…" }. Open redirect_url in a browser or webview.

Nothing is credited by this call. The money is posted by the bank's server-to-server callback, because a client saying "I paid" is not evidence. After the user returns, re-fetch the invoice; if the callback has not landed yet the balance will still be outstanding, and that is correct. A callback for a zero or negative amount, or for a bill the office has cancelled in the meantime, credits nothing — that money is sorted out by the office by hand.

422 — nothing left to pay, or that gateway is not available.

Payments — GET /payments#

Paginated history of payments, in the payment-row shape above — including transfer claims still pending and any the office rejected.

Bins — GET /bins#

Not paginated; a customer has a handful.

{ "data": [{ "id": 31, "bin_number": "BIN-00031", "bin_type": "240L", "waste_type": "general",
             "collection_frequency": "weekly", "collection_days": ["mon", "thu"], "status": "active" }] }

collection_days is an array of short day keys.

Service plan#

The customer's regular service — the plan card at the top of My Bins. The customer says what they want serviced and how often; the office answers with the actual days, the monthly price and a start date, and sends the first month's invoice. Paying that invoice starts the service.

GET /service-plan What can be asked for, and where this customer's plans stand
POST /service-plan Ask for a plan
POST /service-plan/{servicePlan}/cancel Withdraw a plan that has not started
{
  "service_items": [
    { "id": 1, "name": "240L wheelie bin", "description": null, "price": 25.0 }
  ],
  "active_plan": null,
  "open_plan": {
    "id": 9, "reference": "SVP-00009", "status": "awaiting_payment",
    "period": "weekly", "times_per_period": 2,
    "preferred_days": ["mon", "thu"], "schedule_days": ["mon", "thu"],
    "monthly_price": 45.0, "starts_on": "2026-10-01",
    "customer_note": "Bins are behind the gate.", "staff_note": null,
    "items": [{ "service_item_id": 1, "name": "240L wheelie bin", "quantity": 2 }],
    "invoice": { "id": 311, "invoice_number": "INV-2026-09-0042", "status": "pending" },
    "can_cancel": true
  },
  "rejected_plan": null,
  "limits": { "periods": ["weekly", "monthly"], "max_times": { "weekly": 7, "monthly": 12 },
              "weekdays": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] }
}
  • price on a service item is a guide only — say so in the app. The office sets monthly_price.
  • open_plan is one waiting for the office (pending) or for its first payment (awaiting_payment). When invoice is set and unpaid, offer a Pay button that goes to that bill.
  • schedule_days are set by the office: weekday keys for a weekly plan, days of the month (1–31) for a monthly one.
  • rejected_plan is the newest plan only if the office refused it; staff_note says why.

Asking for a plan:

{
  "items": [{ "id": 1, "quantity": 2 }, { "id": 3 }],
  "period": "weekly",
  "times_per_period": 2,
  "preferred_days": ["mon", "thu"],
  "customer_note": "Bins are behind the gate."
}

quantity defaults to 1 (max 50); the same item listed twice is merged. times_per_period is at most 7 a week or 12 a month. preferred_days only counts for a weekly plan. Items that are no longer offered are dropped, and a request with none left is a 422 on items.

201 → { "message": "Request SVP-00009 sent. …", "plan": { … } }

422 — the customer already has an open plan; withdraw it first. Cancelling works until money has been paid against the first invoice (which is cancelled with it); after that it returns 422 and the customer has to contact us. A new plan replaces the current one from the day it starts.

Collections — GET /collections#

Paginated pickup history, newest first. status is one of scheduled · completed · missed · skipped.

{ "id": 918, "scheduled_date": "2026-08-13", "time_window": "6:00am – 12:00pm",
  "status": "completed", "completed_at": "2026-08-13T08:41:07+08:00",
  "bin_number": "BIN-00031", "notes": null }

bin_number is null for a one-off job booked as a service request.

Recycling — GET /recycling#

Weighed waste and the diversion rate.

{ "total_kg": 128.4, "diverted_kg": 96.2, "landfill_kg": 32.2,
  "diversion_rate": 74.9, "by_type": { "recyclable": 80.1, "organic": 16.1, "general": 32.2 } }

Hazardous waste counts in the denominator but not as diverted — it is special disposal, not diversion, and counting it would inflate the number we quote to the customer's own auditor.

Rewards — GET /rewards#

{
  "balance": 320,
  "earned": 480,
  "redeemed": 160,
  "transactions": { "data": [ … ], "current_page": 1, … }
}

Note the nesting: the summary is top-level, the ledger is a paginator under transactions.

Complaints#

GET /tickets Paginated list
POST /tickets Raise one
GET /tickets/{ticket} Detail with the message thread
POST /tickets/{ticket}/reply Add a reply
{
  "type": "missed_collection",
  "subject": "Bin not collected on Monday",
  "message": "At least ten characters describing what happened.",
  "collection_id": 918
}

subject is 5–255 characters and message 10–2000. collection_id is optional and must be one of the customer's own pickups — pointing it at somebody else's is a validation error, not a 404. Naming the collection saves the agent matching a date against the round by hand.

201 → { "id": 57, "ticket_number": "TKT-00057", "status": "open" }. A missed_collection ticket is raised at high priority, everything else at medium.

A list row is id, ticket_number, subject, type, status, priority and created_at. The detail adds assigned_to (the staff member's name, or null), resolution_notes and the thread:

"messages": [
  { "id": 301, "message": "Bin not collected on Monday.", "from_staff": false,
    "author": "Awang bin Ahmad", "created_at": "2026-08-11T09:15:00+08:00" }
]

The thread is oldest first and never includes internal staff notes. from_staff tells the app which side to draw each message on.

A reply is { "message": "…" }, 2–2000 characters; 201 with a message. Replying to a resolved or closed ticket returns 422; open a new one. A reply to a ticket that was waiting on the customer moves it back to in_progress automatically.

Service requests#

GET /service-requests Paginated list
POST /service-requests Book a one-off job
POST /service-requests/{serviceRequest}/cancel Withdraw it
{
  "type": "bulky",
  "preferred_date": "2026-08-20",
  "description": "One double mattress and a broken washing machine, at the back gate."
}

preferred_date is optional, cannot be in the past, and is a preference, not a booking — staff confirm the real date. description is required (10–1000 characters) and should be specific: it decides which vehicle is sent.

201 → { "id": 24, "reference": "SRQ-00024", "status": "pending" }

A list row:

{ "id": 24, "reference": "SRQ-00024", "type": "bulky", "description": "…",
  "status": "scheduled", "preferred_date": "2026-08-20",
  "scheduled_date": "2026-08-21", "charge_amount": 30.0 }

charge_amount is null until the office prices the job.

Cancelling is allowed while the request is pending or approved. Once scheduled it returns 422 — a job already on a crew's sheet has to be taken off it by staff, or somebody drives to a job nobody wants.

Shop#

GET /shop/products Paginated catalogue. ?search= and ?category=
GET /shop/products/{product} One product, with every image and up to four related ones
GET /shop/categories { "data": [{ "id": 1, "name": "Bins", "image": "https://…webp" }] }, in the order the office set. image is null when the category has none
POST /orders Place an order
GET /orders Paginated order history
GET /orders/{order} Order with its lines

There is no basket endpoint. The web basket lives in the session, and a token client has no session to keep it in. The app holds its own list and posts the whole order at once:

{
  "items": [
    { "product_id": 3, "quantity": 2 },
    { "product_id": 7, "quantity": 1 }
  ],
  "delivery_type": "delivery",
  "delivery_address": "No. 12, Simpang 45, Jalan Muara"
}

Up to 50 lines, each quantity 1–100. delivery_address is required when delivery_type is delivery. The same product listed twice is merged into one line of the combined quantity — before the stock check, so two lines of the same product cannot each pass it on their own. A product that has been switched off is a 422 on its items.*.product_id.

Prices are never accepted from the client. Send product ids and quantities; the server reads the price from the catalogue at the moment of sale and copies it onto the line, so a later price change cannot rewrite what the customer was charged. Any price in the request is ignored.

Out-of-stock products are still listed — a customer looking for a 660L bin should learn that we sell one — with "in_stock": false, after the in-stock ones. Ordering more than stock_quantity returns 422 naming the product, under errors.items. A product row:

{ "id": 3, "name": "Wheelie Bin 240L", "sku": "BIN-240", "description": "…",
  "category": "Bins", "category_id": 1, "price": 45.0, "unit": "pcs",
  "image": "https://your-domain/storage/products/…webp",
  "in_stock": true, "stock_quantity": 12, "reward_points_value": 45 }

GET /shop/products/{product} answers { "product": { …row, "images": ["https://…", "…"] }, "related": [ …up to four rows ] }. related is other products from the same category, in-stock first. A product the office has switched off returns 404.

201 → { "message": "…", "order": { "id": 12, "order_number": "ORD-00012", "status": "pending", "total_amount": 90.0 } }

An order list row is id, order_number, status, total_amount, items_count, delivery_type and created_at. GET /orders/{order} adds payment_status, delivery_address and the lines — the name and price as they were when the order was placed:

"items": [{ "product_name": "Wheelie Bin 240L", "price": 45.0, "quantity": 2, "subtotal": 90.0 }]

Orders arrive pending, and stock is not reserved until staff confirm. A customer's order is a request, not a promise the depot can fill it. Say so in the app; the web checkout does.

Quotes#

GET /quotes This customer's own enquiries
POST /quotes Ask for a quote

Only service_type is required. Name, email, phone, district and address default to the account's details, so an app can send one field — but all of them can be overridden, because the enquiry may be about a second property or name a different contact.

Field
service_type Required, max 100
name, email, phone, district, address Optional; default to the account's
company, ic_number Optional
bin_size Optional — 120L · 240L · 660L · Custom
collection_frequency Optional — daily · 3x_week · 2x_week · weekly
message Optional, max 2000

201 → { "message": "…", "quote": { "id": 8, "status": "new" } }. The list is paginated: id, service_type, district, address, status and created_at.

User guide — GET /guide#

The Help & User Guide from the portal (/my-account/help), for an in-app help screen. No token needed: it is written content, not account data, and the articles about signing in are read by people who cannot yet. Throttled to 60/min per IP.

Send Accept-Language: ms for Bahasa Melayu; anything else gets English.

{
  "updated": "2026-09-23",
  "popular": ["billing-pay-bill", "collections-missed-collection", "…"],
  "sections": [
    {
      "key": "billing",
      "id": "billing",
      "title": "Bills & payments",
      "description": "Reading your invoices, paying them and checking what you have paid.",
      "articles": [
        {
          "key": "pay_bill",
          "id": "billing-pay-bill",
          "title": "How do I pay a bill?",
          "summary": "Open the bill and follow the options under **How to pay**. …",
          "steps": ["Open **My Bills** and tap the bill you want to pay …", "…"],
          "tips": ["Online payment through BIBD and Baiduri is being set up …"],
          "keywords": "pay payment online bank bibd baiduri …",
          "screen": "bills",
          "web_url": "https://your-domain/my-account/bills"
        }
      ]
    }
  ]
}
  • Order is meaning. Sections and articles come in the order to show them; popular lists article ids for a shortcuts row.
  • Bold is marked **like this**, around the names of buttons and menus. Render it as bold, or strip the asterisks — the text is never HTML.
  • steps and tips may be empty arrays.
  • screen names the app screen an article is about, so a "Go to this page" button can open it. null when the article has no screen (signing in, for example). The values are: dashboard, bills, payments, bins, collections, tickets, tickets.create, serviceRequests, serviceRequests.create, shop, shop.cart, orders, rewards, notifications, profile, profile.complete, profile.complete.ic. An app that does not have one of these screens can open web_url instead.
  • id matches the anchor on the web page, so https://your-domain/my-account/help#billing-pay-bill and an in-app deep link can name the same article.
  • keywords are extra search terms (synonyms, what people call things). Search locally across title, summary, steps, tips and keywords.
  • Caching. The guide changes only when the server is updated. Responses carry an ETag; send it back as If-None-Match and an unchanged guide answers 304 Not Modified with no body. updated is the date the content last changed, for showing "Last updated" to the reader.

5. Driver endpoints#

All under /driver, all require a driver token. A customer token gets 403, and so does a driver whose crew record is inactive. (The one exception is the user guide, GET /driver-guide, which is public — see the end of this section.)

These are the driver panel (/driver in a browser) as JSON. Both read the same service, so the app and the phone browser always show the same numbers. Everything is scoped to the signed-in driver: another crew's stop or round answers 404, never 403, so the response does not confirm it exists.

Drivers sign in with the same POST /login as customers; role in the response is driver and driver_id is set. They change their password with PUT /password, like anyone else.

Location is required — the X-Driver-Location header#

Every write under /driver (every POST and PATCH) must carry the phone's position, and it is recorded against what was saved — the stop marked, the fuel logged, the shift started. Send it as a header, which works the same for JSON and multipart bodies:

X-Driver-Location: 4.8903125,114.9401820,12,3

Latitude, longitude, the fix's accuracy in metres, and the fix's age in seconds (how long ago the phone took it). Accuracy and age are optional but send both: the office sees the accuracy, and a fix older than 120 seconds is refused like a missing one — a position from before GPS was switched off does not count. The form fields geo_lat, geo_lng, geo_accuracy and geo_age are accepted too; the driver panel's pages use those.

A write without a usable position is refused with 422 and nothing is saved:

{ "message": "Your location is needed. Turn on location (GPS) on your phone and try again.", "errors": { "location": ["…"] } }

0,0 and out-of-range values count as missing. Reads (GET) do not need it. Ask for location permission at sign-in and keep the app from being used without it, the way the web panel does: lock the screens while location services are off or permission is withdrawn, and check again whenever the app returns to the foreground.

Live position — POST /driver/location#

While the driver is on shift, send the position every minute or so (more often is wasted; skip it when the truck has not moved 25 m, but send at least every five minutes). The body can be empty — the header is the whole request. Answers 204. This is the trail the office sees on its driver map; positions are kept for 90 days. Unlike a web page, an app can keep doing this in the background, which is the main reason to prefer it for tracking.

GPS turned off — POST /driver/location/lost#

When location goes off (or permission is withdrawn) during a shift, send this once with the last fix the phone had in the header, including its age — here the age may be anything; the position is recorded at the time of the fix. The body can be empty; optional reason (denied or unavailable). Answers 204. The office's driver map shows the driver in red with GPS turned off until a new position arrives. Send it again only after GPS has come back and gone off again.

The round sheet#

GET /driver/today#

Optional ?date=YYYY-MM-DD for another day — the crew sometimes needs yesterday's sheet to finish a note.

Returns the driver's own stops with the customer, address, phone and bin, plus a progress count. Stops come in driving order — the route's stop sequence, then customer, then bin — the same order as the web round sheet. A date that cannot be read falls back to today.

{
  "date": "2026-09-30",
  "summary": { "total": 42, "done": 17, "remaining": 24, "missed": 1 },
  "stops": [
    {
      "id": 9812,
      "status": "scheduled",
      "route": "Gadong Monday",
      "bin_number": "BIN-00412",
      "bin_type": "240L",
      "waste_type": "general",
      "special_job": false,
      "service_reference": null,
      "customer": { "name": "Awang bin Ahmad", "phone": "+673 8123456",
                    "customer_code": "CUST-00042", "address": "Block A, Aman Complex, Gadong A" },
      "notes": null,
      "photo_path": null,
      "weights": [{ "waste_type": "general", "weight_kg": 34.5 }]
    }
  ]
}

A one-off job (special_job: true) has no bin — bin_number, bin_type and waste_type are null and service_reference names the service request.

PATCH /driver/collections/{collection}#

{ "status": "completed", "notes": "Bin blocked by a parked car." }

status is completed, missed or skipped. notes is optional (max 1000); leaving it out keeps the stop's existing note.

200 → { "id": 9812, "status": "completed", "completed_at": "2026-09-30T08:41:07+08:00" }

Marking a stop that is not on this driver's sheet returns 404.

This goes through the same CollectionService as the web round sheet, so a stop marked from the app produces exactly the same record, the same customer notification and the same service-request completion as one marked in a browser.

POST /driver/collections/{collection}/photo#

multipart/form-data, field photo, image, max 4 MB. Returns { "photo_path": "/uploads/collections/…" }. Uploading again replaces the earlier photo.

POST /driver/collections/{collection}/weight#

{ "waste_type": "recyclable", "weight_kg": 12.5, "disposal_site": "Sungai Paku" }

waste_type is a waste record type; weight_kg 0–100000; disposal_site optional, max 120.

200 → { "id": 501, "waste_type": "recyclable", "weight_kg": 12.5, "diverted": true }

A separate call from marking the stop on purpose: the weight is read at the tip, the stop is marked at the kerb. Tying them together would mean no stop could be marked until its weight was known.

Recyclable and organic weights earn the customer reward points automatically. Correcting a weight adjusts the points by the difference rather than awarding them twice.

Home — GET /driver/dashboard#

The home screen: today's progress, the next stop, today's rounds, the week so far and the licence warning.

{
  "driver_status": "active",
  "today": { "total": 42, "done": 17, "remaining": 24, "missed": 1 },
  "next_stop": {
    "id": 9812,
    "bin_number": "BIN-00412",
    "special_job": false,
    "customer": { "name": "Awang bin Ahmad", "phone": "+673 8123456", "address": "Block A, Aman Complex, Gadong A" }
  },
  "today_rounds": [
    { "id": 3, "name": "Gadong Monday", "stops_count": 38, "vehicle": "BAB 1234" }
  ],
  "week": { "completed": 160, "missed": 4, "weight_kg": 5120.5 },
  "licence": { "expires_on": "2026-10-15", "days_left": 15, "warning": true }
}
  • next_stop is the first stop still scheduled today, in driving order, or null when there is none.
  • week runs from Monday to today. missed counts missed and skipped together — both are stops that were not collected.
  • licence.days_left is negative once the licence has expired, and null when no expiry date is on file. warning is true from 30 days before expiry. Show it prominently: the office cannot roster an unlicensed driver.
  • driver_status is active or on_leave (an inactive driver never gets this far). A driver on leave can still sign in; show them a notice.

Schedule — GET /driver/schedule#

The rounds this driver runs each week, and how many stops each of the next 14 days actually holds.

{
  "rounds": [
    { "id": 3, "name": "Gadong Monday", "day_of_week": "mon", "stops_count": 38, "vehicle": "BAB 1234" }
  ],
  "days": [
    { "date": "2026-09-30", "total": 42, "done": 17, "special": 2 },
    { "date": "2026-10-01", "total": 0, "done": 0, "special": 0 }
  ]
}
  • rounds are the plan, ordered Monday to Sunday. days are what the nightly schedule has actually put on the sheet, including one-off jobs and stops moved over from another crew, so the two need not agree.
  • days always has 14 entries starting today, including empty days. special counts one-off jobs (no bin). Open a day with GET /driver/today?date=….

One round — GET /driver/routes/{routePlan}#

A weekly round and its stops in driving order.

{
  "id": 3,
  "name": "Gadong Monday",
  "day_of_week": "mon",
  "district": "brunei_muara",
  "mukim": "Gadong A",
  "notes": null,
  "vehicle": "BAB 1234",
  "stops": [
    {
      "sequence": 1,
      "note": "Bins behind the shop",
      "customer": { "name": "Awang bin Ahmad", "phone": "+673 8123456", "customer_code": "CUST-00042", "address": "Block A, Aman Complex, Gadong A" }
    }
  ]
}

Another driver's round answers 404.

History — GET /driver/history#

The driver's own stops, a month at a time, newest first. Query parameters:

month YYYY-MM; defaults to the current month
status optional — one collection status, to list only those
page page number
{
  "month": "2026-09",
  "summary": { "total": 610, "completed": 598, "missed": 8, "skipped": 4, "kg": 20480.5 },
  "data": [
    {
      "id": 9812,
      "scheduled_date": "2026-09-29",
      "status": "completed",
      "completed_at": "2026-09-29T08:41:07+08:00",
      "bin_number": "BIN-00412",
      "special_job": false,
      "service_reference": null,
      "customer": { "name": "Awang bin Ahmad", "address": "Block A, Aman Complex, Gadong A" },
      "notes": null,
      "photo_path": "/uploads/collections/…",
      "weight_kg": 34.5
    }
  ],
  "meta": { "current_page": 1, "last_page": 25, "per_page": 25, "total": 610 }
}
  • History never goes past today; future stops are on the schedule.
  • summary covers the whole month and ignores status, so filter chips can show a count each. kg is everything weighed on the month's stops.

Vehicle — GET /driver/vehicle#

The trucks this driver works with, and their own recent fault reports.

There is no "assigned vehicle" on a driver: a vehicle belongs to a round, and each day's pickups carry the vehicle they went out on. So the list is today's pickups' vehicle first (today: true), then the vehicles on the driver's active weekly rounds. It is empty when neither has one.

{
  "vehicles": [
    {
      "id": 7,
      "plate_number": "BAB 1234",
      "type": "Compactor",
      "capacity_kg": 5000,
      "status": "active",
      "today": true,
      "road_tax_expiry": "2027-01-31",
      "insurance_expiry": "2026-10-20",
      "permit_expiry": "2027-06-30",
      "recent_service": [
        { "type": "repair", "performed_at": "2026-09-12", "workshop": "Syarikat Bengkel", "note": "New brake pads" }
      ]
    }
  ],
  "issues": [ { "…": "the same shape as POST /driver/vehicle/issues returns" } ]
}
  • type is the vehicle type's name, as the office keeps it on Operations → Vehicle Types — display text, ready to show, not a fixed key. The office can add and rename types, so do not match on it. (Before October 2026 this was a key such as compactor.) null when the vehicle has no type.
  • recent_service is the last five workshop visits. Costs are not included — they are the office's business.
  • Warn when road_tax_expiry, insurance_expiry or permit_expiry is past, or within 30 days. Any one of them lapsed means the truck should not be on the road. Each is null when not recorded.
  • issues is this driver's ten most recent fault reports, newest first.

Reporting a truck problem — POST /driver/vehicle/issues#

multipart/form-data when sending a photo, JSON otherwise.

Field
vehicle_id required — one of the ids from GET /driver/vehicle
category required — a vehicle issue category
severity required — minor (can still be driven) or urgent (not safe to drive)
description required, up to 2000 characters
photo optional image, max 6 MB

201

{
  "id": 51,
  "vehicle_id": 7,
  "plate_number": "BAB 1234",
  "category": "brakes",
  "severity": "urgent",
  "description": "Brake pedal goes to the floor.",
  "photo_path": "/uploads/vehicle-issues/…",
  "status": "open",
  "resolution_note": null,
  "reported_at": "2026-09-30T07:12:44+08:00",
  "resolved_at": null
}
  • A vehicle_id that is not one of this driver's trucks is a 422 on vehicle_id. Drivers report on the trucks they work with, not the whole fleet.
  • The report reaches the office at once — the admin bell and, if connected, the office Telegram group. For an urgent report, prompt the driver to phone the operations desk as well.
  • The office moves it through open → in_progress → resolved and may write a resolution_note, which is meant for the driver. Show both.

Profile — GET /driver/profile#

The crew record behind the login — what GET /me does not carry.

{
  "name": "Hj Zainal",
  "phone": "+673 8881234",
  "email": null,
  "photo_url": null,
  "emergency_contact_name": null,
  "emergency_contact_phone": null,
  "license_number": "BN-99881",
  "license_expiry": "2027-03-01",
  "license_pending": null,
  "status": "active"
}

photo_url and the emergency contact are null until set. license_pending is the renewal the driver has sent in and the office has not checked yet, or null:

"license_pending": { "license_number": "BN-99881", "license_expiry": "2031-03-01", "submitted_at": "2026-09-30T08:15:00+08:00" }

Changing the profile — POST /driver/profile#

multipart/form-data when sending a photo. Returns the profile, as above.

Field
name required
phone required, unique — this is the sign-in
current_password required only when phone changes
email optional, unique
emergency_contact_name, emergency_contact_phone optional
photo optional image, max 6 MB
remove_photo optional, true to clear the photo

Renewing the licence — POST /driver/profile/licence#

multipart/form-data: license_number, license_expiry (a future date) and license_photo (image, max 8 MB), all required. Answers 202 with the profile: the renewal sits in license_pending until the office checks the photo and accepts it, and only then replaces license_number and license_expiry. The licence-expiry warnings keep counting the licence on file until then. Sending again before it is checked replaces the pending one.

Fuel log — GET /driver/fuel · POST /driver/fuel#

GET returns vehicles (every truck in service, the driver's own first — use it for the picker) and data, the driver's last 30 fill-ups:

{
  "id": 12, "vehicle_id": 7, "plate_number": "BAB 1234", "filled_on": "2026-09-30",
  "litres": 85.5, "amount": 45.32, "odometer": 120450, "station": "Shell Gadong",
  "note": null, "receipt_url": "/uploads/driver-documents/…", "booked": false
}

POST (multipart/form-data for the receipt) takes vehicle_id, filled_on (today or up to 60 days back), litres, amount (BND), and optionally odometer, station, note and receipt (image, max 8 MB). 201 with the row. booked turns true once the office has entered it in the accounts as an expense against the truck.

Daily vehicle check — GET /driver/checks · POST /driver/checks#

GET returns items (the checklist keys, in order), vehicles and data, the last 30 checks. A check row:

{
  "id": 22, "vehicle_id": 7, "plate_number": "BAB 1234", "checked_on": "2026-09-30",
  "odometer": 120450, "items": { "brakes": "ok", "tyres": "fault", "…": "ok" },
  "faults": ["tyres"], "safe_to_drive": true, "note": "Front left tyre worn"
}

Sending a check (vehicle_id from vehicles; odometer and note optional):

{
  "vehicle_id": 7,
  "odometer": 120450,
  "items": { "brakes": "ok", "tyres": "fault", "lights": "ok" },
  "safe_to_drive": true,
  "note": "Front left tyre worn"
}
  • Each item is ok or fault; an item left out counts as ok.
  • safe_to_drive is required. Every fault is also filed as a truck problem (see POST /driver/vehicle/issues), one per problem category, urgent when safe_to_drive is false. A check marked unsafe with no faults still files one urgent problem. So the office hears about it the same way whichever way the driver reports it.
  • 201 with the check, including faults (the keys marked fault).

Shifts — GET /driver/shifts · POST /driver/shifts/start · POST /driver/shifts/end#

GET returns open (the shift in progress, or null), vehicles and data (the last 30 finished shifts):

{
  "id": 40, "vehicle_id": 7, "plate_number": "BAB 1234",
  "started_at": "2026-09-30T06:02:11+08:00", "ended_at": null,
  "start_odometer": 120400, "end_odometer": null,
  "hours": 3.5, "kilometres": null, "note": null
}
  • start takes optional vehicle_id (defaults to the truck on today's round) and start_odometer. 422 on shift if a shift is already open.
  • end takes optional end_odometer and note. 422 on shift if none is open, or on end_odometer if it is lower than the start reading.
  • hours runs to now while the shift is open. kilometres is null unless both odometer readings were given.

Leave — GET /driver/leave · POST /driver/leave · POST /driver/leave/{leaveRequest}/cancel#

GET returns types and data, the driver's last 30 requests:

{
  "id": 5, "starts_on": "2026-10-12", "ends_on": "2026-10-16", "days": 5,
  "type": "annual", "reason": "Family visit", "status": "pending", "office_note": null
}
  • POST takes starts_on (today or later), ends_on (on or after starts_on, and within a year), type (a leave type) and optional reason. 201 with the request. 422 on starts_on if it overlaps a pending or approved request.
  • The office approves or declines, optionally with an office_note for the driver. An approved leave sets the driver's status to on_leave for its days and back to active the day after.
  • cancel withdraws a request still pending and answers with it, now cancelled; any other status is a 422 on leave. Another driver's request is a 404.

Driver user guide — GET /driver-guide#

The Help page from the driver panel (/driver/help), for an in-app help screen. No token needed — "I cannot sign in" is read by someone who cannot. Throttled to 60/min per IP. Send Accept-Language: ms for Bahasa Melayu.

The response has exactly the same shape as GET /guide (see section 4), so one help-screen component can render either. Only the screen values differ; for the driver guide they are: dashboard, today, schedule, history, shifts, check, fuel, vehicle, leave, profile. An app without one of these screens can open web_url instead. ETag / If-None-Match work the same way.


6. Push notifications#

Registering a device — POST /device-token#

{ "token": "fcm-registration-token", "platform": "android" }

token is the FCM registration token (at least 32 characters). platform is android, ios or web, and defaults to web when left out — so send it. An optional device_name labels the device; without one it is guessed from the User-Agent. Open to both customers and drivers. Registering the same token again updates it rather than adding a second row — and moves it to the account that registered it last, so a shared phone only notifies whoever is signed in.

200 → { "registered": true, "id": 14, "platform": "android" }

DELETE /device-token with { "token": "…" } on sign-out → { "removed": true } (false when the token was not registered).

The inbox#

A push may never arrive — no app installed, notifications declined, a dead token. Every message therefore also lands in a durable inbox, which is what the portal bell shows. Show this list in the app too; it is the only copy that always exists.

GET /notifications ?unread=1 to filter
POST /notifications/{notification}/read
POST /notifications/read-all
GET /notification-preferences
PUT /notification-preferences
{
  "unread_count": 3,
  "notifications": { "data": [
    { "id": 88, "type": "collection", "title": "Collection missed",
      "body": "We could not collect your bin on 11/08/2026. Tap to report a problem.",
      "link": "https://…/my-account/collections", "image_url": null,
      "read": false, "created_at": "2026-08-11T09:15:00+08:00" }
  ], "current_page": 1, … }
}

title and body are written by the server; dates inside them read dd/mm/yyyy. Marking one read answers { "message": "Marked as read." }; read-all adds marked, the number changed. Another customer's notification id is a 404.

Preferences are three switches — billing, collection, promotion. GET returns the switches with a translated label for each, ready to draw:

{
  "categories": [
    { "key": "billing", "label": "Bills and payments", "description": "New invoices, due-date reminders and payment receipts." },
    { "key": "collection", "label": "Collections", "description": "…" },
    { "key": "promotion", "label": "Offers and news", "description": "…" }
  ],
  "preferences": { "billing": true, "collection": true, "promotion": false }
}

All three must be sent on PUT, which answers with message and the saved preferences:

{ "billing": true, "collection": true, "promotion": false }

They are opt-out: a category the customer has never touched is on. A muted category is not delivered and not stored, so it will not appear in the inbox either. Account and service notices (type: "general", type: "reward") have no switch and are always delivered — muting "we are suspending your collection" is not a preference we offer.


7. Website shop#

The public website shop at /shop, for a shop app. It is a separate module with its own accounts: a website shop customer is not a portal customer, has no bins, bills or approval step, and signs in with an email and password of its own. A portal or driver login does not work here, and the reverse.

  • Separate tokens. A token from /webshop/login or /webshop/register works only on the /webshop endpoints. Sent anywhere else it gets 401, and a portal or driver token sent to a /webshop endpoint that needs a sign-in gets 401 too.
  • One catalogue. The products, categories, prices and stock are the ones the office manages on Shop → Products, the same as the portal shop's.
  • No guest checkout. Browsing is open; placing an order needs an account.
  • Nothing is paid online. Orders arrive pending; the office confirms them, and the customer pays on collection or delivery.

Accounts#

POST /webshop/register Open an account. Returns a token
POST /webshop/login Sign in. Returns a token
POST /webshop/forgot-password Email a reset link
POST /webshop/reset-password Choose a new password with the emailed token
GET /webshop/me The signed-in customer
PUT /webshop/me Update their details
PUT /webshop/password Change the password
POST /webshop/logout Revoke this device's token

Registering:

{
  "name": "Siti Aminah",
  "email": "siti@example.com",
  "phone": "+673 8123456",
  "password": "at-least-8-chars",
  "password_confirmation": "at-least-8-chars",
  "device_name": "Siti's iPhone"
}

All fields but device_name are required. The account is active at once — there is nothing for the office to approve. An email that already has a shop account is a 422 on email. Emails are stored lower-case.

Signing in: { "email": "…", "password": "…", "device_name": "…" }.

200 / 201

{
  "token": "31|pQ8x…",
  "customer": {
    "id": 7, "name": "Siti Aminah", "email": "siti@example.com", "phone": "+673 8123456",
    "address": null, "city": null, "postcode": null, "full_address": "",
    "created_at": "2026-09-26T10:15:00+08:00"
  }
}

Register (201) also carries a welcome message. GET /webshop/me answers { "customer": { … } } in the same shape, and PUT /webshop/me answers with message and the updated customer.

A 422 on email means wrong credentials, a blocked account, or too many attempts (5 wrong passwords per email and IP, counted together with the web sign-in form). Send the token as Authorization: Bearer ….

PUT /webshop/me takes name, email, phone (required) and address, city, postcode (optional). full_address joins the three, ready to prefill a delivery address. email is optional — and may be sent as null to clear it — on an account linked to a My Account login, since that account signs in through the portal and may have come from a phone sign-up.

PUT /webshop/password takes current_password, password and password_confirmation, and signs out every other device.

Resetting a password. forgot-password answers the same way whether or not the email has an account (429 if asked again too soon). The email links to the shop's reset page on the website, where the customer can finish; an app that opens that link itself can post its token with email, password and password_confirmation to reset-password instead. A reset signs out every device.

Blocked accounts. When the office blocks a customer on Shop → Website Customers, or deletes the account, every token it holds is revoked at once — the next request gets 401. Signing in again returns the 422 above.

Catalogue#

No token needed. Throttled to 60/min per IP.

GET /webshop/products Paginated. Filters below
GET /webshop/products/{product} One product, with every image and up to four related ones
GET /webshop/categories Categories that have products, with count and lowest price

Query parameters for /webshop/products, all optional:

category Category id
search Matches name and SKU
sort featured (default) · newest · price_asc · price_desc · name
min, max Price range, in BND
in_stock 1 for in-stock products only
on_sale 1 for discounted products only
per_page 1–50, default 20

In-stock products always come first, whatever the sort.

{
  "id": 3,
  "name": "Wheelie Bin 240L",
  "description": "…",
  "category": "Bins",
  "category_id": 1,
  "unit": "pcs",
  "price": 40.0,
  "was_price": 45.0,
  "discount": 11,
  "stock_quantity": 12,
  "image": "https://your-domain/storage/products/…webp"
}

price is what the customer pays — the sale price when there is one. was_price is the normal price when the product is discounted, otherwise null.

GET /webshop/products/{product} answers { "product": { …card, "sku": "BIN-240", "images": ["https://…", "…"] }, "related": [ …up to four cards ] }. A product the office has switched off returns 404.

/webshop/categories → { "data": [{ "id": 1, "name": "Bins", "description": null, "image": "https://your-domain/storage/categories/…webp", "count": 4, "from_price": 12.5 }] }, in the order the office set. image is null when the category has none.

Orders#

Need a shop token.

GET /webshop/orders Paginated, newest first
POST /webshop/orders Place an order
GET /webshop/orders/{order} One order with its lines
POST /webshop/orders/{order}/cancel Cancel it while it is still pending

There is no basket endpoint, as with the portal app's orders: the app keeps its own list and posts the whole order at once.

{
  "items": [
    { "product_id": 3, "quantity": 2 },
    { "product_id": 7, "quantity": 1 }
  ],
  "delivery_type": "delivery",
  "delivery_address": "No. 12, Simpang 45, Jalan Muara",
  "phone": "+673 8123456",
  "notes": "Please call before delivering."
}
  • quantity is 1–100 per line. The same product twice is merged into one line.
  • delivery_address is required when delivery_type is delivery.
  • phone defaults to the account's phone; notes is optional.
  • Prices are never accepted from the client. The server reads each price from the catalogue when the order is placed and copies it onto the line.

201

{
  "message": "Thank you! Your order WEB-00012 has been placed. …",
  "order": {
    "id": 12, "order_number": "WEB-00012", "status": "pending", "payment_status": "pending",
    "delivery_type": "delivery", "delivery_address": "No. 12, Simpang 45, Jalan Muara",
    "phone": "+673 8123456", "notes": "Please call before delivering.",
    "total_amount": 90.0, "can_cancel": true, "created_at": "2026-09-26T10:20:00+08:00",
    "items": [{ "product_id": 3, "name": "Wheelie Bin 240L", "quantity": 2, "price": 40.0, "subtotal": 80.0 }]
  }
}

The customer and the office are emailed, the same as a website checkout. A product that is switched off, or asked for in a larger quantity than is in stock, is a 422 on basket naming the problem; nothing is ordered.

Stock is not set aside until the office confirms the order. The customer may cancel only while the order is pending (can_cancel); after that the cancel endpoint returns 422 and they have to call. Another customer's order is 404.

payment_status is pending until the office records the payment (paid) on collection or delivery. Website shop orders earn no reward points — those belong to portal customers.


8. Enumerations#

Stored values, which is what the API sends and accepts. Display names are translated server-side wherever the API renders them; if the app renders its own, translate these keys in the app.

Set Values
District brunei_muara · tutong · belait · temburong
Customer type municipal · commercial · industrial
Invoice status pending · partial · paid · overdue · cancelled
Payment status pending · completed · rejected · failed · refunded
Payment gateway bank_transfer · cash · plus the online gateway keys listed by GET /payment-gateways
Collection status scheduled · completed · missed · skipped
Ticket status open · in_progress · pending_customer · resolved · closed
Ticket priority low · medium · high · urgent
Ticket type missed_collection · bin_request · billing · service_change · general
Order status pending · confirmed · processing · shipped · delivered · cancelled
Service request type bulky · extra_pickup · bin_repair · bin_replacement
Service request status pending · approved · scheduled · completed · declined · cancelled
Waste record type general · recyclable · organic · hazardous
Bin waste type general · recyclable · organic
Bin status active · inactive · damaged · lost
Profile status incomplete · pending · approved · rejected
Delivery type pickup · delivery
Service plan status pending · awaiting_payment · active · ended · rejected · cancelled
Service plan period weekly · monthly
Order payment status pending · paid · failed
Notification type collection · service_request · invoice_due · payment_confirmed · promotion · reward · general
Driver status active · on_leave · inactive
Round day mon · tue · wed · thu · fri · sat · sun
Vehicle status active · maintenance · retired
Maintenance type service · repair · inspection · tyres · other
Vehicle issue category engine · brakes · tyres · lights · hydraulics · bodywork · other
Vehicle issue severity minor · urgent
Vehicle issue status open · in_progress · resolved
Vehicle check item brakes · tyres · lights · mirrors · fluids · hydraulics · body · safety_gear
Leave type annual · sick · emergency · unpaid · other
Leave status pending · approved · rejected · cancelled

9. Endpoint index#

Rate limits: login and webshop/login 10/min; register, webshop/register and both password-reset pairs 5/min; guide, driver-guide and the website shop catalogue 60/min; everything authenticated 120/min. All per IP.

Public#

Method Path
POST /login
POST /register
POST /forgot-password
POST /reset-password
GET /guide
GET /driver-guide

Any signed-in user#

Method Path
GET /me
PUT /password
POST /refresh
POST /logout
POST /logout-everywhere
POST /device-token
DELETE /device-token

Customer#

Method Path
GET /dashboard
GET /bills
GET /bills/{invoice}
POST /bills/{invoice}/pay
POST /bills/{invoice}/transfer
GET /bills/{invoice}/document
GET /payment-gateways
GET /payments
GET /bins
GET /service-plan
POST /service-plan
POST /service-plan/{servicePlan}/cancel
GET /collections
GET /recycling
GET /rewards
GET /tickets
POST /tickets
GET /tickets/{ticket}
POST /tickets/{ticket}/reply
GET /service-requests
POST /service-requests
POST /service-requests/{serviceRequest}/cancel
GET /shop/products
GET /shop/products/{product}
GET /shop/categories
GET /orders
POST /orders
GET /orders/{order}
GET /quotes
POST /quotes
GET /notifications
POST /notifications/{notification}/read
POST /notifications/read-all
GET /notification-preferences
PUT /notification-preferences
GET /profile
PUT /profile

Driver#

Method Path
POST /driver/location
POST /driver/location/lost
GET /driver/today
PATCH /driver/collections/{collection}
POST /driver/collections/{collection}/photo
POST /driver/collections/{collection}/weight
GET /driver/dashboard
GET /driver/schedule
GET /driver/routes/{routePlan}
GET /driver/history
GET /driver/vehicle
POST /driver/vehicle/issues
GET /driver/profile
POST /driver/profile
POST /driver/profile/licence
GET /driver/fuel
POST /driver/fuel
GET /driver/checks
POST /driver/checks
GET /driver/shifts
POST /driver/shifts/start
POST /driver/shifts/end
GET /driver/leave
POST /driver/leave
POST /driver/leave/{leaveRequest}/cancel

Website shop#

Method Path
GET /webshop/products
GET /webshop/products/{product}
GET /webshop/categories
POST /webshop/register
POST /webshop/login
POST /webshop/forgot-password
POST /webshop/reset-password
GET /webshop/me
PUT /webshop/me
PUT /webshop/password
POST /webshop/logout
GET /webshop/orders
POST /webshop/orders
GET /webshop/orders/{order}
POST /webshop/orders/{order}/cancel

Keeping this honest#

tests/Feature/Api/ApiDocumentationTest.php fails if a route exists that this file does not list, or the other way round. Add the endpoint here in the same commit that adds it to routes/api.php — a reference an app developer cannot trust is worse than none, because they will build against it before they find out.

The same test holds the Postman collection (in docs/postman/) to the router in both directions, so a new endpoint needs a request there in the same commit too.

The same test compares the enumeration table against the constants the code validates against, exactly and in both directions. A value listed here that the API would reject is the worse half of that: a missing one is found the first time somebody needs it, an invented one is found in the field.