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

28 KiB
Raw Blame History

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:

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

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

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

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

  • 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

# 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_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