438 lines
18 KiB
Markdown
438 lines
18 KiB
Markdown
# 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.
|