# 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 ` 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: ::` on every write that moves money or state: ``` payment::20260825 pickup-complete::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 ` 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.