# Doormile Backend — Customer App Integration Handbook **For:** the developer building `doormile_customer_app` (Flutter) **Backend:** `doormile_backend` @ `main` (Go · Fiber v2 · PostgreSQL · Redis · NATS) **Base URL:** `https://api.doormile.com/api/v1` **Namespace:** everything you call lives under `/customer/*` — 28 routes, no exceptions **Verified against source:** 16 Sep 2026 > This is the *practical* handbook. Two companion docs already exist and are still correct: > `docs/customer-app-api.md` (the full change record and design rationale) and > `docs/customer-app-api-crisp.md` (the terse endpoint reference). > This file is what you need to actually ship — the flows, the gotchas, and the things that will > waste your week if nobody tells you. --- ## 0. Read this first — two things are blocking you today ### 0.1 There is no SMS gateway. Nobody can sign in. `sms.Register()` had **zero callers** in the entire backend until this week. The package shipped with a logging sink standing in for a real gateway, and the sink was never replaced. So: ``` POST /customer/auth/otp/request -> backend generates a 4-digit code -> writes it to the APPLICATION LOG -> returns { success: true } <-- a lie -> no text is ever sent ``` The endpoint reports success. No customer has ever received a code. **What changed:** `sms.Configure()` is now called at boot (`main.go`), and the log sink now *refuses* in production instead of pretending. The boot log says plainly which transport it got, and `GET /api/v1/ready` reports it: ```json { "sms": { "transport": "log", "configured": false } } ``` **What has NOT changed:** there is still no provider. `SMS_GATEWAY_URL` is unset. That is a procurement item (an Indian provider plus DLT template registration), not something you or I can code around. **How you get in *today* — pick one:** | Method | How | Notes | |---|---|---| | **Staging OTP** (best) | Ops sets `CX_STAGING_OTP=1234` on a **non-production** deployment | A fixed code that always verifies. Refused outright when `ENV=production` — `internal/sms/sms.go:105`. **Not yet set on the cluster.** | | **Read the log** | Ask backend/ops for the code out of the pod log | Works right now, no deploy needed. Tedious. | | **Paste a token** | `--dart-define=DM_DEV_TOKEN=eyJ...` | A **real** server-issued token. Everything after it is genuinely authorised. Expires in 1 hour unless you also pass `DM_DEV_REFRESH_TOKEN`. | ### 0.2 `DM_MOCK=true` bookings never reach the server. At all. This is the root cause of *"I created a booking in the app and it doesn't show in the admin console."* `DevDoormileApi.createBooking()` (`lib/data/dev_doormile_api.dart:440`) builds a `Booking` object in memory and pushes it onto a local list. **It never opens a socket.** Nothing is POSTed, nothing is stored, nothing exists. Because sign-in was impossible (§0.1), offline mode became the only practical way into the app — and every booking made that way was fiction. The database confirms it: the newest `Customer_App` booking is **id 565, dated 8 Sep 2026**. The count has not moved since. **Rule:** a booking is only real if `AppConfig.useDevData == false`. If the Account screen says `DEV DATA (offline)`, nothing you do on that screen reaches Doormile. Use `DM_LOGIN_AS` + `DM_LOGIN_CODE` instead when you want to skip the login *screen* without faking the login — it runs the real `otp/request` + `otp/verify` pair and the token it gets back is the server's. --- ## 1. Connection basics ### 1.1 Base URL | Environment | URL | |---|---| | Production | `https://api.doormile.com/api/v1` | | Staging | *not yet provisioned* — staging currently points at production | | Local | `http://10.0.2.2:8080/api/v1` (Android emulator to host) | Every customer path is then prefixed `/customer`, so a full URL looks like: ``` https://api.doormile.com/api/v1/customer/bookings ``` ### 1.2 Headers | Header | When | Value | |---|---|---| | `Authorization` | every authenticated call | `Bearer ` | | `Content-Type` | every POST/PUT/PATCH | `application/json` | | `Idempotency-Key` | `POST /bookings`, `POST /auth/otp/verify` | any stable unique string per logical action (mint a UUID when the user taps the button) | | `X-Client` | optional | `doormile-cx/1.0.0+1` | | `X-Platform` | optional | `android` / `ios` | > **Flutter Web only:** the backend's CORS `AllowHeaders` is > `Origin,Content-Type,Accept,Authorization,Idempotency-Key` (`main.go:169`). > `X-Client` and `X-Platform` are **not** in it, so a browser preflight will reject them. > On a native build CORS does not apply and they are fine. If you target web, either drop those > two headers or ask backend to add them. ### 1.3 Timeouts | Call | Budget | |---|---| | Reads | 15s | | `POST /bookings` | 30s — it writes across several tables; better to wait than orphan a booking the server did create | --- ## 2. The response envelope Customer endpoints use their **own** envelope (`utils/response_cx.go`), deliberately different from the miler/console one. Do not copy parsing code from another Doormile client. ### 2.1 Success ```json { "success": true, "data": { }, "message": "" } ``` `message` is **always present** on success — empty string, never omitted. ### 2.2 List ```json { "success": true, "data": [], "total": 48, "nextCursor": "540", "message": "" } ``` - `data` is **always an array**, never `null`. Type it as a list. - `nextCursor` is `null` on the last page. - `total` is the size of the *filtered* set — the count matches the tab you asked for. ### 2.3 Error ```json { "success": false, "message": "That code has expired. Request a new one.", "error": { "code": "invalid_otp" } } ``` The machine-readable code is **nested under `error.code`**, not at the top level. `message` is customer-safe English — render it verbatim in your error state. ### 2.4 Error codes | Code | HTTP | Meaning | What the app should do | |---|---|---|---| | `invalid` | 400 | Malformed or rejected request | Show `message`, let them fix it | | `invalid_name` | 400 | Name failed validation at signup | Focus the name field | | `invalid_otp` | 401 | Wrong or expired code | Clear the field, offer resend | | `unauthorized` | 401 | Missing or expired access token | Refresh once, then sign out | | `forbidden` | 403 | Not your resource | Go back | | `not_found` | 404 | No such booking or order | Go back, refresh the list | | `conflict` | 409 | Already done, or in progress | Refresh and re-read state | | `unserviceable` | 400 | Outside operating cities | Show the serviceability message | | `rate_limited` | 429 | Throttle hit | Back off, show a countdown | | `server_error` | 500 | We broke | Generic retry state | --- ## 3. Authentication No passwords anywhere. A 4-digit code to a phone **or** an email address. ### 3.1 The flow ``` +- new user ---> POST /customer/auth/signup {name, phone, email} | | user -+ v +- returning --> POST /customer/auth/otp/request {identifier} | v (code delivered - see 0.1) POST /customer/auth/otp/verify {identifier, code} + Idempotency-Key | v { accessToken, refreshToken, expiresIn, customer } | +------------------+------------------+ v v use for 1 hour POST /customer/auth/refresh {refreshToken} -> new pair ``` ### 3.2 Session response ```json { "success": true, "message": "", "data": { "accessToken": "eyJhbGciOi...", "refreshToken": "9f2c... (64 hex chars)", "expiresIn": 3600, "customer": { "id": 1, "name": "", "phone": "", "email": "" } } } ``` ### 3.3 Lifetimes and limits — code against these exactly | Thing | Value | Source | |---|---|---| | Access token TTL | **1 hour** | `cxAccessTTL` | | Refresh token TTL | **60 days** | `cxRefreshTTL` | | OTP TTL | **5 minutes** | `cxOtpTTL` | | OTP length | **4 digits** | `cxOtpLength` | | Resend cooldown | **30 seconds** | `cxResendWait` | | Verify attempts per code | **3**, then the code dies | `cxOtpMaxVerify` | | Codes per identifier per hour | **5** | `cxOtpMaxRequests` | | Credential endpoint throttle | **10/min shared** across `otp/request`, `signup`, `otp/verify`, `refresh` | `authLimiter()` | That last one matters: the budget is **shared**. An aggressive resend loop will 429 the verify call too. Put a real 30-second countdown on the resend button. ### 3.4 Token handling - Persist the refresh token. 60 days means a returning customer should never see the login screen. - Refresh **once** on a 401, then give up and sign out. Do not loop — the throttle is shared. - The refresh token is stored **hashed** server-side. If you lose it, it cannot be recovered; the customer signs in again. ### 3.5 `POST /auth/otp/verify` needs an `Idempotency-Key` The client retries over flaky networks, and a replayed verify must return the **original** session rather than mint a second one. Send a key. > **Fixed this week:** the idempotency middleware used to cache any status below 500 for 24 hours, > including the **401** from a mistyped code. One typo locked a customer out for a day — confirmed > live, the retry came back carrying `Idempotent-Replay: true`. Only 2xx is cached now > (`middlewares/idempotency.go:104`). A wrong code now genuinely re-executes. --- ## 4. Endpoint reference — all 28 ### 4.1 Public (no token) | Method | Path | Purpose | |---|---|---| | POST | `/customer/auth/otp/request` | Send a code to a phone or email | | POST | `/customer/auth/signup` | Create an account | | POST | `/customer/auth/otp/verify` | Exchange code for a session · **Idempotency-Key** | | POST | `/customer/auth/refresh` | Exchange refresh token for a new pair | | GET | `/customer/serviceability/states` | State picker | | GET | `/customer/serviceability/states/:stateCode/districts` | District picker | | GET | `/customer/pickup-slots` | Bookable time slots | | GET | `/customer/config/booking-limits?lat=&lng=` | `maxPackages`, `maxDestinations` | > The booking form is explorable **before** sign-in by design. Do not put a login wall on the first > screen. ### 4.2 Authenticated (`Bearer` + role 9) **Session and profile** | Method | Path | Purpose | |---|---|---| | GET | `/customer/auth/me` | Who am I | | POST | `/customer/auth/logout` | Revoke this session | | GET · PUT | `/customer/profile` | Read / update profile | **Saved addresses** | Method | Path | |---|---| | GET · POST | `/customer/locations` | | PUT · DELETE | `/customer/locations/:id` | **Push** | Method | Path | Purpose | |---|---|---| | POST | `/customer/devices` | Register an FCM token | | DELETE | `/customer/devices/:token` | Unregister | > One row per device token, not one column per customer — a phone and a tablet must both receive the > delivery notification. Re-register on every token rotation. **Places** — proxied, never keyed | Method | Path | |---|---| | GET | `/customer/places/reverse-geocode?lat=&lng=` | | GET | `/customer/places/search?q=` | > The legacy rider app shipped a Google Maps key inside the binary and it had to be revoked. **You > are never handed a key.** Ask the backend, the backend asks the geocoder. **Pricing** | Method | Path | |---|---| | POST | `/customer/fare/estimate` | **Bookings** | Method | Path | Purpose | |---|---|---| | POST | `/customer/bookings` | Create · **Idempotency-Key** · city-gated | | GET | `/customer/bookings?status=&limit=&cursor=` | List | | GET | `/customer/bookings/:reference` | Detail / tracking poll | | POST | `/customer/bookings/:reference/cancel` | Cancel | | PATCH | `/customer/bookings/:reference/destinations/:index` | Edit one destination | | GET | `/customer/orders/:trackingId` | One order, for `doormile://track/...` deep links | **QA only** | Method | Path | |---|---| | POST | `/customer/ops/bookings/:reference/stage` | > Refused unless `ENV` is non-production **and** `CX_ALLOW_STAGE_OVERRIDE=true` — two independent > switches, because either one wrong in production would let any customer mark their own parcel > delivered. It exists so every tracking state is reachable for design QA, which means **your debug > stepper can be deleted.** --- ## 5. The payloads that matter ### 5.1 `POST /customer/fare/estimate` Call this on every route and package-count change. It is cheap, cached 60s, and a failed estimate **must never block a booking**. ```json { "pickup": { "lat": 11.0168, "lng": 76.9558 }, "destinations": [ { "stateCode": "TN", "districtCode": "CBE", "packageCount": 2 } ] } ``` ```json { "min": 180, "max": 240, "paymentMethod": "UPI · Cash at doorstep", "parcel": "2 parcels", "routeKm": 12.4 } ``` ### 5.2 `POST /customer/bookings` ```json { "pickup": { "title": "Home", "sub": "12 Gandhi St, RS Puram", "lat": 11.0168, "lng": 76.9558 }, "slotId": "2026-09-16T10:00", "destinations": [ { "stateCode": "TN", "districtCode": "CBE", "packageCount": 2, "details": { "street": "45 Cross Cut Rd", "building": "Flat 3B", "landmark": "opp. the bakery", "recipientName": "Priya", "recipientPhone": "9876543210", "instructions": "Ring twice", "pin": { "lat": 11.0041, "lng": 76.9662 }, "codAmount": 500 } } ], "estimate": { "min": 180, "max": 240 }, "remarks": "Handle with care" } ``` Field notes that will bite you: | Field | Why it matters | |---|---| | `estimate` | What the customer was **shown on Review**. Recorded for dispute audit — when the settled price is questioned months later, the number on the screen is the fact that matters. The server validates it against its own quote and rejects a tampered band. | | `remarks` | **Top-level, not inside a destination.** Lands in `PickupBooking.Notes`, which the admin Orders table displays and searches. Put it in the wrong place and every booking reaches the console with an empty note. | | `codAmount` | Money collected at that door **on the customer's behalf**. Doormile is the carrier, not the seller. | | `details.pin` | Overrides the district centroid with an exact drop pin. Send it whenever you have one. | Caps: `maxDestinations` and `maxPackages` from `/config/booking-limits` (hard ceiling 25, `cxAbsoluteMaxDestinations`). Re-fetch the limits when the pickup point moves — they vary by city. **City gate:** pickup must be in an operating city, matched on the 3-digit pincode prefix — `641` Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil. Anything else is refused. The backend resolves your `lat`/`lng` to a pincode itself, so you do not send one. ### 5.3 `GET /customer/bookings` | Param | Values | Default | |---|---|---| | `status` | `active` · `completed` · `cancelled` | all | | `limit` | 1–**50** | 20 | | `cursor` | the `nextCursor` from the previous page | — | **Keyset pagination, not offset.** Offsets drift when a new booking lands mid-scroll and show the same row twice. Pass the cursor back verbatim; stop when `nextCursor` is `null`. --- ## 6. The booking object One shape. The list row and the detail read are **identical** — build one parser. ```json { "reference": "DM2609160042", "stage": "on_the_way", "status": "active", "cancellable": true, "createdAt": 1789564800000, "pickup": { "title": "Home", "sub": "12 Gandhi St", "lat": 11.0168, "lng": 76.9558 }, "slotId": "2026-09-16T10:00", "destinations": [ { "stateCode": "TN", "stateName": "Tamil Nadu", "districtCode": "CBE", "districtName": "Coimbatore", "packageCount": 2, "district": { "code": "CBE", "name": "Coimbatore", "available": true, "hub": "CBE Central", "promise": "Same day" }, "details": { "street": "45 Cross Cut Rd", "recipientName": "Priya", "codAmount": 500 }, "trackingId": null, "stage": null, "verification": null } ], "miler": { "name": "Ravi", "vehicle": "TN 37 AB 1234", "phone": "9000000000", "rating": 4.8, "trips": 412, "vehicleType": "bike" }, "deliveryAgent": null, "milerDistanceKm": 2.4, "milerEtaMinutes": 9, "milersInZone": 0, "routeKm": 12.4, "expectedDelivery": 1789600000000, "fare": { "min": 180, "max": 240, "paymentMethod": "UPI · Cash at doorstep", "parcel": "2 parcels" }, "amountPaid": null, "deliveredAt": null, "cancelReason": null, "history": [ { "stage": "booked", "at": 1789564800000, "actor": "customer" } ] } ``` ### 6.1 Contract guarantees — your type declarations depend on these - `pickup` and `slotId` are present on **every** booking, cancelled ones included. - `destinations[].stateName` and `districtName` are **always populated**. Render `"Coimbatore, Tamil Nadu"` from them; never look a code up client-side. - All timestamps are **epoch milliseconds**, integers. ### 6.2 Nullability — when each field appears | Field | Null until | |---|---| | `miler` | a rider is assigned (stage >= `assigned`) | | `milerDistanceKm` / `milerEtaMinutes` | **only** at `on_the_way` and `arrived` — after pickup the number would describe a journey that already ended | | `milersInZone` | `0` except on a **single-booking read** at stage `booked` (it is a Redis GEOSEARCH; running it across a 20-row list would put 20 of them behind one page load) | | `destinations[].trackingId` | `order_created` | | `destinations[].stage` | `order_created` | | `destinations[].verification` | `picked_up` — the weight and photos are what the rider recorded at the door | | `amountPaid` | `picked_up` | | `deliveryAgent` | an order is out for delivery | | `deliveredAt` | every destination is delivered | | `cancelReason` | cancelled | Parcel photo URLs are **signed and expire in 30 minutes**. Long enough to open the receipt; short enough that a forwarded link is dead. Do not cache them — re-fetch the booking. --- ## 7. The stage machine ### 7.1 Nine stages The wire format is **lowercase snake_case, verbatim**. Your client rolls these into its seven milestones and falls back to `booked` on an unknown key — **silently**. A new stage added without an app release therefore makes a parcel look un-started. Never rename; coordinate. | # | `stage` | Means | Set by | |---|---|---|---| | 0 | `booked` | Pickup requested, nobody assigned | booking create | | 1 | `assigned` | A rider was allotted the job | assignment | | 2 | `on_the_way` | Rider heading over — distance/ETA live | rider taps Accept | | 3 | `arrived` | Rider at the door — **last cancellable stage** | rider taps Reached | | 4 | `picked_up` | Weighed, photographed, price settled | pickup-complete | | 5 | `order_created` | One tracking number minted per destination | pickup-complete | | 6 | `in_transit` | In the Doormile network — **per order** | hub inward / tripsheet | | 7 | `out_for_delivery` | Delivery agent carrying it | start-delivery | | 8 | `delivered` | Handed over | deliver | ### 7.2 `status` — three values | Value | When | |---|---| | `active` | stages 0–7 | | `completed` | stage reaches `delivered` | | `cancelled` | customer, ops, or rider stand-down. **Terminal** — late rider telemetry cannot resurrect it | ### 7.3 Which leg is which ``` customer's door --(1)--> HUB --(2)--> recipient's door | | order_created out_for_delivery +- in_transit -+ ``` `in_transit` is the **middle** leg — the only stage where no rider is holding the parcel. The first-mile ride (customer to hub) is still `order_created`; the last mile is `out_for_delivery`. ### 7.4 Four behaviours that will look like bugs and are not **A hyperlocal booking never shows `in_transit`.** Same postal area means no hub leg — the same rider carries it door to door. The stage jumps `order_created` to `out_for_delivery`. Your milestone UI must tolerate a skipped rung. **Stages 6–8 take the *slowest* destination.** Three parcels, one still at a hub, and the booking stays `in_transit`. Deliberate: showing "Delivered" while a parcel is in Kerala is worse than being pessimistic. Per-destination progress is on `destinations[].stage` — use that for the per-parcel rows. **`booked` can be reached *backwards*.** If a rider cancels or skips, the pickup is not cancelled — it returns to the pool, `stage` walks back to `booked`, `miler` goes null. Your tracking screen must handle a rider card disappearing. History entries for `assigned` and `on_the_way` stay, because those things did happen. **`in_transit` currently fires before the parcel reaches the hub.** With `MILER_HUB_HANDOVER_ENABLED` off (the default, and it is unset everywhere), pickup-complete stamps `Inwarded_at_Hub` immediately. The customer sees "In transit" while the rider is still standing at their door. Known — the backend comment says so in as many words. Do not build UI that assumes the parcel is physically at a hub. ### 7.5 Cancellation window `cancellable` is on the booking — **use it, do not compute it.** It closes after `arrived` (rank <= 3). The server re-checks on the cancel call regardless; the flag is a hint for hiding the button, never the authority. Expect a `409` if the stage moved between render and tap, and handle it by refreshing rather than erroring. ### 7.6 `history` Append-only, one entry per stage **actually reached**, with the real time and the real actor (`customer` · `miler` · `ops` · `system`). **Nothing is backfilled.** A booking that predates this surface has a short history — a short honest history beats a long invented one, because the customer cannot tell which entries were guessed. Render what you get; do not pad it. A cancellation rides the event log with `remarks` rather than as a stage, because `cancelled` is not one of the nine. Read `status` and `cancelReason` for the display. --- ## 8. Rules the app must implement 1. **Idempotency keys on `POST /bookings` and `POST /auth/otp/verify`.** Mint a UUID when the user taps, reuse it across retries, discard it on success. A duplicate pickup is unacceptable. 2. **Keyset pagination.** Pass `nextCursor` back; never construct offsets. 3. **Refresh once on 401, then sign out.** The throttle is shared across all credential endpoints. 4. **Fetch `/config/booking-limits` when the pickup point moves.** Caps vary by city and are deliberately not hardcoded anywhere in the UI. 5. **A failed fare estimate must not block the booking.** Let them proceed on the last good quote. 6. **Never cache signed photo URLs.** 30-minute expiry. 7. **Render `message` verbatim** on any failure. It is written to be customer-safe. 8. **Poll the detail endpoint while tracking is open.** It is the canonical read and is built for it — the whole bundle loads in batch specifically so a poll is not six queries. --- ## 9. Backend quirks worth knowing | Quirk | Impact on you | |---|---| | `Arrived_At_Pickup` is a declared status that is **never written** | Arrival is stored as a fact (`arrivedat` plus GPS), not a status. You get it via `stage: "arrived"`. Do not look for the status string. | | `Picked_Up` is **transient** | Written and overwritten to `Converted_To_Consignment` inside the same transaction. You will effectively never observe it. | | Two response envelopes exist in this backend | `/customer/*` uses `CxOK`/`CxFail` (nested `error.code`). `/miler/*` and `/admin/*` use `OK`/`Fail` (top-level code). Never copy parsing code across. | | `/miler/verify-pin` returns its payload **outside** the envelope | A known inconsistency that cost the miler client a release. It is not repeated on your surface — every `/customer/*` response, auth included, puts its payload in `data`. | | `ENV` is currently **not** `production` on `api.doormile.com` | Confirmed via a CORS probe: an `OPTIONS` from an un-allowlisted origin was echoed back. This means `CX_STAGING_OTP` *would* work there — and also that the production safety guard is inert. Flag for ops. | --- ## 10. Build flags ```bash # Real backend, real login - what a release build does flutter run --dart-define=DM_ENV=prod ``` ```bash # Skip the login SCREEN, keep the login REAL <-- use this for day-to-day dev flutter run --dart-define=DM_LOGIN_AS=9876543210 --dart-define=DM_LOGIN_CODE=1234 ``` ```bash # Paste a real token you already hold flutter run --dart-define=DM_DEV_TOKEN=eyJ... --dart-define=DM_DEV_REFRESH_TOKEN=abc... ``` ```bash # Offline fake - UI work only. NOTHING reaches a server. flutter run --dart-define=DM_MOCK=true ``` | Flag | Default | Effect | |---|---|---| | `DM_ENV` | `staging` | `prod` · `staging` · `dev` | | `DM_API_BASE` | — | Overrides the per-environment base URL | | `DM_MOCK` | `false` | Offline fake API. **Never reaches a server.** | | `DM_DEV_LOGIN` | `true` | With `DM_MOCK`, opens straight on Home | | `DM_LOGIN_AS` / `DM_LOGIN_CODE` | — | Real auto sign-in against the real API | | `DM_DEV_TOKEN` | — | A real server-issued access token | | `DM_ALLOW_STAGE_OVERRIDE` | `false` | Offers the QA stage stepper (server must also allow it) | Every one of these is guarded by `!kReleaseMode`. **No define can put any of them in a release build**, and each is named on the Account screen whenever it is on. Backend-side flags that change what you observe: | Var | Default | Effect on the app | |---|---|---| | `CX_STAGING_OTP` | unset | A fixed code that always verifies. Ignored when `ENV=production`. | | `CX_ALLOW_STAGE_OVERRIDE` | unset | Enables `POST /ops/bookings/:ref/stage` | | `SMS_GATEWAY_URL` | unset | Unset means codes go to the log and no text is sent | | `MILER_HUB_HANDOVER_ENABLED` | unset | Off means `in_transit` fires at pickup, not at the hub | | `MILER_COLLECTED_STATE_ENABLED` | unset | Off means hyperlocal goes straight to `out_for_delivery` | --- ## 11. Pre-release checklist - [ ] `DM_MOCK` is off and the Account screen shows a real base URL, not `DEV DATA (offline)` - [ ] A booking made on the build appears in the admin console within 15 seconds - [ ] Idempotency keys sent on `POST /bookings` and `POST /auth/otp/verify` - [ ] Resend button has a real 30-second countdown - [ ] A 401 triggers exactly **one** refresh, then sign-out - [ ] Pagination uses `nextCursor`, never an offset - [ ] Tracking UI survives a skipped `in_transit` (hyperlocal) - [ ] Tracking UI survives the rider card disappearing (release back to `booked`) - [ ] An unknown `stage` value does not crash — falls back to `booked` and logs - [ ] Cancel button driven by `cancellable`, and a `409` refreshes rather than errors - [ ] Photo URLs re-fetched, not cached - [ ] Debug stage stepper removed (the server's QA endpoint replaces it) - [ ] `X-Client` / `X-Platform` dropped if you ship a web target --- ## 12. Where the source is | Topic | File | |---|---| | Routes and middleware wiring | `routes/routes.go:105-171` | | Response envelope | `utils/response_cx.go` | | Auth, OTP, sessions | `controllers/cxAuthController.go` | | Booking create / list / detail / cancel | `controllers/cxBookingController.go` | | **The booking JSON shape** | `controllers/cxBookingView.go` | | Stage machine and timeline | `internal/cxstage/stage.go` | | Stage and status constants | `constants/constants.go:190-230` | | Consignment to stage mapping | `controllers/cxConsignmentHooks.go` | | Fare | `controllers/cxFareController.go` | | Serviceability, slots, limits | `controllers/cxCatalogueController.go` | | City gate | `middlewares/city_gate.go` | | Idempotency | `middlewares/idempotency.go` | | SMS gateway | `internal/sms/` | Full contract and design rationale: `docs/customer-app-api.md` Terse endpoint list: `docs/customer-app-api-crisp.md` Machine-readable: `docs/openapi-customer.yaml`