# 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: 1. **No tracking number exists at booking time.** `POST /customer/bookings` returns a `reference` only. Tracking numbers are minted **per destination** when the Miler completes pickup (stage `order_created`). 2. **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. 3. **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. 4. **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."* 5. **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. 6. **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 same `booking` / `consignment` records.** 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 a `consignment`. - **§9 ("what ops must be able to write") is mostly already built.** `reached`, `parcel`, `pickup-complete`, `start-delivery`, `deliver`, `inward-at-hub` already 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": , "message": "" }` | | List envelope | `{ "success": true, "data": [...], "total": , "nextCursor": }` | | Error envelope | `{ "success": false, "message": "", "error": { "code": "…" } }` | | Auth | `Authorization: Bearer ` on everything except §4.1–§4.3 | | Money | Integer **rupees**, not paise. `min: 49` renders as `₹49` | | Client headers | `X-Client: doormile-cx/+`, `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** for `createdAt`, `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 `Z` on 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/bookings` and `POST /customer/auth/otp/verify` must accept `Idempotency-Key` and 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=30` at most. - **Rate limits:** OTP request ≤ 5 per number per hour; ≤ 3 verify attempts per code. - **Pagination:** `?limit=&cursor=`, default 20, newest first, `nextCursor` in 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 `tenantid` claim. 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` ```jsonc // 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` ```jsonc // 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, else `invalid_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` ```jsonc // 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`, `email` are all rendered on Account and drive the avatar initials. - **`email` must not be null** — the client types it as `String`. 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 same `customer` object, 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` ```jsonc { "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` ```jsonc { "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=` ```jsonc { "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` ```jsonc { "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=` ```jsonc { "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=` ```jsonc { "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`/`lng` and 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` ```jsonc // 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 ```jsonc // 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. ```jsonc { "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` / `districtName` are **required** — the client renders "Chennai, Tamil Nadu" from them and does not look codes up. - `pickup` and `slotId` must be **present on every booking, including cancelled ones** — the client parser types them non-nullable and will throw on `null`. - `verification.photos` must be **fetchable URLs** (signed, ≥ 15 min TTL), one per package. `weightKg` is the number the price settled on; shown as "2.8 kg". - `miler.phone` powers a **Call Miler** action. Real dialable number or masked-calling proxy — say which. Masked preferred. - `amountPaid` is the settled total in ₹, present from `picked_up`. The receipt renders `amountPaid − fare.min` as "Weight adjustment", so **keep `fare` on the booking forever**, even after settlement. - `expectedDelivery` is a display string, formatted server-side in IST. - `history` is append-only and ordered; every timestamp on the timeline comes from it. ### 9.4 `POST /customer/bookings/{reference}/cancel` ```jsonc // 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*. `reason` may be `null`. - **Allowed only through `arrived`.** From `picked_up` onward return `409 conflict`. The server is the authority; `cancellable` on 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. ```jsonc { "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; `null` clears a field. - Accept until `picked_up`; after that `409 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:** 1. **Push (FCM + APNs)** — one notification per customer-visible *milestone* change: ```jsonc { "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/devices` registers `{ token, platform, appVersion }`; logout unregisters. (The Miler side already has `/miler/device-token` — mirror it.) - Do **not** notify on every operational stage: `on_the_way` and `order_created` roll up on the timeline. Agree the notification set with product. - ⚠️ The client has **no deep-link intent filter yet** (§Appendix B) — send the `deepLink` field from day one; the client will start honouring it. 2. **Polling fallback:** `GET /customer/bookings/{reference}` is polled while tracking is open. Make it cheap; support `If-None-Match` → `304`. 3. **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 1. **OpenAPI 3.1 spec** for §4–§10 plus a Postman collection. 2. **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. 3. **Test accounts + a fixed staging OTP** (e.g. `1234`) so automated tests can sign in. 4. **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. 5. Documented error responses, ideally error injection, so the app's empty / error / retry states can be verified against the real service. 6. **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 1. **Payment.** The app shows "UPI · Cash at doorstep" and an `amountPaid` but 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. 2. **Masked calling** for `miler.phone` — real number or proxy? 3. **Partial pickup.** What happens if the Miler collects some destinations but not all? The model has no partial-pickup state today. 4. **Failed delivery / reattempt.** Does it exist operationally? There is no customer screen for it, and no stage key. If it exists we need both. 5. **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/addresses` and a preferences endpoint. 6. **Support.** "Need help with this order?" is a dead end today. Chat, ticket API (the Miler side has `/miler/support`), or a phone number? 7. **Slot capacity semantics.** Is `available` per zone, per Miler count, or a hard cap? It decides how often the client re-fetches before confirming. 8. **Pricing.** Who owns the estimate formula, and does the customer see a breakdown when `amountPaid` exceeds `fare.max`? 9. **Cancellation fee** — confirmed free through `arrived`? 10. **Retention** for parcel photos and PII. 11. **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.yaml` still has no network dependency** — no `http`, no `shared_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) and `geolocator` are 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.fromJson` does not yet parse `miler`, `deliveryAgent`, `fare`, `verification`, `history`, `amountPaid`, `deliveredAt`, `status`, per-destination `details` / `district` / `stage` (§9.3). - `Destination` and `DeliveryDetails` have no `fromJson`/`toJson` yet. - No deep-link intent filter in `AndroidManifest.xml` and no URL types on iOS, so `doormile://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 `_StageStepper` is the prototype stand-in for real events; deleting it is the only client change needed once §10 lands.