18 KiB
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: falseis 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 answer0for 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.
addressesbeforepickup-complete— routing and zone are built from them and the handler refuses them afterwards. (§3.4)parcelbeforepickup-complete— chargeable weight is recomputed from it, and after conversion there is nothing to attach a measurement to. (§3.5)paymentbeforepickup-complete— a payment cannot attach to a booking that has become a consignment. (§3.6)pickup-completebefore 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:
- Rotate the Spaces credential pair. This is not conditional on anything below.
- Confirm
/uploads/signacceptspurpose: "pickup_proof"keyed on abookingid— the photo is taken before §3.7, so no consignment id exists yet.consignmentidis the only resource key the sign call documents today. - With (2) answered, the app moves both pickup paths onto §3.8 and
uploadProofImageis 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-completedecides 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.