Files
doormile_backend/docs/customer-app-integration-handbook.md
2026-09-22 15:27:13 +05:30

693 lines
28 KiB
Markdown
Raw 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 — 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 <accessToken>` |
| `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`