28 KiB
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) anddocs/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:
{ "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
AllowHeadersisOrigin,Content-Type,Accept,Authorization,Idempotency-Key(main.go:169).X-ClientandX-Platformare 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
{ "success": true, "data": { }, "message": "" }
message is always present on success — empty string, never omitted.
2.2 List
{
"success": true,
"data": [],
"total": 48,
"nextCursor": "540",
"message": ""
}
datais always an array, nevernull. Type it as a list.nextCursorisnullon the last page.totalis the size of the filtered set — the count matches the tab you asked for.
2.3 Error
{
"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
{
"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
ENVis non-production andCX_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.
{
"pickup": { "lat": 11.0168, "lng": 76.9558 },
"destinations": [
{ "stateCode": "TN", "districtCode": "CBE", "packageCount": 2 }
]
}
{
"min": 180,
"max": 240,
"paymentMethod": "UPI · Cash at doorstep",
"parcel": "2 parcels",
"routeKm": 12.4
}
5.2 POST /customer/bookings
{
"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.
{
"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
pickupandslotIdare present on every booking, cancelled ones included.destinations[].stateNameanddistrictNameare 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
- Idempotency keys on
POST /bookingsandPOST /auth/otp/verify. Mint a UUID when the user taps, reuse it across retries, discard it on success. A duplicate pickup is unacceptable. - Keyset pagination. Pass
nextCursorback; never construct offsets. - Refresh once on 401, then sign out. The throttle is shared across all credential endpoints.
- Fetch
/config/booking-limitswhen the pickup point moves. Caps vary by city and are deliberately not hardcoded anywhere in the UI. - A failed fare estimate must not block the booking. Let them proceed on the last good quote.
- Never cache signed photo URLs. 30-minute expiry.
- Render
messageverbatim on any failure. It is written to be customer-safe. - 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
# Real backend, real login - what a release build does
flutter run --dart-define=DM_ENV=prod
# 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
# Paste a real token you already hold
flutter run --dart-define=DM_DEV_TOKEN=eyJ... --dart-define=DM_DEV_REFRESH_TOKEN=abc...
# 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_MOCKis off and the Account screen shows a real base URL, notDEV DATA (offline)- A booking made on the build appears in the admin console within 15 seconds
- Idempotency keys sent on
POST /bookingsandPOST /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
stagevalue does not crash — falls back tobookedand logs - Cancel button driven by
cancellable, and a409refreshes rather than errors - Photo URLs re-fetched, not cached
- Debug stage stepper removed (the server's QA endpoint replaces it)
X-Client/X-Platformdropped 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