── The full-address path was reaching the Miler empty ──
`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.
Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.
The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.
── Who the Miler rings ──
One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.
Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.
── Account's rows led nowhere ──
Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.
Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.
── ONE TOUCH is one sheet ──
It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.
Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.
── Sending a second parcel ──
`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.
Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.
── Bundle ──
pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
15 KiB
Doormile CX — Backend Changes Needed
| Date | 2026-09-29 |
| From | doormile_cx (customer app) team |
| Checked against | Customer App API Reference v1.0 (backend code at 44ba33e) |
| Evidence | Live probes against https://api.doormile.com/api/v1 on 2026-09-29, quoted inline |
The two most urgent items here need no app work at all — they are deployment configuration. One of them is a live security exposure. Everything below §3 is ordinary product work and can wait its turn.
| # | Item | Owner | Effort |
|---|---|---|---|
| 1 | ENV is not set to production |
Backend / DevOps | One line |
| 2 | SMS is unfunded — new customers cannot sign in | Product decision | One line, or weeks |
| 3 | If PIN becomes the only auth, three holes must close first | Backend | Days |
| 4–8 | Payload, contacts, multi-destination, push, staging | Both | Ordinary |
1. ENV is not production on api.doormile.com
Verified today. POST /customer/auth/otp/request with a valid phone
returns:
{"data":{"codeLength":4,"resendAfterSeconds":30,"sent":true},"success":true}
Per API Reference §3.5, when no SMS gateway is configured the log sink is used,
and it behaves differently by environment: in production, sending fails and
this endpoint returns 500. It only writes the code to the server log and
reports sent: true when ENV is not production.
It reported sent: true. The readiness probe confirms the gateway is absent:
GET /api/v1/ready → "sms": { "configured": false, "transport": "log" }
Why this matters beyond OTP. ENV != production is the gate on two other
things:
CX_STAGING_OTP. If that variable is set in this environment, every issued code is a fixed constant — for every account, including real customers. Anyone who knows it can sign in as anyone. We did not test this, because confirming it means signing in to an account we do not own. Please check the deployment config for it and treat it as urgent if it is set.POST /customer/ops/bookings/:reference/stage, the QA stage override, is one env var (CX_ALLOW_STAGE_OVERRIDE) away from being reachable by any customer token.
What to do: set ENV=production on api.doormile.com, and confirm
CX_STAGING_OTP is unset there. This is the single highest-priority item in
this document.
2. SMS is unfunded — new customers cannot sign in
The OTP routes are not removed. We probed all six auth endpoints with
malformed bodies; every one returned a normal 400 validation error rather than
a 404:
POST /auth/otp/request → 400 invalid "Enter a valid phone number or email address"
POST /auth/signup → 400 invalid_name "Enter your full name"
POST /auth/otp/verify → 400 invalid "Enter the code we sent you"
POST /auth/login → 400 invalid "Enter a valid phone number"
POST /auth/set-pin → 400 invalid "Enter a valid phone number"
POST /auth/verify-pin → 400 invalid "Enter your PIN"
So the server still issues a valid 4-digit code on every request. The code goes to the server log instead of the customer's handset. The practical result is that anyone with log access can sign in as anybody, and no customer can sign in at all.
The failure is slow, which is what makes it dangerous. Refresh tokens live 60 days and rotate, so customers already signed in keep working. Only new sign-ins break — new installs, reinstalls, new customers, anyone signed out. Nobody reports this for weeks, then everybody does at once as tokens age out.
The customer app has no other way in. Its entire auth surface is
/auth/otp/request, /auth/signup and /auth/otp/verify
(lib/data/live_doormile_api.dart:44-95). There is no PIN code in the app.
The decision we need
| Option | Backend work | App work | Consequence |
|---|---|---|---|
| A — Fund SMS again | Set SMS_GATEWAY_URL |
None | Sign-in works immediately. The app ships unchanged. You inherit none of §3 |
| B — PIN becomes permanent | §3 first | Three new screens | Weeks. You inherit all three holes in §3, on the only door into the product |
We recommend A. The routes are already live and validating; it is one environment variable against weeks of work on both sides, and it avoids §3 entirely. If cost is the blocker, say so and we will price the alternatives — but B is not the cheap option, it only looks like it.
3. If PIN becomes the only auth, three things must change first
An earlier version of this document asked you to remove the PIN routes,
because the app did not use them and set-pin is an account-takeover path. That
ask is withdrawn. If PIN becomes the only door, those same holes stop being a
side risk and become the front door.
| # | Hole | Why it matters as the only auth | What we need |
|---|---|---|---|
| 1 | set-pin requires no proof of ownership. It sets the first PIN on any account that has none — including every account created by OTP signup, by otp/verify, or by the console |
Anyone who types a stranger's number owns their account, their address history and their active pickups. Every existing OTP-created account is currently claimable | Proof of ownership before the first PIN. This is the same question OTP was answering — it does not disappear when SMS does |
| 2 | No PIN reset or change endpoint | A customer who forgets a 4-digit PIN is locked out permanently, and support has no lever. OTP always provided a way back; nothing does now | A reset path, and a way for support to trigger it |
| 3 | No per-account lockout on verify-pin |
10,000 combinations. The only limit is 10 req/min per IP, shared with /miler/login, /admin/login and /hub/login, and defeated by rotating IPs |
Per-account attempt limit and backoff |
Also: POST /auth/login tells an anonymous caller whether a number is
registered, and returns the account holder's name. Free customer enumeration.
4. What we found and fixed in the app
Separately from auth, we audited the booking payload against the API reference. Five things on the wire were wrong. All five are fixed.
| # | What the app sent | What the contract wants | Consequence |
|---|---|---|---|
| 1 | Destination details spread flat on the destination | Nested under details{} |
Every building number, street, landmark, recipient name, recipient phone and pin was dropped. 201 returned, nothing reached the Miler |
| 2 | Destination pin as latitude/longitude |
pin: {lat, lng} |
Pin discarded. Same spelling that caused months of 422 unserviceable on the pickup before it was fixed there |
| 3 | PATCH .../destinations/{index} sent null to clear a field |
"" clears; null means "leave alone" |
A customer could add a landmark and never remove one |
| 4 | Per-destination instructions folded into the visit's remarks |
details.instructions exists per destination |
One note for a multi-stop visit instead of one per door |
| 5 | contactName / contactPhone on the pickup object |
Pickup is {title, sub, lat, lng} only |
Written on every booking, read by nobody. See §6 |
A customer using the full-address path typed a flat number, a street and a recipient, saw them on the review screen, confirmed — and the Miler arrived with only a district. There was no error anywhere in that chain.
We are shipping the fix before it is verified, because it cannot be worse
than today: if details{} is also the wrong shape, the fields are dropped
exactly as they are now. Verification below is confirmation, not a gate.
5. Please confirm the nested shape
One booking created with the body below, then a read of the stored destination
confirming street, building, landmark, recipientName, recipientPhone,
instructions and the pin all landed. If they did not, tell us the shape that
does work — we will follow the server, not the document.
{
"stateCode": "TN",
"districtCode": "TN-MAA",
"packageCount": 3,
"details": {
"recipientName": "Meera S",
"recipientPhone": "9876543210",
"building": "12/A",
"street": "MG Road",
"landmark": "Opp. bus depot",
"instructions": "Call before arriving",
"pin": { "lat": 13.085, "lng": 80.21 }
}
}
Worth fixing regardless: a destination carrying keys the server does not
recognise is accepted silently. That property is what let this run for months.
If unknown keys on destinations[] returned 400 invalid naming them, this
class of bug would surface on the first request instead of in the field.
6. A real pickup contact field
The Miler gets exactly one phone number for a collection, and it is the number
the customer signed in with. GET /miler/bookings returns a single phone field,
customerphone, which the rider app stores as pickupcontactno — verified
against production on 21 August 2026 and recorded in the rider app's own source
(miler/lib/data/stop_contact.dart). Nothing the customer app sends on pickup
can change it: the create contract's pickup is {title, sub, lat, lng} and
extra keys are dropped.
Why it matters. A parcel handed over by a spouse, a neighbour, a receptionist or a shop assistant is ordinary in collections. Today the rider rings the account holder, who may be at work, and the collection fails on the doorstep. A failed pickup costs a whole rider trip — the most expensive unit in this business.
Meanwhile the app puts a handover person in remarks, labelled so a human
reads it unambiguously (Handover contact: Meera S, +91 9003144518), and both
the pickup and review screens tell the customer plainly that the rider's call
button still dials their own number.
What we are asking for
pickup.contactNameandpickup.contactPhoneaccepted onPOST /customer/bookings, stored on the booking.- Surfaced to the rider as a second contact on the stop — not replacing
customerphone, because the account holder is still who the rider escalates to. - Meanwhile, confirm whether
remarksreaches the rider at all. The rider app reads anotesfield per stop; we do not know whether the customer app'sremarkslands there or stops at the admin console.
Related: details.recipientPhone is stored raw when normalisation fails and
details.codAmount accepts a negative number. Both are money-or-contact fields
reaching a rider unvalidated.
7. The rest, ranked
| Ask | Why | What to change |
|---|---|---|
| Multi-destination | A rider makes one trip to the door. Three parcels for three cities in one visit is one trip instead of three. Production advertises maxDestinations: 1 (confirmed live 2026-09-28), so a customer books three times and either three visits get scheduled or the hub merges them by hand |
Key the Miler build on consignmentid, then add the customerbookinglimits row. The customer app already supports N destinations end to end and obeys the cap it reads on every launch. Raising the number is the only change; none on our side |
| Push is configured but dead | POST /customer/devices exists and the stage pushes are written, but they reach nobody. Several older paths still write appcustomers.device_token, which no /customer/* endpoint fills — so a customer cancel sends no push and an ops cancel usually sends none either |
Confirm FIREBASE_SERVICE_ACCOUNT_PATH is set, or every push is skipped silently. We will wire FCM and register on sign-in. Retire the legacy device_token paths |
| Failed delivery is invisible | RTO, missing, damaged and failed attempts have no customer stage, so the order keeps showing out_for_delivery forever. The parcel is in trouble and the app says it is on its way |
Add stage keys. Needs an app release first — the client falls back to booked for an unknown key — so tell us before shipping them |
| Two responses break the envelope | We branch on error.code. The idempotency 409 puts code at the top level; the city gate 400 sends error as a string, not an object. Both fall through to a generic message, so "an identical request is still being processed" renders to a customer as a booking conflict |
Move both into error: {code} |
estimate on booking create |
Documented with a ±15% tamper rule. We do not send it, so the server always re-quotes — the band the customer saw can differ from the stored fare |
Confirm you want it and we will send the exact min/max |
maxCodAmount |
We parse it from /config/booking-limits; the reference says it is never returned |
Send it, or we drop the field |
8. There is no test environment
AppConfig._stagingBase is _prodBase; no staging host has ever been named.
Every request this app makes — including anything we do to test it — is
production.
That is why §5 is unanswered. Verifying a JSON field name currently costs a real
pickup in a real slot that a real Miler can be dispatched to. With SMS down, the
only way to get a session for a test is set-pin, which creates a permanent
production account with no reset and no delete path.
A staging host closes §5 in ten minutes. Cheapest item here; unblocks the most.
9. Remaining security items
From API Reference §9.2, excluding the OTP items now covered by §1.
/ws/bookings/:id/trackhas no authentication and streams a rider's name and live GPS for any sequential booking id. The app does not use WebSockets; we pollGET /bookings/:reference. Nobody should be able to enumerate rider positions.- The rider's real mobile number is exposed in
miler.phoneunlessMILER_CALL_PROXYis set. The reference marks this "a setting to close before launch". Please confirm it is set.
10. What we need back
ENV=productionset, andCX_STAGING_OTPconfirmed unset (§1). Today.- A or B on SMS (§2). Everything about auth waits on this one answer.
- A staging host (§8) — cheapest item, unblocks §5 immediately.
- Yes or no on the pickup contact field (§6), and whether
remarksreaches the rider today. - A date for multi-destination (§7), or a note that it is not planned, so we can word the app honestly. It currently says "one destination per pickup for now".
Appendix — how to verify a payload change
Use the method that settled the lat/lng bug: two requests, one apart,
differing in one field. That bug ran for months because the app sent
latitude/longitude on the pickup while the server reads lat/lng — it saw
a booking with no coordinates and answered 422 unserviceable, blaming the
customer's address for a field name. Settled on 2026-09-23 with two requests:
same pickup, same destination, same slot. latitude/longitude → 422.
lat/lng → 201, booking DM-252803.
tool/verify_booking.sh in the customer app repo automates exactly that: signs
in, takes the first available slot, books one pickup with every detail field
filled in and marked VERIFY …, reads it back, prints which of the seven fields
survived, and cancels the booking. Point it at staging with --base the moment
there is one.