Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
37 KiB
Doormile — Backend Requirements (Customer App v1)
Audience: backend team
Client: doormile_cx — Flutter customer app, Android/iOS, app id in.doormile.customer
Client status: UI complete, flutter analyze clean, 20 widget tests green. It runs
entirely against an in-app mock (lib/data/doormile_api.dart) — every value on every
screen comes from that one file. This document specifies the service that replaces it.
Match these shapes and no screen changes are required.
Read §2 first. A production Doormile backend already exists and the Miler app is live against it. The customer app is a new namespace on that same service, not a new system, and it must read the records the Miler app writes. Anything that treats the customer API as greenfield will produce two databases and a reconciliation problem.
1. The product model you are implementing
This is not the usual courier model. Read this before the endpoints.
The customer books a pickup, not a shipment.
Pickup booking (reference: DM-482913)
├── destination 1 : Chennai, Tamil Nadu · 2 packages
├── destination 2 : Ernakulam, Kerala · 1 package
└── destination 3 : Bengaluru Urban, KA · 1 package
│
the Miler collects everything in ONE visit
│
▼
Orders are created HERE — one per destination
order 1: DMX10482913 order 2: DMX10559120 order 3: DMX10662004
each with its own tracking number and its own journey
Consequences you must honour:
- No tracking number exists at booking time.
POST /customer/bookingsreturns areferenceonly. Tracking numbers are minted per destination when the Miler completes pickup (stageorder_created). - One booking → 1..N destinations → 1..N orders. A single-destination booking is the common case and must not be a special case in the schema.
- Weight is never collected from the customer. The Miler weighs and photographs each package at the door — that is also when the price settles. Everything the customer sees before then is an estimate range.
- Only state + district are required per destination. Street, building, landmark, recipient name/phone and instructions are optional at booking time and may be completed by the Miler at pickup. The UI shows missing ones as "Not added — the Miler can confirm this at pickup."
- Doormile is the carrier, not the seller. The goods belong to the customer or to the customer's own buyer. Any money at the door is the customer's, collected on their behalf. Never write copy or schema that treats Doormile as the merchant.
- Cancellation is whole-pickup only, and only up to and including
arrived.
Glossary
| Term | Meaning |
|---|---|
| Miler | The agent who collects from the customer's door. Also used for the delivery agent. |
| Pickup booking | What the customer creates. Identified by reference, format DM-######. |
| Destination (group) | One place inside a booking + how many packages go there. |
| Order | Created per destination at pickup completion. Identified by trackingId, format DMX########. |
| Consignment | The existing backend's name for what the customer calls an order (delivery leg). |
| Stage | Operational status, 9 values (§8). |
| Milestone | The rolled-up status the customer sees, 7 values (§8). |
2. Where this fits: the backend that already exists
The Miler (rider) app runs against https://api.doormile.com/api/v1 with a bearer
JWT, and uses ~40 endpoints, all under /miler/*:
/miler/login /miler/verify-pin /miler/profile
/miler/duty/current /miler/duty/start /miler/duty/end
/miler/assignments /miler/assignments/{id}/accept | /reject
/miler/bookings /miler/bookings/status
/miler/bookings/{id}/reached /miler/bookings/{id}/parcel
/miler/bookings/{id}/pickup-complete /miler/bookings/{id}/payment
/miler/bookings/{id}/addresses /miler/bookings/{id}/cancel | /skip
/miler/consignments/{id} /miler/consignments/{id}/start-delivery
/miler/consignments/{id}/deliver /miler/consignments/{id}/inward-at-hub
/miler/location /miler/notifications /miler/device-token /miler/support …
Therefore:
- The customer API is a sibling namespace,
/customer/*, on the same host and the samebooking/consignmentrecords. A customer booking must become the same row the Miler app later reads from/miler/bookings; a customer "order" must be the same row as aconsignment. - §9 ("what ops must be able to write") is mostly already built.
reached,parcel,pickup-complete,start-delivery,deliver,inward-at-hubalready exist. What is missing is not the writes — it is the derivation of the customer's 9 stages from those writes (§8.3) and the read endpoints that expose them. - Conventions below are the real ones, taken from the live service, not invented.
2.1 Corrections to the previous draft of this document
The earlier version of this file was written before the live backend was inspected. If you were given that version, these four things were wrong:
| Was written | Reality |
|---|---|
Base URL https://api.doormile.in/v1 |
https://api.doormile.com/api/v1 |
Envelope { "data": … } |
{ "success": bool, "data": …, "message": string }; lists add total |
| A greenfield ops/"Miler app" section | Those endpoints exist; only the customer projection is missing |
| Timestamps assumed epoch millis end-to-end | The live service sends naive IST wall-clock strings, sometimes with a spurious trailing Z (§3.3) |
3. Conventions
| Item | Requirement |
|---|---|
| Base URL | https://api.doormile.com/api/v1 + a staging host with the same contract |
| Namespace | /customer/* for everything in this document |
| Transport | HTTPS only, TLS 1.2+ |
| Format | JSON, UTF-8 |
| Success envelope | { "success": true, "data": <payload>, "message": "" } |
| List envelope | `{ "success": true, "data": [...], "total": , "nextCursor": <string |
| Error envelope | { "success": false, "message": "<customer-safe English>", "error": { "code": "…" } } |
| Auth | Authorization: Bearer <accessToken> on everything except §4.1–§4.3 |
| Money | Integer rupees, not paise. min: 49 renders as ₹49 |
| Client headers | X-Client: doormile-cx/<version>+<build>, X-Platform: android|ios — accept and log |
| Request id | Echo X-Request-Id on every response, including errors |
| Display strings | Any human string (day, window, expectedDelivery) is formatted server-side in IST |
3.1 Envelope consistency — one thing not to copy
/miler/verify-pin returns its payload outside data, as
{success, token, user:{…, profile:{…}}}. That inconsistency cost the Miler client a
release to discover. Do not repeat it here. Every /customer/* response — auth
included — puts its payload in data.
3.2 Field naming
The /miler/* surface uses lowercase run-together keys (bookingid, createdat,
slotstarttime). The customer client models are camelCase (districtCode,
packageCount, trackingId).
Preferred: serve /customer/* in camelCase exactly as specified below. If your ORM
makes that expensive, say so — the client will add one mapping layer — but decide now
and freeze it, because a mixed-case response is what forces the client to guess.
3.3 Timestamps — decide this explicitly
The client currently parses DateTime.fromMillisecondsSinceEpoch(int).
The live service emits naive IST wall-clock strings, occasionally suffixed Z for a
timezone they are not in.
- Preferred:
/customer/*sends epoch milliseconds, UTC, integer forcreatedAt,history[].at,verification.capturedAt,deliveredAt. - If you cannot: send ISO-8601 with a real offset (
2026-09-04T14:30:00+05:30). Tell us and the client adds a tolerant parser. - Never send a naive local string with a
Zon it. That is a wrong instant, not a formatting nit, and it has already produced "yesterday's work shown as today" bugs on the Miler app.
3.4 Error contract
Every failure returns the error envelope. The app funnels all failures into one error
state with a Retry, and shows message verbatim — so it must be customer-safe
English, never an enum key, stack trace or HTML page.
| HTTP | error.code |
When | message shown |
|---|---|---|---|
| 400 | invalid |
Validation failed | "Every destination needs a serviceable state and district" |
| 400 | invalid_name |
Name < 2 chars at signup | "Enter your full name" |
| 401 | invalid_otp |
Wrong or expired code | "That code did not match" |
| 401 | unauthorized |
Missing/expired access token | "Please sign in again" |
| 403 | forbidden |
Token valid, resource not the caller's | "You do not have access to this" |
| 404 | not_found |
Unknown reference / state / order | Context-specific |
| 409 | conflict |
Cancelling after the window; slot filled | "This pickup can no longer be cancelled" |
| 422 | unserviceable |
District closed between selection and booking | "That district is no longer available" |
| 429 | rate_limited |
Throttled; include Retry-After |
"Too many attempts. Try again in a minute" |
| 5xx | server_error |
Anything else | "Something went wrong" |
| — | network |
Client-side only | "We could not reach Doormile" |
3.5 Non-functional
- Latency: p95 ≤ 400 ms for every GET in §5 (each sits behind a loading skeleton);
p95 ≤ 1.2 s for
POST /customer/bookings. - Idempotency:
POST /customer/bookingsandPOST /customer/auth/otp/verifymust acceptIdempotency-Keyand replay the original response for 24 h. The client retries on flaky networks; duplicate pickups are unacceptable. - Caching: serviceability supports
ETag/If-None-Match. Slots are volatile —Cache-Control: max-age=30at most. - Rate limits: OTP request ≤ 5 per number per hour; ≤ 3 verify attempts per code.
- Pagination:
?limit=&cursor=, default 20, newest first,nextCursorin the envelope. - PII: phone, email, recipient name/phone and delivery instructions are PII. Encrypt at rest, redact in logs, never leak across customers.
- Audit: every stage transition recorded with actor (miler id / ops user / system), timestamp and source. The customer timeline is derived from this, so it must be real.
- Tenancy: the JWT already carries a
tenantidclaim. Customer tokens must carry it too, and every read must be tenant-scoped server-side.
4. Auth & session
4-digit OTP over phone (primary) or email. No password anywhere in the app.
Note this is deliberately not the Miler's phone+PIN flow — do not reuse verify-pin.
Phone is currently sent as +91 98765 43210 (with spaces). Normalise to E.164
server-side and accept both; the client will be tightened to send E.164.
4.1 POST /customer/auth/otp/request
// request
{ "identifier": "+919876543210" } // or "you@example.com"
// 200
{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30, "codeLength": 4 } }
- The resend countdown is server-driven; the client stops hardcoding 30 s once this ships.
- Code TTL 5 minutes, single use.
- Errors:
rate_limited,invalid.
4.2 POST /customer/auth/signup
// request
{ "name": "Joe Oommen", "phone": "+919876543210", "email": "joe@example.com" } // email optional
// 200 — creates the account AND sends the OTP
{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30 } }
name≥ 2 chars, elseinvalid_name.- If the phone already exists, do not error — treat it as a sign-in and send the code. The UI has no "account exists" state. Object now if you disagree.
4.3 POST /customer/auth/otp/verify
// request
{ "identifier": "+919876543210", "code": "4821", "name": "Joe Oommen" } // name only on signup
// 200
{
"success": true,
"data": {
"accessToken": "…", "refreshToken": "…", "expiresIn": 3600,
"customer": { "id": "cust_10241", "name": "Joe Oommen",
"phone": "+919876543210", "email": "joe@example.com" }
}
}
name,phone,emailare all rendered on Account and drive the avatar initials.emailmust not be null — the client types it asString. Send""if unknown.- Errors:
invalid_otp.
4.4 POST /customer/auth/refresh · POST /customer/auth/logout · GET /customer/auth/me
- Refresh:
{ "refreshToken": "…" }→ new access token; rotation preferred; refresh TTL 60 days so the customer stays signed in. - Logout: revokes the refresh token and unregisters the device push token.
GET /customer/auth/me→ the samecustomerobject, for cold-start session restore.
Session persistence is client work (§Appendix B) but it cannot start until refresh exists. Ship §4.3 and §4.4 together.
5. Catalogue & configuration
These four drive the entire booking form. Highest priority — nothing else works without them.
5.1 GET /customer/serviceability/states
{ "success": true, "data": [
{ "code": "TN", "name": "Tamil Nadu", "districtCount": 6, "transitTag": "Ultra-fast transit" },
{ "code": "KL", "name": "Kerala", "districtCount": 3, "transitTag": "Next-day transit" },
{ "code": "PY", "name": "Puducherry", "districtCount": 0, "transitTag": "Opening soon" }
]}
| Field | Type | Req | Notes |
|---|---|---|---|
code |
string | yes | Stable; stored on the booking |
name |
string | yes | Display name |
districtCount |
int | yes | Count of available districts only. The client hides any state with 0 |
transitTag |
string | no | ≤ 22 chars |
Return [] when nothing is serviceable — the app has a designed "no service" state.
5.2 GET /customer/serviceability/states/{stateCode}/districts
{ "success": true, "data": [
{ "code": "TN-CBE", "name": "Coimbatore", "available": true,
"hub": "Coimbatore Central Hub", "promise": "Next-day delivery",
"lat": 11.0168, "lng": 76.9558 },
{ "code": "TN-MDU", "name": "Madurai", "available": false, "note": "Opening soon",
"lat": 9.9252, "lng": 78.1198 }
]}
| Field | Type | Req | Notes |
|---|---|---|---|
code / name |
string | yes | Stable code + display name |
available |
bool | yes | false districts are not offered |
note |
string | no | Why not: "Opening soon", "Paused this week" |
hub |
string | no | Serving hub, shown on the destination card |
promise |
string | no | "Next-day delivery" / "2-day delivery" |
lat / lng |
number | no | Where the hub is. The route map draws pickup → hub from these. Omit them and the map draws the pickup end only — no line, no destination marker |
Return the full list including unavailable districts — the client filters them out
of the picker but uses their names for a quiet "Coming soon" line.
Unknown stateCode → 404 not_found, "That state is no longer serviceable".
5.3 GET /customer/pickup-slots?lat=&lng=
{ "success": true, "data": [
{ "id": "slot_t_1", "day": "Today", "window": "2:00 – 4:00 PM", "available": true,
"tag": "Fastest pickup", "milersNearby": 4, "caption": "Arriving in approx. 45 mins" },
{ "id": "slot_t_3", "day": "Today", "window": "6:00 – 8:00 PM", "available": false,
"note": "Fully booked" }
]}
| Field | Type | Req | Notes |
|---|---|---|---|
id |
string | yes | Opaque; sent back as slotId |
day |
string | yes | "Today" / "Tomorrow" / "Mon, 8 Sep" — server-formatted, IST |
window |
string | yes | "2:00 – 4:00 PM" (en dash, spaced) |
available |
bool | yes | Capacity remaining in the customer's zone |
note |
string | no | "Fully booked" |
tag |
string | no | At most one slot carries "Fastest pickup" |
milersNearby |
int | no | 0 hides the line |
caption |
string | no | "Arriving in approx. 45 mins" |
Slots are capacity- and location-aware — use the lat/lng of the pickup point.
Roughly today + tomorrow; the design expects ~6 windows.
The slot ids must resolve to whatever the Miler assignment engine consumes — a customer
slot that ops cannot staff is worse than no slot.
5.4 GET /customer/config/booking-limits
{ "success": true, "data": { "maxPackages": 20, "maxDestinations": 5 } }
Nothing in the UI hardcodes these; they exist so ops can vary them by city or tier
without an app release. Must degrade safely (client keeps 20/5 on failure). Never 0.
If they become per-city, key them off the pickup location and tell us — the client will
re-fetch when the pickup point moves.
6. Location
Maps in the customer app are real as of this revision: flutter_map over
OpenStreetMap/CARTO raster tiles, and geolocator for the device fix. No key is held
by the client, which matches the decision already taken on the Miler side. The
coordinates reaching these endpoints are real GPS coordinates.
6.1 GET /customer/places/reverse-geocode?lat=&lng=
{ "success": true, "data": { "title": "12 Nehru Street",
"sub": "Gandhipuram, Coimbatore 641012", "lat": 11.0183, "lng": 76.9725 } }
title = short label (≤ 32 chars), sub = full address line. Both required, neither
nullable. Used to pre-fill the pickup point from the device location.
This is the hottest endpoint in the booking flow. The pickup screen puts the pin at the centre of the map and moves the map under it; every time the map comes to rest, this is called and the answer is what the customer reads in the box below. Target p95 under 300 ms and cache by rounded coordinate (~5 decimal places is more precision than any door needs). The client already debounces 320 ms after the map settles and discards a response that a later drag has superseded, so you will not get a request per frame — but you will get one per correction, and each one is on the critical path to "Next".
6.2 GET /customer/places/search?q=&lat=&lng=
{ "success": true, "data": [ { "title": "Brookefields Mall",
"sub": "Brookebond Road, Coimbatore 641001", "lat": 10.9987, "lng": 76.9628 } ] }
- Empty
q→ the customer's recent/saved places (≤ 4). The search sheet opens on this. - Bias to
lat/lngand to serviceable areas. - Proxy the geocoder through the backend — the Miler app's Google Maps key was revoked and that side now runs on OSM/OSRM with no key. Do not hand the customer app a key to hold; serve results and cache them.
7. Fare estimate
POST /customer/fare/estimate
// request
{ "pickup": { "lat": 11.0183, "lng": 76.9725 },
"destinations": [
{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2 },
{ "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 } ] }
// 200
{ "success": true, "data": {
"min": 167, "max": 267,
"paymentMethod": "UPI · Cash at doorstep",
"parcel": "3 boxes (up to 3 kg each)",
"routeKm": 6.4 } }
| Field | Type | Notes |
|---|---|---|
min / max |
int (₹) | Rendered "₹167 – ₹267". An estimate range, never final |
paymentMethod |
string | Shown as-is on Review and the receipt |
parcel |
string | What is being priced, e.g. "Standard box (up to 3 kg)" |
routeKm |
number | Pickup→destination distance, drives the route outline and receipt |
- Called on every route or package-count change — must be cheap and cacheable.
- A failed estimate must not block booking; the client swallows the error.
- Must be the combined price for one visit, including multi-stop uplift.
8. Stages, milestones and how they are derived
8.1 The nine stages
The backend owns nine operational stages; the customer sees seven milestones. The
rollup happens on the client — always send the raw stage key, lowercase snake_case,
spelled exactly as below. An unknown key silently falls back to booked on the client,
so new keys require a client release.
| # | Stage key | Customer milestone | Must also be true |
|---|---|---|---|
| 0 | booked |
Pickup booked | Set by POST /customer/bookings |
| 1 | assigned |
Miler assigned | miler object present |
| 2 | on_the_way |
Pickup in progress | milerDistanceKm, milerEtaMinutes set |
| 3 | arrived |
Pickup in progress | Distance 0, ETA 0. Last cancellable stage |
| 4 | picked_up |
Package collected | verification populated; amountPaid settles |
| 5 | order_created |
Package collected | trackingId minted per destination; expectedDelivery set |
| 6 | in_transit |
In transit | Per-order from here on |
| 7 | out_for_delivery |
Out for delivery | deliveryAgent present |
| 8 | delivered |
Delivered | deliveredAt set; booking status → completed |
- Stages 0–5 belong to the booking; 6–8 belong to each order and may differ between destinations of the same booking. The client already renders independent journeys.
- The timeline is built from a history array — one entry per stage actually reached, with the real timestamp. Do not synthesise or backfill times. (The mock does; that is a prototype affordance, not a spec.)
status(active/completed/cancelled) is derived but must be sent explicitly. Do not make the client infer it.
8.2 Milestone rollup (client-side, for your reference)
booked → Pickup booked
assigned → Miler assigned
on_the_way | arrived → Pickup in progress
picked_up | order_created → Package collected
in_transit → In transit
out_for_delivery → Out for delivery
delivered → Delivered
8.3 Derivation from the writes that already exist ← the actual work
| Existing Miler write | Customer stage it must produce |
|---|---|
assignment created / POST /miler/assignments/{id}/accept |
assigned (+ miler from the accepting rider) |
| rider goes on-route (duty + location stream) | on_the_way (+ distance/ETA from /miler/location) |
POST /miler/bookings/{id}/reached |
arrived — cancellation closes here |
POST /miler/bookings/{id}/parcel (weight + photos) |
populates verification |
POST /miler/bookings/{id}/pickup-complete |
picked_up, then order_created; mint one consignment + trackingId per destination; settle amountPaid |
POST /miler/consignments/{id}/inward-at-hub |
in_transit (per order) |
POST /miler/consignments/{id}/start-delivery |
out_for_delivery (+ deliveryAgent) |
POST /miler/consignments/{id}/deliver |
delivered, deliveredAt, booking → completed |
POST /miler/bookings/{id}/cancel | /skip, or ops cancel |
cancelled + cancelReason |
If any of these does not currently emit an event the customer projection can read, that is the gap to close first.
8.4 Known gaps in the existing backend that block this
Found while integrating the Miler app; each one has a customer-visible consequence:
| Gap | Customer-app consequence |
|---|---|
Booking carries no assignmentid / consignmentid (the app assumes == bookingid) |
Multi-destination bookings cannot mint N orders; §1 breaks |
| No COD / collection amount on the booking object | amountPaid and the receipt cannot settle |
No per-stop type (pickup vs delivery) or step ordering |
Stages 6–8 cannot be attributed to the right order |
| No skip/resume and no customer-side cancel endpoint | §9.4 cannot be built |
| Parcel weight/photos not exposed on any read | verification block stays empty; the receipt loses its evidence |
9. Bookings
9.1 POST /customer/bookings — create the pickup
// request (Idempotency-Key header required)
{
"pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012",
"lat": 11.0183, "lng": 76.9725 },
"slotId": "slot_t_1",
"destinations": [
{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2,
"details": { // every field optional, may be omitted entirely
"street": "12th Main", "building": "3B", "landmark": "Near bus stand",
"recipientName": "Meera S", "recipientPhone": "+919884412210",
"instructions": "Call before delivery",
"pin": { "lat": 13.0827, "lng": 80.2707 } } },
{ "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 }
],
"estimate": { "min": 167, "max": 267 } // what the customer was shown, for dispute audit
}
Server-side validation — mirror exactly, the client displays your message:
| Rule | Error |
|---|---|
≥ 1 destination, each with serviceable stateCode + districtCode |
400 invalid — "Every destination needs a serviceable state and district" |
slotId present and still available |
400 invalid — "Pick a pickup slot" / 409 conflict — "That pickup window just filled up" |
packageCount ≥ 1, total ≤ maxPackages |
400 invalid — "Up to 20 packages per pickup" |
destinations ≤ maxDestinations |
400 invalid — "Up to 5 destinations per pickup" |
district still available |
422 unserviceable |
Response: the full booking object (§9.3) with reference DM-######,
stage: "booked", status: "active", cancellable: true, a one-entry history, and
no trackingId on any destination.
9.2 GET /customer/bookings?status=active|completed|cancelled&limit=&cursor=
Backs the Orders tabs, Home's "Recent" (3 most recent non-active) and pull-to-refresh. Array of booking objects, newest first.
9.3 GET /customer/bookings/{reference} — the canonical object
The single most important response in the API: the tracking screen and the receipt are both rendered from it.
{ "success": true, "data": {
"reference": "DM-482913",
"stage": "in_transit",
"status": "active",
"cancellable": false,
"createdAt": 1757056800000,
"pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012",
"lat": 11.0183, "lng": 76.9725 },
"slotId": "slot_t_1",
"destinations": [
{ "stateCode": "TN", "stateName": "Tamil Nadu",
"districtCode": "TN-MAA", "districtName": "Chennai",
"packageCount": 2,
"district": { "code": "TN-MAA", "name": "Chennai", "available": true,
"hub": "Chennai Guindy Hub", "promise": "Next-day delivery" },
"details": { "street": "12th Main", "recipientName": "Meera S",
"recipientPhone": "+919884412210" },
"trackingId": "DMX10482913", // null until order_created
"stage": "in_transit", // null until order_created
"verification": { // null until picked_up
"weightKg": 2.8,
"photos": ["https://cdn.doormile.com/pv/abc.jpg"],
"capturedAt": 1757060400000,
"capturedBy": "Arun Kumar" } }
],
"miler": { "name": "Arun Kumar", "vehicle": "TN 37 BX 4412", "phone": "+919000011223",
"rating": 4.9, "trips": 1240, "vehicleType": "E-Scooter" },
"deliveryAgent": null, // populated from out_for_delivery
"milerDistanceKm": null, // set during on_the_way / arrived
"milerEtaMinutes": null,
"milersInZone": 3, // shown while finding a Miler
"routeKm": 6.4,
"expectedDelivery": "Thu, 12 Sep", // server-formatted, IST
"fare": { "min": 49, "max": 64, "paymentMethod": "UPI · Cash at doorstep",
"parcel": "Standard box (up to 3 kg)" },
"amountPaid": 64, // null until picked_up
"deliveredAt": null,
"cancelReason": null,
"history": [
{ "stage": "booked", "at": 1757056800000 },
{ "stage": "assigned", "at": 1757057400000 },
{ "stage": "on_the_way", "at": 1757058900000 },
{ "stage": "arrived", "at": 1757059800000 },
{ "stage": "picked_up", "at": 1757060400000 },
{ "stage": "order_created", "at": 1757060460000 },
{ "stage": "in_transit", "at": 1757062200000 }
]
}}
Field notes that matter:
destinations[].stateName/districtNameare required — the client renders "Chennai, Tamil Nadu" from them and does not look codes up.pickupandslotIdmust be present on every booking, including cancelled ones — the client parser types them non-nullable and will throw onnull.verification.photosmust be fetchable URLs (signed, ≥ 15 min TTL), one per package.weightKgis the number the price settled on; shown as "2.8 kg".miler.phonepowers a Call Miler action. Real dialable number or masked-calling proxy — say which. Masked preferred.amountPaidis the settled total in ₹, present frompicked_up. The receipt rendersamountPaid − fare.minas "Weight adjustment", so keepfareon the booking forever, even after settlement.expectedDeliveryis a display string, formatted server-side in IST.historyis append-only and ordered; every timestamp on the timeline comes from it.
9.4 POST /customer/bookings/{reference}/cancel
// request
{ "reason": "Package not ready" } // optional; free text or one of the 5 presets
// 200
{ "success": true, "data": { "reference": "DM-482913", "status": "cancelled",
"cancelReason": "Package not ready" } }
- Preset reasons in the UI: Booked by mistake · Package not ready · Sending it another
day · Changed the destination · Other.
reasonmay benull. - Allowed only through
arrived. Frompicked_uponward return409 conflict. The server is the authority;cancellableon the booking mirrors the same policy so the UI can hide the button, but the server must re-check. - Cancels the whole pickup, every destination. No partial cancellation in v1.
- The UI promises "You can cancel free of charge until the Miler collects your package." — confirm no fee applies in that window.
9.5 PATCH /customer/bookings/{reference}/destinations/{index}
The customer can fill in street / landmark / recipient / instructions / map pin after booking, up to collection.
{ "street": "12th Main", "landmark": "Near bus stand",
"recipientName": "Meera S", "recipientPhone": "+919884412210",
"instructions": "Call before delivery", "pin": { "lat": 13.08, "lng": 80.27 } }
- Any subset;
nullclears a field. - Accept until
picked_up; after that409 conflict. - These edits must reach the Miler app in near real time — that app reads addresses
via
/miler/bookings/{id}/addresses, so the write must land on the same record.
9.6 GET /customer/orders/{trackingId}
One order by tracking number, returning the booking object focused on that destination. Needed for push deep links. A slimmer order object is acceptable if you prefer — say so; the client currently reuses the booking shape.
10. Live updates
The tracking screen currently advances through a debug stepper. Production needs real events. Required for launch:
-
Push (FCM + APNs) — one notification per customer-visible milestone change:
{ "type": "stage_change", "reference": "DM-482913", "trackingId": "DMX10482913", // null before order_created "stage": "out_for_delivery", "title": "Out for delivery", "body": "Arriving today at the delivery address.", "deepLink": "doormile://track/DM-482913" }POST /customer/devicesregisters{ token, platform, appVersion }; logout unregisters. (The Miler side already has/miler/device-token— mirror it.)- Do not notify on every operational stage:
on_the_wayandorder_createdroll up on the timeline. Agree the notification set with product. - ⚠️ The client has no deep-link intent filter yet (§Appendix B) — send the
deepLinkfield from day one; the client will start honouring it.
-
Polling fallback:
GET /customer/bookings/{reference}is polled while tracking is open. Make it cheap; supportIf-None-Match→304. -
Phase 2, optional: SSE/WebSocket for live Miler position while
on_the_way. The Miler app already streams to/miler/location, so the data exists. The customer UI shows distance and ETA and will simply not update them without this.
11. Deliverables from the backend team
- OpenAPI 3.1 spec for §4–§10 plus a Postman collection.
- Staging environment seeded to mirror the mock, because these exact cases back our widget tests and design QA: Tamil Nadu / Kerala / Karnataka open; Puducherry serviceable with no open district; Madurai / Kozhikode / Mangaluru unavailable with a reason; at least one fully-booked slot.
- Test accounts + a fixed staging OTP (e.g.
1234) so automated tests can sign in. - A way to force a booking to any stage on staging (ops endpoint or console button). Every tracking state must be reachable for QA — this is what lets us delete the debug stepper.
- Documented error responses, ideally error injection, so the app's empty / error / retry states can be verified against the real service.
- A written answer to §13.
12. Suggested delivery order
| Phase | Endpoints | Unblocks |
|---|---|---|
| 1 | §5.1 §5.2 §5.3 §5.4 | The whole booking form. Highest priority |
| 2 | §4 auth (request / verify / refresh / me) | Real sign-in + session persistence |
| 3 | §7 estimate, §9.1 create, §9.2 list, §9.3 detail | End-to-end booking |
| 4 | §8.3 stage derivation from the existing Miler writes | Real tracking; removes the debug stepper |
| 5 | §9.4 cancel, §9.5 details patch | Feature-complete v1 |
| 6 | §10 push, §6 places | Live updates and real pickup-point search |
Phase 4 is the one with hidden cost — it depends on §8.4 being closed first.
13. Open questions — please answer in writing
- Payment. The app shows "UPI · Cash at doorstep" and an
amountPaidbut has no payment step. Is v1 cash/UPI-at-the-door, settled outside the app? If an in-app payment or payment link is coming, we need the contract now. Note the money at the door may be the customer's own COD collection — confirm who owns that flow. - Masked calling for
miler.phone— real number or proxy? - Partial pickup. What happens if the Miler collects some destinations but not all? The model has no partial-pickup state today.
- Failed delivery / reattempt. Does it exist operationally? There is no customer screen for it, and no stage key. If it exists we need both.
- Saved addresses, notification preferences, payment methods — rows exist on the
Account screen with no backend. In v1 scope? If yes we need
GET/POST/DELETE /customer/addressesand a preferences endpoint. - Support. "Need help with this order?" is a dead end today. Chat, ticket API
(the Miler side has
/miler/support), or a phone number? - Slot capacity semantics. Is
availableper zone, per Miler count, or a hard cap? It decides how often the client re-fetches before confirming. - Pricing. Who owns the estimate formula, and does the customer see a breakdown
when
amountPaidexceedsfare.max? - Cancellation fee — confirmed free through
arrived? - Retention for parcel photos and PII.
- Tenancy. Does the customer app serve both the logistics line and the meal line,
or logistics only in v1? The Miler app switches its entire mode on
tenantid.
Appendix A — Client source of truth
| Contract element | File |
|---|---|
Every model + fromJson |
lib/data/models.dart |
| Every endpoint the app calls (currently mocked) | lib/data/doormile_api.dart |
| Stage side-effects the backend must perform | AppState._applyStage, lib/state/app_state.dart |
| End-to-end flow the contract must satisfy | test/booking_flow_test.dart (18 tests) |
To go live, each method body in DoormileApi becomes a network call and the return
types stay identical. No screen changes are needed.
Appendix B — Client-side work to do alongside this
Not backend scope, listed so nobody plans around capabilities the app does not yet have:
pubspec.yamlstill has no network dependency — nohttp, noshared_preferences, no push SDK. Those three sections need packages added first. Maps and geolocation are no longer on this list:flutter_map(OpenStreetMap/CARTO tiles, no key) andgeolocatorare in and working on both maps.District.lat/lng(§5.2) are parsed and drive the route map. Until the API sends them the app falls back to a built-in table of district centroids.Booking.fromJsondoes not yet parsemiler,deliveryAgent,fare,verification,history,amountPaid,deliveredAt,status, per-destinationdetails/district/stage(§9.3).DestinationandDeliveryDetailshave nofromJson/toJsonyet.- No deep-link intent filter in
AndroidManifest.xmland no URL types on iOS, sodoormile://track/…does nothing today (§10). - Location permissions are declared now (
ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION,NSLocationWhenInUseUsageDescription) and the pickup pin comes from the real device fix; only the address behind it is still mocked. - Session persistence is unbuilt — it depends on §4.4.
- The tracking screen's
_StageStepperis the prototype stand-in for real events; deleting it is the only client change needed once §10 lands.