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

37 KiB
Raw Blame History

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

// 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, 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

// 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

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

// 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 / 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

// 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.

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

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