Files
doormile_customer_app/docs/BACKEND_REQUIREMENTS.md
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
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>
2026-09-15 16:07:33 +05:30

783 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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": <payload>, "message": "" }` |
| List envelope | `{ "success": true, "data": [...], "total": <int>, "nextCursor": <string|null> }` |
| 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** 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.