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/jsonon 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. SetbaseUrl, run a login request, and the token is saved for the rest of the folder.
Contents#
- Getting a token
- Conventions — errors, pagination, dates, money, language
- Account
- Customer endpoints
- Driver endpoints — round sheet, home, schedule, history, vehicle and fault reports, profile, user guide
- Push notifications
- Website shop — its own accounts, catalogue and orders
- Enumerations
- 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.5means 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(oren). 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"] }
}
priceon a service item is a guide only — say so in the app. The office setsmonthly_price.open_planis one waiting for the office (pending) or for its first payment (awaiting_payment). Wheninvoiceis set and unpaid, offer a Pay button that goes to that bill.schedule_daysare set by the office: weekday keys for a weekly plan, days of the month (1–31) for a monthly one.rejected_planis the newest plan only if the office refused it;staff_notesays 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;
popularlists articleids 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. stepsandtipsmay be empty arrays.screennames the app screen an article is about, so a "Go to this page" button can open it.nullwhen 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 openweb_urlinstead.idmatches the anchor on the web page, sohttps://your-domain/my-account/help#billing-pay-billand an in-app deep link can name the same article.keywordsare extra search terms (synonyms, what people call things). Search locally acrosstitle,summary,steps,tipsandkeywords.- Caching. The guide changes only when the server is updated. Responses
carry an
ETag; send it back asIf-None-Matchand an unchanged guide answers304 Not Modifiedwith no body.updatedis 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_stopis the first stop stillscheduledtoday, in driving order, ornullwhen there is none.weekruns from Monday to today.missedcountsmissedandskippedtogether — both are stops that were not collected.licence.days_leftis negative once the licence has expired, andnullwhen no expiry date is on file.warningistruefrom 30 days before expiry. Show it prominently: the office cannot roster an unlicensed driver.driver_statusisactiveoron_leave(aninactivedriver 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 }
]
}
roundsare the plan, ordered Monday to Sunday.daysare 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.daysalways has 14 entries starting today, including empty days.specialcounts one-off jobs (no bin). Open a day withGET /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.
summarycovers the whole month and ignoresstatus, so filter chips can show a count each.kgis 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" } ]
}
typeis 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 ascompactor.)nullwhen the vehicle has no type.recent_serviceis the last five workshop visits. Costs are not included — they are the office's business.- Warn when
road_tax_expiry,insurance_expiryorpermit_expiryis past, or within 30 days. Any one of them lapsed means the truck should not be on the road. Each isnullwhen not recorded. issuesis 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_idthat is not one of this driver's trucks is a 422 onvehicle_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
urgentreport, prompt the driver to phone the operations desk as well. - The office moves it through
open→in_progress→resolvedand may write aresolution_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
okorfault; an item left out counts asok. safe_to_driveis required. Every fault is also filed as a truck problem (seePOST /driver/vehicle/issues), one per problem category,urgentwhensafe_to_driveisfalse. 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 markedfault).
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
}
starttakes optionalvehicle_id(defaults to the truck on today's round) andstart_odometer. 422 onshiftif a shift is already open.endtakes optionalend_odometerandnote. 422 onshiftif none is open, or onend_odometerif it is lower than the start reading.hoursruns to now while the shift is open.kilometresisnullunless 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
}
POSTtakesstarts_on(today or later),ends_on(on or afterstarts_on, and within a year),type(a leave type) and optionalreason. 201 with the request. 422 onstarts_onif it overlaps a pending or approved request.- The office approves or declines, optionally with an
office_notefor the driver. An approved leave sets the driver'sstatustoon_leavefor its days and back toactivethe day after. cancelwithdraws a request stillpendingand answers with it, nowcancelled; any other status is a 422 onleave. 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/loginor/webshop/registerworks only on the/webshopendpoints. Sent anywhere else it gets 401, and a portal or driver token sent to a/webshopendpoint 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."
}
quantityis 1–100 per line. The same product twice is merged into one line.delivery_addressis required whendelivery_typeisdelivery.phonedefaults to the account's phone;notesis 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.