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

18 KiB
Raw Permalink Blame History

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

{ "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.

{
  "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):

{
  "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.

{
  "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

{
  "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

{ "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

{ "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:

{ "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:

{ "purpose": "pickup_proof", "contentType": "image/jpeg", "consignmentid": 4211 }

purpose ∈ pickup_proof · delivery_proof · receiver_signature · support.

{
  "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 PUTs 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.