Files
doormile_milderapp/MILER_LOGISTICS_API.md
2026-08-28 11:13:15 +05:30

438 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Miler — the logistics line's API contract
**From:** Miler rider-app engineering
**Date:** 25 Aug 2026
**Base URL:** `https://api.doormile.com/api/v1`
**Scope:** the **logistics (parcel) line only** — `ServiceProfile.parcel`.
The meal line (`ServiceProfile.milkMan`) runs the same screens with different
capabilities and calls a strict subset of this; where the two diverge it is
called out.
This is a companion to `MILER_API_REQUIREMENTS.md`, not a replacement. That
document is organised by *request* — what is broken and what we are asking for.
This one is organised by *call order*: what the logistics flow actually sends,
in the sequence it sends it, and which orderings are contractual rather than
incidental. Read this one to implement or verify a handler; read that one for
the open asks.
Everything below is traced from the shipped app — `lib/data/miler_api.dart`,
`lib/providers/pickuplog/pickuplog_provider.dart`,
`lib/controllers/pickups_controller.dart` and the pickup flow under
`lib/views/Dashboard/pickups/`.
---
## 1. What makes the logistics line different
A meal run delivers something that already exists. **The logistics line
creates the shipment at the door.** The rider is the first person who stands in
front of the sender, so he is the first person who can establish the four facts
the consignment will be routed, priced and billed on:
| Fact | Where it comes from | What depends on it |
|---|---|---|
| FROM / TO addresses + pincodes | the rider asks the customer | routing hub, pricing zone |
| Measured weight | the rider's scale | chargeable weight |
| Category + service type | the rider asks | the pricing rule that applies |
| Money taken | the rider's hand | the payment record, the COD ledger |
Everything in §3 follows from that: a booking arrives half-specified and leaves
the door as a consignment with a tracking number.
Capabilities that gate this flow (`lib/data/service_profile.dart`):
```
needsVerification true → the proof-of-work page runs
capturesShipmentAddresses true → the shipment desk runs
initiatesShipment true → the review page raises the order
```
All three are `false` on the meal line, which is why a milk run reaches none of
§3.2 – §3.7.
---
## 2. Cross-cutting contract
These apply to every call below and are not repeated per endpoint.
### 2.1 Auth
`Authorization: Bearer <token>` from `POST /miler/verify-pin`. The **one**
exception is `POST /pricing/check` (§3.3), which is unauthenticated.
The bearer token must never be forwarded to the object store — see §3.8.
### 2.2 Idempotency
Sent as `Idempotency-Key: <verb>:<resource>:<yyyymmdd>` on every write that
moves money or state:
```
payment:<bookingid>:20260825
pickup-complete:<bookingid>:20260825
```
The key is **derived, not random**, so a retry after a dropped acknowledgement
produces the same key and replays the first result. A genuine second attempt
tomorrow gets a new key and is allowed through.
`IDEMPOTENCY_IN_PROGRESS` is not surfaced to the rider. The app waits and
re-asks twice before treating it as a failure — telling a rider his payment
failed while it is in the act of succeeding is the worst answer available.
### 2.3 Error codes
Every 4xx must carry a stable machine-readable `code`. The app branches on
`ApiResult.code` and **never** on message prose. Codes currently consumed:
| Code | Meaning the app acts on |
|---|---|
| `INVALID_STATE` | the resource is not on a rung this call can move it from |
| `IDEMPOTENCY_IN_PROGRESS` | the first attempt is still running — wait, re-ask |
| `EMAIL_IN_USE` | profile edit conflict (409) |
New codes are welcome; new *messages* used as signals are not.
### 2.4 Coordinates
`latitude` / `longitude` on a write always mean **where the rider is standing
at that moment**, not the booking's stored pin. The server computes rider
kilometres by haversine from these and writes them onto the earnings record, so
sending the booking's own coordinates silently zeroes the rider's distance for
that leg.
---
## 3. The flow, in call order
The whole logistics stop, from the rider tapping *I've arrived* to a shipment
existing on the hub's screen:
```
3.1 POST /miler/bookings/:id/reached he is at the door
────── proof of work, on the phone ─────────────────────────────
3.2 (local) parcel count · condition · weight · code · photo
3.3 POST /pricing/check what it costs
3.4 PATCH /miler/bookings/:id/addresses FROM and TO ← must be first
3.5 POST /miler/bookings/:id/parcel measured weight
3.6 POST /miler/bookings/:id/payment the money ← must precede 3.7
3.7 POST /miler/bookings/:id/pickup-complete the shipment exists
────── the consignment is now the subject ──────────────────────
3.8 POST /miler/uploads/sign proof photo ← see §5.1
```
### 3.1 Reached — `POST /miler/bookings/:bookingid/reached`
```jsonc
{ "latitude": 11.0168, "longitude": 76.9558 } // both optional, both sent
```
Records arrival at the pickup address. Guarded on the device against a double
press (`MutationGuard`), so a duplicate is a network replay rather than a
second intent.
> **Open:** this is a deployed no-op on production — see request 15 in
> `MILER_API_REQUIREMENTS.md`. The app still sends it.
### 3.2 The proof of work — no network call
`StopVerificationPage` collects, for a pickup leg: parcel count actually taken,
packaging condition (+ a mandatory note when it is not *Sealed & intact*), total
weight, the pickup code, and one photo of the parcels. Nothing here is posted on
its own. It feeds §3.5 (the weight) and §5.1 (the photo, which on this path is
not uploaded at all).
A count **below** the booked quantity forces a note. That discrepancy is the
single most common cause of a first-mile dispute weeks later, and it is only
knowable at the door.
### 3.3 The price — `POST /pricing/check`
**Unauthenticated.** Called live from the shipment desk as the rider types, so
it must stay cheap.
```jsonc
{
"weight": 2.5, // required, kg, measured not booked
"service_type": "Normal", // "Normal" | "Express"
"pickup_pincode": "641012", // zone is derived server-side from the pair
"delivery_pincode": "641018",
"category": "General" // optional; see the list below
}
```
`category` ∈ `General · Documents · Electronics · Clothing · Fragile · Medical ·
Automotive · Food`.
Response (`data`):
```jsonc
{
"found": true,
"zone": "Local", // Local | Regional | National
"service_type": "Normal",
"weight": 2.5,
"currency": "INR",
"results": [
{ "category": "General", "category_label": "General",
"min_price": 150, "max_price": 180 }
]
}
```
Two contract points that matter:
- **A band, not a number.** The table prices a weight slab in a zone, so it
answers a range. The app quotes `min_price` — that is the figure the customer
was shown when the booking was raised, and quoting the top of a band at a
doorstep is how a rider ends up arguing about money.
- **`found: false` is not zero.** It means no rule covers this
weight/zone/category combination. The app shows "this cannot be priced here"
and refuses to continue. Do not answer `0` for an unpriceable combination —
a zero renders as a free shipment.
The rider does **not** send `zone`. He has just captured two addresses and has
no business deciding what a zone is.
### 3.4 The addresses — `PATCH /miler/bookings/:bookingid/addresses`
**This must land before §3.7.** `pickup-complete` builds the consignment — its
routing hub *and* its pricing zone — from these values, and the handler refuses
an address change once that conversion has happened. There is exactly one window
and this is it.
```jsonc
{
"pickupaddress": "14 Cross Cut Road, Gandhipuram, Coimbatore",
"pickuppincode": "641012",
"pickuplatitude": 11.0168,
"pickuplongitude": 76.9558,
"deliveryaddress": "22 Race Course Road, Coimbatore",
"deliverypincode": "641018",
"deliverylatitude": 11.0043,
"deliverylongitude": 76.9695,
"deliverycity": "Coimbatore"
}
```
Every field is optional and **only non-empty values are applied**. This is a
correction, never a wipe: a booking that arrived with a good pickup address and
a vague destination must keep the good half.
**Failure here stops the flow.** It is the one step in §3 the app refuses to
continue past, because the alternative is a shipment routed and priced from an
address the rider has just been told is wrong, with neither he nor the customer
ever seeing the discrepancy.
### 3.5 The parcels — `POST /miler/bookings/:bookingid/parcel`
```jsonc
{
"parcels": [
{ "weight": 1.25, "length": 0, "width": 0, "height": 0 },
{ "weight": 1.25, "length": 0, "width": 0, "height": 0 }
]
}
```
The rider weighs the **consignment**, not each box, so the total is split evenly
across the collected count. The chargeable total is correct; the per-parcel
figures are a distribution rather than a measurement. Dimensions are sent as
zero — nothing in the flow asks a rider to measure a box, and a made-up number
is worse than an absent one.
`pickup-complete` recomputes chargeable weight from whatever this submitted, so
this is the last moment a measurement can be attached to the shipment at all.
**Failure here does not stop the flow.** The stop completes and bills on the
customer's booked estimate instead of the measured figure. Blocking a rider at
a doorstep over a billing detail is the worse trade.
### 3.6 The money — `POST /miler/bookings/:bookingid/payment`
```jsonc
{ "amount": 150, "paymentmode": "Cash", "transactionref": "" }
```
`paymentmode` ∈ `Cash · UPI · Card · Wallet`. `amount` must be > 0 — the app
skips the call entirely for a prepaid or zero-rated shipment rather than
sending a zero.
**This must precede §3.7.** `pickup-complete` converts the booking into a
consignment, and a payment recorded against a booking that has already been
converted has nothing to attach to. Money first, every time.
The amount is the quote from §3.3, carried through on the stop's
`collectionamt` so the figure the rider showed the customer and the figure the
payment screen asks for cannot drift apart.
> **Miler is the carrier, not the retailer.** This cash belongs to the shipper.
> The app says so on the payment screen and the rider deposits it at the hub —
> the ledger this call writes should reflect custody, not revenue.
### 3.7 The pivot — `POST /miler/bookings/:bookingid/pickup-complete`
```jsonc
{ "latitude": 11.0168, "longitude": 76.9558 }
```
Converts the booking into a consignment, recomputes chargeable weight from
§3.5, mints a tracking number, and decides routing: a shared 3-digit pincode
prefix between pickup and delivery means hyperlocal and the consignment stays in
this rider's hands; otherwise it routes via the hub.
Response must carry the tracking number. The app reads either spelling and shows
it on the success screen:
```jsonc
{ "tracking_no": "DM2608250042" } // "trackingno" also accepted
```
The app calls this through `PickupsController.updatePickedupStatus`, not
directly, so the **geofence** check, the rider-kilometre calculation and the
punctuality bonus all still run. A geofence refusal is not a failure: nothing
was sent, and the rider is told how far off he is.
**Failure here is the one place the app is pessimistic.** Every other status
write in the app is optimistic — a rider who watches a completed stop bounce
back stops trusting the button. Not this one: playing "order created" over a
failed create would send a rider away believing a shipment exists with the
customer's money in his pocket and nothing on the hub's screen.
### 3.8 The proof photo — `POST /miler/uploads/sign` → `PUT <uploadurl>`
Two steps. Step one asks for somewhere to put the image:
```jsonc
{ "purpose": "pickup_proof", "contentType": "image/jpeg", "consignmentid": 4211 }
```
`purpose` ∈ `pickup_proof · delivery_proof · receiver_signature · support`.
```jsonc
{
"uploadurl": "https://…?X-Amz-Signature=…", // expires in 10 minutes
"url": "https://cdn.doormile.com/proofs/…",
"headers": { "x-amz-acl": "private", "Content-Type": "image/jpeg" }
}
```
Step two `PUT`s the bytes to `uploadurl` with **exactly** the headers returned
and nothing else. `x-amz-acl` is part of what was signed, so adding a header of
our own invalidates the signature and the store answers 403. In particular the
bearer token must not be sent to the object store.
A signature that has expired is **re-signed**, not retried against the old URL.
---
## 4. Ordering invariants, stated once
These are the four the app depends on. Three of them are enforced by the
handlers today; they are written down because a future refactor that reorders
them breaks silently rather than loudly.
1. **`addresses` before `pickup-complete`** — routing and zone are built from
them and the handler refuses them afterwards. (§3.4)
2. **`parcel` before `pickup-complete`** — chargeable weight is recomputed from
it, and after conversion there is nothing to attach a measurement to. (§3.5)
3. **`payment` before `pickup-complete`** — a payment cannot attach to a booking
that has become a consignment. (§3.6)
4. **`pickup-complete` before anything consignment-keyed** — the consignment id
does not exist until it returns.
---
## 5. Open, and specific to this line
The general asks live in `MILER_API_REQUIREMENTS.md`. These three shape the
logistics flow in particular.
### 5.1 P0 — pickup proof does not use the signed-upload route
There are **two** upload paths in this app and only one of them is the contract.
| Path | Used by | How |
|---|---|---|
| `MilerApi.uploadProof` → `/miler/uploads/sign` | delivery proof (`delivery_actions.dart`) | server-signed URL, §3.8 |
| `PickupsController.uploadProofImage` | pickup proof: Home's bulk pick, the crate photo | **client-side, straight into the `doormile` Spaces bucket** |
The second one holds a DigitalOcean Spaces access/secret pair in the client. It
is passed by `--dart-define` today rather than being a source literal, but that
only stops the *next* build embedding it: the pair is in this repository's
history and in every APK shipped before the change, it is read-write on the
whole bucket, and it therefore reads and deletes every rider's proof photo for
every tenant. It has to be **rotated**, and the upload has to move behind
`/uploads/sign` like delivery proof already is.
Separately, the single-stop logistics path does not upload at all: the
verification page takes a photo of the parcels and `updatePickedupStatus` sends
`proofImage: ''`. A disputed first-mile collection has the rider's word and a
count, and no picture.
**Asks:**
1. Rotate the Spaces credential pair. This is not conditional on anything below.
2. Confirm `/uploads/sign` accepts `purpose: "pickup_proof"` keyed on a
**`bookingid`** — the photo is taken before §3.7, so no consignment id
exists yet. `consignmentid` is the only resource key the sign call documents
today.
3. With (2) answered, the app moves both pickup paths onto §3.8 and
`uploadProofImage` is deleted.
### 5.2 P0 — `step` is `0` on every booking row
`GET /miler/bookings` returns `step`, `stoptype`, `etaminutes`, `cumulativekms`
and `cumulativeeta`, and the app consumes all five. `step` arrives as `0` on
every live row, so the admin's route order cannot be followed and the app falls
back to sorting nearest-first. This is request 13 in the main document and it is
the largest single gap on this line: a rider works a hub-planned route by
guessing at it.
### 5.3 P2 — `vehicle-required` has no way in
`POST /miler/bookings/:id/vehicle-required` exists and is wired
(`UpdatePickupProvider.requireVehicle`) but is unreachable — there is no control
anywhere in the app for "this doesn't fit on a bike". That is a design question
about where a rider says it, not a backend gap. Listed so the endpoint is not
assumed dead and removed.
---
## 6. Field-name appendix
The wire uses lowercase, unseparated names. The app's own maps use camelCase and
translate at the adapter boundary; these are the names on the wire.
| Wire | Type | Where |
|---|---|---|
| `bookingid` | int | path param, §3.1 – §3.7 |
| `consignmentid` | int | after §3.7; `/uploads/sign`, `/consignments/*` |
| `pickupaddress` `pickuppincode` `pickuplatitude` `pickuplongitude` | string / string / num / num | §3.4 |
| `deliveryaddress` `deliverypincode` `deliverylatitude` `deliverylongitude` `deliverycity` | string / string / num / num / string | §3.4 |
| `parcels[].weight` `.length` `.width` `.height` | num | §3.5 |
| `amount` `paymentmode` `transactionref` | num / enum / string | §3.6 |
| `latitude` `longitude` | num | §3.1, §3.7 — **rider's position** |
| `tracking_no` \| `trackingno` | string | §3.7 response |
| `weight` `service_type` `pickup_pincode` `delivery_pincode` `category` | num / enum / string / string / enum | §3.3 request |
| `found` `zone` `currency` `results[].min_price` `.max_price` | bool / enum / string / num / num | §3.3 response |
| `purpose` `contentType` | enum / string | §3.8 request |
| `uploadurl` `url` `headers` | string / string / object | §3.8 response |
| `step` `stoptype` `etaminutes` `cumulativekms` `cumulativeeta` | int / enum / int / num / int | `GET /miler/bookings` row |
---
## 7. What this line does *not* call
Recorded so a handler is not written for a caller that does not exist:
- `POST /miler/deliveries/start` — never existed, never call it. `pickup-complete`
decides routing itself.
- `POST /miler/consignments/:id/start-delivery` — the **release**, and only
reachable when a logistics consignment stays in the rider's hands
(hyperlocal). A shipment routed via the hub leaves his custody at §3.7 and he
never delivers it.
- Everything under the meal line's collect-a-crate path. A milk run reaches
§3.1 and then its own confirmation sheet; §3.2 – §3.8 do not run.