49 KiB
Miler App — API requirements for the backend team
From: Miler rider-app engineering
Date: 21 Aug 2026 · revised 24 Aug 2026
Against: Doormile Miler App — API reference (38 /miler/* routes)
Base URL: https://api.doormile.com/api/v1
Everything below was traced in the shipped app and, where it says verified,
confirmed with live calls against production using rider Rajan A
(userid 38, tenantid 13, configid 1001) on 20–21 Aug 2026.
Requests are ordered by what is blocking riders today. Each one states the symptom, the evidence, and the smallest change that fixes it.
0. Status — updated 21 Aug 2026
The backend team shipped requests 1, 2, 3, 5, 6 and cross-cutting items 10.1–10.4 the same day. The app has been updated against the new contract and the whole suite is green; each section below carries its own status line.
What the app now does with it
| Shipped | What the app changed |
|---|---|
Collected_By_Miler |
A real rung in ConsignmentState, excluded from the hub guard — a rider is never told the hub is holding a parcel that is in his own box. |
POST /consignments/:id/start-delivery |
Start round is a server write again. The local released-set is written only for what the server released; a stop it refused keeps its PICKED word rather than showing a rung the hub does not agree with. |
GET /consignments/:consignmentid |
The delivery gate reads state in one call instead of pulling and sorting the whole history. The logs walk stays as a fallback so a rider on this build meeting an older deployment is not stranded. |
consignmentid + consignmentstatus on lists |
The 17/29 blocker is closed, and the row itself now says where the delivery half has got to — so a delivered stop drops off the Deliveries tab without a per-stop round trip. |
Arrived_At_Pickup |
Parsed and mapped to the arrived rung; I've arrived now shows in the console. The undocumented At_Customer spelling still parses. |
Skip from Collected_By_Miler |
A failed attempt is reportable the moment the parcel is collected, not only once the round has started. |
| Error codes on 4xx | Every branch reads ApiResult.code — INVALID_STATE, IDEMPOTENCY_IN_PROGRESS. No delivery decision is made from message prose any more. |
Idempotency-Key |
Sent on pickup-complete, deliver, skip, payment and start-delivery as a stable verb:resource:day key. IDEMPOTENCY_IN_PROGRESS is waited out and re-asked twice rather than surfaced — a rider is no longer told his delivery failed while it is in the act of succeeding. |
The flag split — confirmed and handled. Collected_By_Miler +
start-delivery ship behind MILER_COLLECTED_STATE_ENABLED, default off,
so hyperlocal pickup still goes straight to Out_for_Delivery today. This
build is correct in both worlds and does not need the flag flipped:
- Flag off — the row's
consignmentstatusalready readsOut_for_Deliverywhen the load reaches the Deliveries tab, so Start round spends no request at all and behaves exactly as it does now. The app never fires astart-deliverythat can only be refused. - Flag on — the row reads
Collected_By_Miler, the press callsstart-delivery, and the local released-set is written only for what the server released.
Nothing here needs coordinating: flip the flag whenever this build is out.
Also shipped, unannounced and now consumed: step, stoptype,
etaminutes, cumulativekms and cumulativeeta on the booking row. The
adapter builds a fixed map, so it had been dropping all five; the app now reads
them. step in particular is what lets the delivery leg follow the hub's route
instead of re-sorting nearest-first — but see request 13: it is 0 on every
row.
Still open: request 4 (upload route for proof of delivery — the photo cannot leave the phone until this exists), 13 (populate the sequence), 14 (an outcome for a failed attempt), 7, 8, 9.
0y. Backend reply — 25 Aug, and what the app did with it
Point by point, with what changed on this side. Where an item needed no code, that is said rather than left implied.
| Their answer | App-side |
|---|---|
1 · reached persists Arrived_At_Pickup + lat/lng + reachedat |
Consumed. The local ARRIVED store is kept for now — see below. |
2 · step: 0 is not a bug; gate on sequencedat |
Fixed, and it was a real bug on our side. |
3 · pickup-flow endpoints live, PATCH /addresses built |
Already wired; §3 of MILER_LOGISTICS_API.md describes it. |
4 · pickup_proof accepts a bookingid |
Wired. The parcel photo now leaves the phone. |
| 5 · Spaces rotation held (jupiter still ships the key) | Understood. See the ask below. |
| 6 · Ordering not server-enforced — do we want it? | Yes, please. Reasoning below. |
| 7 · Error codes present | Consumed; nothing to do. |
2 · sequencedat — the bug was ours
The contract was read correctly and implemented correctly: RouteOrder gates on
sequencedat and treats step: 0 with a null stamp as no route, fall back,
exactly as specified.
It never ran, because ApiConfig.pickupFromBooking was dropping the field.
The adapter mapped step, stoptype, etaminutes, cumulativekms and
cumulativeeta — and not the one that says whether any of them mean anything.
So every adapted row looked unsequenced and the app fell back to nearest-first
on routes the hub had actually solved.
Carried through now, and pinned by route_order_test so it cannot be dropped
again. No backend action needed, and no bookingno to send you — the rows
you saw with step: 0 + sequencedat: null were correct and the app was
mishandling the correct answer.
1 · Why the local ARRIVED store is still there
It is redundant now and it is not yet removable, for one reason: your own
verification note says the Miler_Assigned → Arrived_At_Pickup flip is the one
branch that could not be exercised live — booking 179 never got assigned.
The store costs nothing while it waits. It is the weakest local record in the
app: any server answer outranks it, and it is dropped the moment the stop is
collected. So during the rollout it is a superset — it covers a build meeting an
older deployment, and it goes quiet the instant reached starts persisting.
Removing it is one constant and one call site. Confirm the flip on a real assigned booking and it goes in the same sitting.
6 · Yes, enforce the ordering
addresses → parcel → payment → pickup-complete is a client convention
today, and the app follows it deliberately — the note in
MILER_LOGISTICS_API.md §4 states each dependency. Please make it a server
rule with INVALID_STATE.
Not because we expect to violate it. Because a convention only one client knows is a convention that breaks the first time a second client — or a retry, or a background replay — gets the order wrong, and the failure would be silent: a consignment routed and priced from an address that arrived too late, with nothing on either side saying so.
0x. What we still need from you
Two asks, one small and one that is costing requests today.
A · A per-day series on GET /miler/earnings — P2
The Earnings chart draws seven bars. The response carries six totals for the
period asked for and no series, so the app was reading a breakdown array
that is not on the contract — it was always null, and the chart drew an empty
week while the rider had ridden all of it.
It is fixed on our side by asking seven period=daily&date=… calls
concurrently and building the week from them. That works, and it is bounded,
and it is seven requests for one chart.
Ask: breakdown: [{ day, kms }] on period=weekly. The app already has the
fast path for it — the seven calls are skipped the moment that field appears,
with no further change here.
B · Per-stop riderkms on GET /miler/bookings — P2
Activity now shows the rider his distance for the day. The only source is
per-stop distance, which the app reads from compliance.actualkm /
riderkms — present on some rows and absent on most, so the figure reads as a
dash for riders who have certainly ridden.
Ask: return the distance the server already computes per stop on the booking
row, under whichever of those two names you prefer. The app reads both, plus its
own actualkms spelling, so no coordination is needed on naming.
C · Nothing else
The ARRIVED → PICKED → ACTIVE ladder, the pickup flow, the pricing call, the upload route and the error codes are all contracted and consumed. If item 1's flip verifies, this list is A and B.
0z. Superseded — the two asks that were open before the 25 Aug reply
Both are answered. Kept because the reasoning under each is still the record of why the app is shaped the way it is, and because §1's workaround is still in the build. Read §0y first; this is the request that prompted it.
0z. Open, as of 25 Aug — the two that still shape the rider's day
Everything else on the ARRIVED → PICKED → ACTIVE flow is settled. These two are not, and both are already worked around on the app side rather than waiting on you — so nothing is blocked, but both workarounds are costs we would rather not keep paying.
1 · POST /miler/bookings/:id/reached does not persist the arrival
Today. It answers 200 {"success": true} and the booking stays on
Miler_Assigned. The arrival is never recorded, so the hub cannot see that a
rider is standing at a pickup.
Request body the app already sends:
{ "latitude": 11.0168, "longitude": 76.9558 }
Please make it write: the ARRIVED state, the latitude/longitude received, and an arrival timestamp.
What the app does meanwhile. ARRIVED is treated as a rider-owned rung
and kept in a local store (arrived_order_ids), because the rung existed only
as a field on an in-memory row and the very next queue poll — which the arrival
sheet itself triggers — rebuilt that row from a server still saying
Miler_Assigned and put it back on ACCEPTED. From the rider's side, marking
arrived did nothing.
That store is deliberately the weakest local record in the app: anything the server does know about a stop outranks it, and it is dropped the moment the stop is collected. Delete it the day this endpoint persists — a local record that outranks the server is a liability once the server has the answer. It is one constant and one call site; say the word and it goes.
2 · GET /miler/bookings returns step: 0 on every live row
The transport is there and the app consumes all five fields. The data is not:
every live booking arrives with step: 0, so the hub-planned route order cannot
be followed and the app falls back to sorting nearest-first. A rider works an
optimised route by guessing at it.
Expected on each row:
{ "step": 1, "stoptype": "...", "etaminutes": 10,
"cumulativekms": 2.5, "cumulativeeta": 15 }
This is request 13 below, restated because it is the largest remaining gap on the logistics line and the one with no app-side workaround worth having.
3 · Nothing else is needed for this flow
The ARRIVED → PICKED → ACTIVE ladder needs no other backend change, unless
something has moved in the pickup-complete / consignment-state contract that
§1–§3 of MILER_LOGISTICS_API.md does not already describe.
For the record, what shipped on the app side alongside this: ARRIVED kept
locally; PICKED held after pickup-complete instead of mirroring the
compatibility-mode release; ACTIVE only once the rider presses Start
delivery; geofence enforcement restored at 10 m with GPS-accuracy and
stale-fix handling and a no-fix refusal; and tests pinning the rung ladder.
0a. Second delivery — what the app consumed on 24 Aug
The backend's follow-up batch (stop typing, COD, pre-pickup skip, earnings counts, profile fields, reject-by-body) is in and wired. What changed on this side:
| Shipped | Where it lands in the app |
|---|---|
stoptype + step on GET /miler/bookings |
Stop typing is authoritative now — the app no longer infers a stop's leg from its shape. The mixed pickup/delivery route is reachable. |
codamount + paymentmode |
Cash-to-collect is read from the booking instead of a field the payload never carried, so a COD stop states the real figure at the door. |
POST /bookings/:id/skip |
The pre-pickup skip. The app was posting a booking id to /consignments/:id/skip — the only skip route that existed — which either 404'd or skipped whichever consignment happened to hold that number. That is now the right route with the right id, and the stop stays resumable. |
cancelled_stops + total_stops |
The success rate has a denominator. It was completed_stops alone, which read 100% on a day with three cancellations. |
email + address on PUT /profile |
Both fields the edit screen has always collected now leave the device. EMAIL_IN_USE (409) is branched on by code. |
reason in the reject body |
Sent in the body; the query-string duplicate is dropped. |
Still not shipped, and still shaping the app: step is 0 on every live
row (request 13), so the admin route order the app is built to follow does not
exist in the payload and it falls back to its own ordering. That one is the
difference between the app following the hub's plan and inventing a plan, and
it is now written up on its own — MILER_ROUTE_ORDER_REQUEST.md — because
it is the single largest gap left between what the console believes a rider is
doing and what he is actually doing.
Short version: the console decides the order, the rider follows it, and the app
already works that way. But step is never written, and it disappears from
/miler/assignments the moment a stop is collected — which is the moment the
delivery leg starts. So every delivery in production today is ordered by the
app, not by the hub, and neither side can currently tell.
0c. Third delivery — answered 24 Aug, consumed the same day
Every open question came back. What the app did with each:
| Answer | What changed here |
|---|---|
Sequencing is automatic, sequencedat is the authority signal, step survives the pickup leg |
RouteOrder reads the stamp as the authority and accepts a positive step alongside it. step: 0 + null stamp is now a stated three-case condition rather than an unexplained fallback. |
POD upload — POST /miler/uploads/sign |
Wired end to end: sign, PUT the bytes with the returned headers verbatim (the x-amz-acl is part of the signature, and the bearer token must not travel to the object store), send the public URL as photourl. A failed upload never blocks a hand-over — the parcel is in the customer's hands whatever the network did, and the local copy is kept. |
Skip: attemptcount is truth, ceiling of 3, no auto-RTO |
The app already parked rather than closed a skipped stop; it now reads the count off the response and, on the third, tells the rider the hub has it — the difference between a fourth attempt and a phone call. |
No tripid/slotid, ever |
The day-part split is documented client behaviour now rather than a guess standing in for a field. Recorded on TripSlots, which is the class that would be deleted if that changed. |
| Delivery OTP is fully server-side | Nothing to build: the app never claimed to verify a code, and it already branches on OTP_INVALID / OTP_REQUIRED by code. "OTP verified" is only ever drawn off a 200. |
ridercharges is the client price, not rider pay; bonuspoints unused |
The reward-points tile is withheld rather than showing a permanent zero — that reads as you have earned nothing, not as not built yet. It returns automatically the moment the figure is non-zero. No money figure anywhere in the app is presented as what a rider earned. |
reached persists behind the flag |
Already handled: the app reads the transition rather than the 200, so it is correct with the flag off and with it on. |
Left on our side: the console status mappings (arrived_at_pickup → arrived,
collected_by_miler → picked) before the flag is flipped. That is console work,
not rider-app work.
0b. Summary table
| # | Request | Priority | Status |
|---|---|---|---|
| 1 | A consignment state that means collected, not yet riding | P0 | ✅ Shipped 21 Aug — Collected_By_Miler + start-delivery |
| 2 | GET /miler/consignments/:id (current state, one call) |
P0 | ✅ Shipped 21 Aug |
| 3 | Return consignmentid on GET /miler/bookings |
P0 | ✅ Shipped 21 Aug — with consignmentstatus |
| 4 | A file-upload route for proof of delivery | P1 | ⏳ Open — POD cannot leave the phone |
| 5 | A booking state for arrived at pickup | P1 | ✅ Shipped 21 Aug — Arrived_At_Pickup |
| 6 | Allow cancel/skip after pickup, or document the refusal | P1 | ✅ Shipped 21 Aug — skip from collected |
| 7 | Real route distance + duration on the assignment | P2 | ⏳ Open — figures are estimates |
| 8 | Persist bonuspoints, and a per-stop payout figure |
P2 | ⏳ Open — Earnings shows placeholders |
| 9 | Notification read-state table | P3 | ⏳ Open — known stub |
| 13 | Route sequence — field shipped, never populated | P0 | ⚠️ Half — step is on both endpoints but is 0 everywhere, sequencedat null. Written up on its own: MILER_ROUTE_ORDER_REQUEST.md |
| 14 | An outcome for a failed delivery attempt | P1 | ⏳ Open — skip leaves the consignment open with no way to say why |
| 15 | reached must actually persist Arrived_At_Pickup |
P0 | 🔴 BLOCKER — reproduced: returns 200, writes nothing |
| 16 | Admin mapping for Arrived_At_Pickup + Collected_By_Miler |
P0 | 🔴 Console change — both absent; flag must not be flipped first |
| 17 | What /admin/bookings returns in status |
P1 | ✅ Answered 22 Aug — frozen booking status, plus consignmentstatus for the live one |
| 18 | Per-stop type + step ordering on /miler/bookings |
P0 | ✅ Shipped 24 Aug — consumed |
| 19 | codamount on the booking |
P0 | ✅ Shipped 24 Aug — consumed |
| 20 | A pre-pickup skip route | P1 | ✅ Shipped 24 Aug — consumed; fixed a wrong-id post on our side |
| 21 | cancelled_stops / total_stops on earnings |
P2 | ✅ Shipped 24 Aug — consumed |
| 22 | email / address on PUT /profile |
P2 | ✅ Shipped 24 Aug — consumed |
| 23 | A trip/slot id, if the console owns the split into runs | P2 | ✅ Answered 24 Aug — there is none; the day-part split is ours by agreement |
| 4 | A file-upload route for proof of delivery | P1 | ✅ Shipped 24 Aug — signed URL, wired end to end |
| 13 | Route sequence — populated, with sequencedat as the authority |
P0 | ✅ Answered 24 Aug — automatic on every assignment; consumed |
| 24 | A rider payout rate card | P2 | ⏳ Open — ridercharges is the client price; no rider figure is shown until there is one |
1. P0 — There is no consignment state meaning "collected, not yet riding"
✅ Shipped 21 Aug 2026 — Option A.
pickup-completenow stops atCollected_By_Miler;POST /miler/consignments/:id/start-deliverymakes the release. The app's Start round is a server write again.
Symptom (reported)
"If I try to update as Picked it is directly updating as active in the console."
Evidence — verified
GET /miler/consignments/logs/34 returns the consignment's real history:
Out_for_Delivery "Package collected by miler and converted to consignment"
Delivered "Delivered to SEQTEST Ukkadam at (11.00506, 76.95087)"
The first row is written by pickup-complete — it carries that handler's
own remark. So on the hyperlocal path, pickup-complete moves the consignment
straight to Out_for_Delivery.
The console maps (src/utils/bookingStatus.js):
picked_up: 'picked',
converted_to_consignment: 'picked',
out_for_delivery: 'active', // ← what the operator sees
So the moment the rider taps Picked at the kitchen counter, the consignment
is Out_for_Delivery and the operator's board says active — while the food
is still on the counter and the rider has not moved.
Why this is a backend request, not a UI one
The app already models the distinction locally (it knows the rider has not set off), but local state cannot reach the hub, and the operator's board is fed from server state. There is no server state that expresses collected but not yet out for delivery, so the information does not exist to display.
Requested change — pick one
Option A (preferred). Add a consignment status between conversion and the road:
Created → Inwarded_at_Hub → … → Out_for_Delivery → Delivered
▲
Collected_By_Miler ← NEW
pickup-complete writes Collected_By_Miler for hyperlocal work.
A new call moves it on:
POST /miler/consignments/:id/start-delivery
→ 200 { success, data: { consignmentid, status: "Out_for_Delivery" } }
400 if the consignment is not Collected_By_Miler
deliver then accepts Out_for_Delivery exactly as it does now.
Option B (smaller). Leave the consignment alone and keep the booking at
Picked_Up until the rider starts the round, moving it to
Converted_To_Consignment at that point. Cheaper, but it leaves the two
vocabularies coupled, which is the root of several problems in this document.
Note on
POST /miler/deliveries/start: the app used to call this. It is not in the 38-route contract and returns404 Cannot POST— verified. We have removed the call. Request 1 Option A is the properly-specced replacement.
2. P0 — No way to read a consignment's current state in one call
✅ Shipped 21 Aug 2026.
GET /miler/consignments/:consignmentidreturns the status pluscollected/out_for_delivery/delivered/can_start_delivery/can_deliver/can_skip. The gate reads it; the logs walk remains only as a fallback for older deployments.
Symptom (reported)
"I can't update it as delivered/skipped/cancelled — it's showing a lot of errors."
Evidence — verified
POST /miler/consignments/34/deliver →
400 { "success": false, "message": "consignment is not out for delivery" }
That message is returned for two opposite situations:
| Real state | Meaning | What the rider should be told |
|---|---|---|
Inwarded_at_Hub |
not released yet | "the hub still has it" |
Delivered |
already delivered | "already done — nothing to do" |
In the reported case it was the second: consignment 34 had been delivered at 10:59 the previous day. The rider was shown an error for work he had completed.
Why the app cannot fix this alone
GET /miler/bookings reports only the booking status, which is terminal at
Converted_To_Consignment and never learns about the delivery half. The only
route that exposes consignment state is
GET /miler/consignments/logs/:consignmentid, which returns the whole event
history — the app now sorts it by historyid and takes the last row. That
works, but it is a log-walk standing in for a state read, on the rider's mobile
data, before every delivery.
Requested change
GET /miler/consignments/:consignmentid
→ 200 {
success: true,
data: {
consignmentid: 34,
trackingno: "DM-TRK-…",
status: "Out_for_Delivery",
bookingid: 60,
attemptcount: 0,
updatedat: "2026-08-20T10:59:35Z"
}
}
404 if it is not this rider's consignment
And make the two refusals distinguishable — either distinct messages or, better, a stable code:
400 { "success": false, "code": "CONSIGNMENT_ALREADY_DELIVERED",
"message": "consignment already delivered" }
400 { "success": false, "code": "CONSIGNMENT_NOT_RELEASED",
"message": "consignment is not out for delivery" }
A machine-readable code on every 4xx across /miler/* would let the app stop
string-matching messages, which is fragile in exactly the way this bug proves.
3. P0 — GET /miler/bookings does not return consignmentid
✅ Shipped 21 Aug 2026. Both
consignmentidandconsignmentstatusare on the list rows. The 17-of-29 gap is closed, and the consignment status now outranks the booking status the app draws the card from.
Evidence — verified
24 booking rows returned for rider 38; zero contain any consignment* key.
GET /miler/assignments (list) does not carry it either. It appears only in
GET /miler/assignments/:id, nested inside booking:
// GET /miler/assignments/71
{ "data": { "assignment": {…}, "booking": { "bookingid": 75, …,
"consignmentid": 46 } } }
This is not a performance concern — it blocks deliveries outright
Verified 21 Aug 2026, rider 38:
| Endpoint | Rows | Booking ids |
|---|---|---|
GET /miler/bookings |
29 | 26, 28, 41, 51, 54–78, 82 |
GET /miler/assignments |
12 | 26, 28, 41, 51, 72–78, 82 |
17 of 29 bookings have no assignment row at all. ?status=Completed,
?status=all, ?pagesize=200 and ?bookingid=59 all return the same 12 rows —
the endpoint ignores query parameters.
So for booking 59 (DM-BK-501CB551-45130, status
Converted_To_Consignment, sitting on the rider's Deliveries tab) every route
to its consignment id is a dead end:
- the booking row — carries no consignment key (none of the 29 do)
- the app's local record from
pickup-complete— absent after a reinstall, and absent entirely if that response did not carry the id GET /miler/assignments/:id— booking 59 is not in the assignments list, so there is no assignment id to fetchGET /miler/consignments/userlogs/:userid— telemetry only (1 row, for an unrelated consignment); carries no booking linkage
There is no fifth route. The rider is holding the parcel, the consignment
demonstrably exists (the booking is converted), and the app cannot name it — so
deliver can never be called and the stop can never be completed from the app.
This is the single highest-impact item in this document.
Requested change
Add consignmentid (nullable) and consignmentstatus to every row of:
GET /miler/bookingsGET /miler/assignments
…and separately, GET /miler/assignments should return every assignment the
rider still has work for, not a 12-row subset. If that list is intentionally
scoped to active assignments, say so and we will stop treating it as a lookup
table — but then requirement (a) below becomes mandatory rather than merely
strongly preferred.
{ "bookingid": 75, "status": "Converted_To_Consignment",
"consignmentid": 46, "consignmentstatus": "Out_for_Delivery" }
This single change removes an N+1 call pattern and makes Request 2's read unnecessary for the list case.
4. P1 — No upload route: proof of delivery cannot leave the phone
⏳ STILL OPEN. The photo is captured, previewed and stored on the device, and it is shown on the Activity record. It cannot reach the hub until this route exists. This is now the highest-priority outstanding item.
What the app now does
The rider taps Delivered → a proof screen opens the camera → he sees the photo → Mark as delivered on the same screen. The photo is stored on the handset and shown on the delivery record in Activity.
The blocker
POST /miler/consignments/:id/deliver takes photourl — a string. Nothing
in the 38-route contract accepts a file: no multipart route, no signed-URL
endpoint, no attachment on any other call.
We deliberately do not send the device file path in photourl. A path from
somebody's phone is not a URL; writing one into the hub's record would store a
string that looks like evidence and resolves to nothing.
So proof of delivery currently exists only on the rider's phone, and the hub cannot see it. For a delivery business this is the difference between having evidence and believing you have it.
Requested change — either shape works
Option A — direct upload (simplest for us).
POST /miler/uploads
Content-Type: multipart/form-data
file: <binary> (jpeg, ≤ 5 MB)
purpose: "delivery_proof" ("pickup_proof" | "signature" | …)
consignmentid: 46 (optional, for association)
→ 201 { success, data: { url: "https://cdn.doormile.com/proof/46-abc.jpg" } }
Option B — signed URL (cheaper server-side).
POST /miler/uploads/sign
{ "purpose": "delivery_proof", "contentType": "image/jpeg" }
→ 200 { success, data: { uploadUrl: "https://…?X-Amz-Signature=…",
url: "https://cdn.doormile.com/proof/…" } }
The app PUTs the bytes to uploadUrl, then sends url as photourl.
Constraints from our side: photos are JPEG, quality 70, max width 1280 — typically 80–250 KB. Riders are on mobile data and often on 3G, so the upload must be retryable and must not block the delivery: we will complete the stop and upload in the background, then attach.
Please also confirm whether receiversignatureurl is expected to be fed from
the same mechanism.
5. P1 — "Arrived" does not persist: there is no booking state for it
✅ Shipped 21 Aug 2026.
reachedpersistsArrived_At_Pickup. The app parses it (and the olderAt_Customerspelling) to the arrived rung.
Symptom (reported)
"If I click Arrived on the home page it is not updating as arrived."
Evidence
The app calls POST /miler/bookings/:bookingid/reached with the booking id —
correct per contract. But the booking enum is:
Pending_Pickup, Created, Miler_Assigned, Pickup_Scheduled,
Picked_Up, Converted_To_Consignment, Cancelled
There is no Arrived. Whatever reached writes, the operator's board maps
pickup_scheduled → 'accepted', so an arrived rider is indistinguishable from
one who merely accepted the job and is still 20 km away.
Requested change
Add Arrived_At_Pickup to the booking enum, written by reached, and return
the new status in the response so the app can confirm rather than assume:
POST /miler/bookings/:bookingid/reached
→ 200 { success, data: { bookingid, status: "Arrived_At_Pickup",
reachedat: "2026-08-21T09:14:02Z" } }
Every state-changing route should return the resulting entity state. Today
most return {success: true} with no body, so the app cannot verify the
transition landed and must re-poll the list.
6. P1 — A rider cannot report a failed delivery
✅ Shipped 21 Aug 2026.
skipis accepted fromCollected_By_Mileras well asOut_for_Delivery.
Symptom (reported)
"I can't update it as … skipped/cancelled."
Evidence
POST /miler/consignments/:id/skiprequiresOut_for_Delivery— so a consignment in any other state cannot be skipped, including one the rider is genuinely holding.POST /miler/bookings/:id/cancelis "refused once picked up" per the contract. Afterpickup-completethere is therefore no route by which a rider can say "this cannot be completed" — he is holding a parcel with no way to report the failure.
Requested change
- Allow
skipfromCollected_By_MilerandOut_for_Delivery(see Request 1). - Add a consignment-level failure route:
POST /miler/consignments/:id/fail
{ "reason": "Customer refused", "lat": 11.0, "lon": 76.9 }
→ 200 { success, data: { status: "RTO_Initiated", attemptcount: 2 } }
- Document the maximum
attemptcountand what the hub does when it is hit — the app should stop offering "skip" at that point rather than letting the rider discover the ceiling by being refused.
7. P2 — The Home figures are estimates, not real data
Direct answer to the question asked. The four figures on Home
(DURATION · DISTANCE · PARCELS · PAYMENT) are:
| Figure | Source | Real? |
|---|---|---|
| PARCELS | sum of parcel counts on the booking rows | ✅ Real (backend data) |
| PAYMENT | sum of collectionamt on the booking rows |
✅ Real (backend data) |
| DISTANCE | client-side haversine: hub → each stop in order → hub, straight lines. Falls back to summing each row's kms when coordinates are missing |
⚠️ Estimate |
| DURATION | client-side: straight-line distance ÷ an assumed 20 km/h, plus a hardcoded service time per stop — 2m30 delivery / 4m00 pickup / 5m30 combined, +90s if cash, +25s per extra parcel | ⚠️ Estimate |
So two of the four are real and two are the app's own arithmetic. Straight-line distance under-reads real road distance by roughly 20–40% in Coimbatore, and the service times are guesses that have never been measured against actual stop durations.
Requested change
Return the routed figures on the assignment/trip, computed once server-side (you already have the coordinates, and a routing engine gives real road distance):
// GET /miler/assignments — per row, or a trip-level summary
{
"routedistancemeters": 21800, // real road distance for the leg
"routedurationseconds": 1620, // real drive time
"cumulativedistancemeters": 48200,
"cumulativedurationseconds": 5400
}
The assignment model already has riderkms, previouskms, cumulativekms,
etaminutes and cumulativeeta — all of them return 0 today (verified on
all 24 rows). Populating those existing fields would be enough; no new schema
needed.
Until then the app will keep showing estimates, because inventing precision we do not have is worse than an honest approximation.
8. P2 — Earnings has no real per-stop money
bonuspointsis never written (acknowledged in your own doc).- There is no per-stop payout anywhere in the API.
delivercomputesriderkmsandriderchargesserver-side and returns neither;GET /miler/earningsanswers for a period only.
The app therefore shows Stop payout · See Account on the delivery record
rather than a number, because a figure derived from a rate the app does not
hold is the one invention a rider would act on and be wrong about.
Requested change
Return the earnings the server already computed, on the response to deliver
and on the booking/consignment rows:
{ "riderkms": 4.6, "ridercharges": 38.50, "bonuspoints": 5 }
9. Battery percentage — direct answer
Yes, it can reach the console today, and the plumbing already exists on both sides.
- The app reads the real battery level (
battery_plus) inlive_tracking_service.dartandforeground_service.dart. - It is sent on
POST /miler/logsandPOST /miler/consignments/logsas the documented string fieldbattery. - The console already renders it — its rider-detail page (
riders.js):riderLogsdata?.battery ? \${battery}%` : 'N/A'`.
What to check on your side: the console reads it from rider logs, so it
only appears while the app is posting telemetry (on duty, service running). If
operators see N/A, the likely causes in order are: (a) the rider is off duty
so nothing is being posted; (b) GET /miler/logs is returning the latest row
without battery populated; (c) the value is being stored but the console's
rider-detail query is not selecting it.
One request: expose the most recent telemetry row per rider in one call, so the console does not have to page logs to find a current battery/GPS reading:
GET /miler/riders/:userid/last-seen (or /admin equivalent)
→ { success, data: { latitude, longitude, battery, speed, status,
logdate, connection } }
10. Cross-cutting requests
✅ 10.1–10.4 shipped 21 Aug 2026. Mutations return the resulting state,
pickup-completereturnsconsignment_idandnext_action, 4xx bodies carry a machine-readablecode, andIdempotency-Keyis honoured. 10.5 (requiredeliveryotpon the tenant payload) is still open.
These are small and would remove whole classes of defect:
-
Return the resulting state from every mutation.
accept,reject,reached,parcel,payment,pickup-complete,deliver,skip,cancelmostly return{success: true}. The app cannot confirm a transition and must re-poll. -
pickup-completemust return theconsignmentidit minted. It is the one moment the id is guaranteed to exist. The app records it locally from the response today; when the response omits it, the delivery leg has to go hunting (Request 3). -
Machine-readable error codes on every 4xx (see Request 2). String matching on
"consignment is not out for delivery"is how the app ended up telling riders the wrong thing. -
Idempotency.
deliver,pickup-completeandacceptare not idempotent. A dropped response on a bad connection means the rider retries and gets a 400 for work that succeeded. Accepting anIdempotency-Keyheader and replaying the original result would remove this entire failure mode. -
Confirm
requiredeliveryotpper tenant is exposed to the app. We currently sendotponly when we have one and never fabricate a placeholder; if the flag were on the profile/tenant payload we could show or hide the OTP field correctly instead of inferring.
11. What the app already does correctly (no change needed)
Recorded so the backend team does not chase these:
deliver/skipkey on the consignment id, never the booking, assignment or pickup id.accept/rejectkey onbookingassignmentid.reached/parcel/payment/pickup-complete/cancelkey onbookingid.- Telemetry sends lat/long/speed/heading/battery as strings, and never
sends
useridin the body. - Booking statuses and consignment statuses are handled as two separate vocabularies; the app no longer infers a delivery state from a booking status.
- Duplicate taps are collapsed client-side (one in-flight mutation per resource+verb), but see Request 10.4 — that is not a substitute for server-side idempotency.
11b. Request 13 — P0: the route sequence is exposed but never populated
Re-verified live 21 Aug 2026 against rider userid 23 / tenantid 1 —
GET /miler/bookings (29 rows) and GET /miler/assignments (11 rows).
The transport is there. Thank you.
step is on both endpoints, alongside sequencedat, etaminutes,
cumulativekms, cumulativeeta and stoptype. That closes the client half of
this entirely: the app now reads step off the booking row, orders both legs
by it, and never re-sorts an assigned route.
The data is empty
/miler/bookings step = 0 on all 29 rows
/miler/assignments step = 0 on all 11 rows
sequencedat = null on all 11 rows
etaminutes = 0, riderkms = 0, cumulativekms = 0
sequencedat: null on every assignment means the route optimizer has never
run for this rider. So there is no admin route to follow — not one the app is
dropping, one that does not exist.
What the app does about it
It does not pretend. With no sequence anywhere in the set it falls back —
booked time first, then nearest-first — and labels the queue on screen with
which rule produced the order (Hub route vs Nearest first), so a rider is
never told the app's guess is his route. It is also logged per fetch:
[ROUTE][deliveries] 7 stop(s) with no assigned sequence — ordering is the
app's own fallback, not the hub's route
Requested change
Populate it. Whatever writes sequencedat — batch assign, the console's
sequence action, a scheduled optimizer pass — is either not running or not
running for this tenant. Once step comes down non-zero, the app follows it on
both legs with no further change and no release needed.
Please confirm: is sequencing expected to be automatic on assignment, or is it a deliberate action a hub manager has to take in the console? The answer changes whether "no route assigned" is a normal state the rider should see or a fault worth alerting on.
11c. Request 14 — P1: a failed delivery attempt has no outcome
POST /miler/consignments/:id/skip returns 200 and, as far as the app can
tell, leaves the consignment Out_for_Delivery. There is no state, field or
route that says this stop was attempted and failed.
That leaves the two systems unable to agree on whether the stop is still actionable:
- The rider has been to the door. It is done with, for him, today.
- The hub still has an open
Out_for_Deliveryconsignment.
The app refuses to resolve that by inventing a terminal state locally — it reads the consignment after every skip and only files the stop as finished if the server agrees it is closed. When the server keeps it open, the stop is parked: still visible under SKIPPED with the rider's reason, still holding its consignment mapping, resumable by him. Honest, but it is a workaround for a missing contract.
Requested change — any one of these
- A consignment state for it —
Delivery_Failed/Attempt_Failed— thatskipmoves it to, with whatever reassignment or RTO the hub decides happening from there. - An
attemptcount+lastattemptaton the consignment, so the app can at least show "attempt 2 of 3" and the hub can act on the count. - Documented confirmation that a skip is expected to leave the consignment open and that the rider is meant to retry it in the same shift — in which case the app will surface it as retryable rather than as a closed record.
Whichever you pick, the app needs to know who owns the stop after a skip.
11d. Request 15 — P0 BLOCKER: reached is a deployed no-op
Reproduced against production 21 Aug 2026. Rider userid 23 / tenantid 1,
booking 78, three calls in sequence:
GET /miler/bookings → booking 78 status = Miler_Assigned
POST /miler/bookings/78/reached
{"lat":11.0168,"lon":76.9558}
→ 200 {"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}
GET /miler/bookings → booking 78 status = Miler_Assigned
The endpoint returns success: true, echoes the booking's unchanged
status, and writes nothing. Arrived_At_Pickup is not written and does not
appear on any of the 31 bookings in this tenant.
This is the whole reason Arrived never reaches Admin. It is not a client
bug: the app calls the documented endpoint with the documented body and gets a
200. It had been treating that 200 as proof of a transition, which it no longer
does — the app now reads the returned status and, when it is not
Arrived_At_Pickup, tells the rider his hub has not recorded the arrival
rather than drawing a tick.
Requested change
POST /miler/bookings/:bookingid/reached must persist Arrived_At_Pickup on
the booking and return it:
POST /miler/bookings/:bookingid/reached
{ "lat": 11.0168, "lon": 76.9558 }
200 { "success": true,
"data": { "bookingid": 78, "status": "Arrived_At_Pickup" } }
Please also confirm which is true, because they need different fixes:
- the handler was never wired to write, or
- it writes only from
Pickup_Scheduledand silently no-ops fromMiler_Assigned(booking 78 was assigned but not yet accepted).
If (2), say so and the app will stop offering I've arrived before the
accept lands. If it is meant to be callable from Miler_Assigned, it must
write from there too.
11e. Request 16 — P0: Admin has no mapping for the two new statuses
Not a backend change — a console change. Traced in
Doormilexpress_console/src/utils/bookingStatus.js:
export const BOOKING_STATUS_TO_DELIVERY_STATUS = {
created: 'pending', pending_pickup: 'pending',
miler_assigned: 'pending', pickup_scheduled: 'accepted',
picked_up: 'picked', converted_to_consignment: 'picked',
out_for_delivery: 'active',
delivered: 'delivered', cancelled: 'cancelled'
};
Neither arrived_at_pickup nor collected_by_miler is in it. Unmapped
statuses "pass through lowercased", which the Deliveries page renders as an
unknown badge — and the file's own comment says the Arrived tab "will show a 0
count until the real enum is confirmed".
Two consequences the console team must act on:
- Even after request 15 ships, Admin still will not show Arrived. Add
arrived_at_pickup: 'arrived'. - Enabling
MILER_COLLECTED_STATE_ENABLEDtoday would make Admin worse, not better. With nocollected_by_milerkey, every freshly collected parcel would render as an unknown badge instead of Active. Addcollected_by_miler: 'picked'before the flag is flipped.
Ordering — this matters
1. console adds arrived_at_pickup + collected_by_miler mappings
2. backend fixes `reached` to persist Arrived_At_Pickup (request 15)
3. this app build ships — it already supports both lifecycles
4. only then flip MILER_COLLECTED_STATE_ENABLED = true
Flipping the flag before step 1 or step 3 breaks the rider's Picked rung.
11f. Request 17 — the one thing still unproven: what /admin/bookings puts in status
GET /miler/bookings reports the booking status: booking 77 is
Converted_To_Consignment with consignmentstatus: Out_for_Delivery.
The console reads b.status from /admin/bookings and maps it
(api.js:611 and api.js:664). If that field held Converted_To_Consignment
too, the console would render Picked — which is not what we see; it shows
Active.
So exactly one of these is true, and we cannot tell which from the rider token:
| # | Possibility | How to confirm | Owner |
|---|---|---|---|
| A | /admin/bookings projects the consignment status onto status |
one network call in the console's dev tools | backend |
| B | the backend also writes Out_for_Delivery onto the booking row for hyperlocal, and /miler/bookings reports something different |
compare both endpoints for the same bookingid | backend |
| C | the deployed console is older than this source (the repo comment says converted_to_consignment "used to map to accepted") |
check the deployed bundle | console |
Please open the console's Deliveries page, look at the /admin/bookings
response for one collected booking, and tell us the value of status. That
single value decides which of the three it is, and none of them is fixed in the
rider app.
Whichever it is, note that in compatibility mode Active is not wrong — the
consignment genuinely is Out_for_Delivery. The app now agrees with the
console instead of showing Picked over it. Picked/Collected becomes a real,
separate rung only once the flag is on.
12. Open questions for the backend team
1. Is Answered: Option A, shipped.Collected_By_Miler (Request 1 Option A) acceptable, or do you prefer
holding the booking at Picked_Up (Option B)?
-
Which upload shape do you want to support — direct multipart or signed URL?
-
Is there an existing object store / CDN we should target for proof photos, and what retention applies to them?
-
Can
riderkms/etaminutes/cumulativekmsbe populated from the routing engine, or is there no routing engine in the stack today? -
What is the intended
attemptcountceiling, and what happens at it? -
Who owns the split into Trip 1 / Trip 2 / Trip 3? The app groups a rider's stops into up to three runs by day part — morning, afternoon, evening — because nothing in the payload names a run. If the console assigns runs, we should be following its ids rather than guessing, and a rider's "Trip 2" and the hub's would then be the same object. Either answer is workable; we need to know which:
- The console owns it → return
tripid(andslotid, if separate) on every row ofGET /miler/bookings, stable for the day. The app follows it and drops the day-part split entirely. - Nobody owns it → say so, and the day-part split stays as the documented client behaviour rather than an unlabelled guess.
- The console owns it → return
-
Is
stepever written today? Request 13 says the field exists and is0on every live row. Before we spend more time on the fallback: is sequencing generated automatically when assignments are created, or does an operator have to trigger a route/optimizer action? If it is manual, the app should probably say unsequenced rather than silently ordering the stops itself. -
Where should a re-quoted price go? A logistics rider re-prices a shipment at the door.
POST /bookings/:id/paymentrecords what was collected, andbookingserviceoptions.estimatedpriceis the customer's original figure and is not rider-writable — so the new quote has nowhere to land and the booking keeps an estimate nobody honoured. A rider-writable price field on the booking, or an accepted field onparcel, closes it. -
Delivery OTP.
delivertakes anotpand the tenant flagrequiredeliveryotpgates it, but there is no delivery-OTP column on consignments — so with the flag on there is nothing to check the code against. Is the column planned? Until it exists the app records that a code was presented, not that it was correct, and we would rather not draw a verification the backend cannot perform.