Files
doormile_backend/docs/customer-app-api-reference.md

102 KiB
Raw Blame History

Doormile Customer App (doormile_cx) — API Reference

Document version 1.0
Date 2026-09-28
Generated from code at commit 44ba33e (working tree clean at time of writing)
Audience doormile_cx mobile app developers
Source of truth The Go code in this repository. Where this document and older docs disagree, this document follows the code. Differences are listed in §9.3.

Every endpoint section ends with a Source: line (file:line) so reviewers can check it against the code.


Contents

  1. Overview
  2. Quick reference: all endpoints
  3. Authentication
  4. Conventions
  5. Endpoint reference
  6. Booking lifecycle
  7. WebSockets and live tracking
  8. Push notifications
  9. Known limitations, open issues, and changes vs previous docs

1. Overview

1.1 Base URL and environments

Environment Base URL Notes
Production https://api.doormile.com/api/v1 When the server runs with ENV=production, the staging OTP and the QA stage override are switched off (§3.5, §5.10). Check the environment: docs/customer-app-integration-handbook.md reports that api.doormile.com was not running with ENV=production when it was written. The code cannot confirm or deny this.
Staging / development Depends on the deployment (not determinable from code). The path prefix is still /api/v1. Staging OTP and the QA stage override can be switched on here.

All REST paths in this document are relative to the base URL. For example, POST /customer/bookings means POST https://api.doormile.com/api/v1/customer/bookings.

WebSocket paths are not under /api/v1. They sit at the server root: wss://api.doormile.com/ws/... (§7).

Source: routes/routes.go:35 (app.Group("/api/v1")), routes/routes.go:545-552 (WebSocket routes on app, not api).

1.2 Content type and headers

Header Direction Notes
Content-Type: application/json Request Required on every request that has a body. The handlers use Fiber's BodyParser. A body that cannot be parsed returns 400 invalid ("We could not read that request").
Authorization: Bearer <access_token> Request Required on authenticated endpoints (§3.6).
Idempotency-Key: <unique string> Request Optional. Honoured only on POST /customer/auth/otp/verify and POST /customer/bookings (§4.4).
X-Request-Id: <string> Request and response Optional on the request. The server echoes it on every response, including errors. If the client does not send one, the server creates a 16-hex-character id. Include it in support reports. Source: middlewares/requestid.go:18-33.
If-None-Match: "<etag>" Request Optional on the endpoints that send an ETag (§4.6).
ETag, Cache-Control Response See §4.6.
Idempotent-Replay: true Response Present when the response is a stored replay (§4.4).
Retry-After: <seconds> Response Sent with the OTP hourly-cap 429 (§5.1.1).

1.3 Time and time zone

  • Every timestamp in a /customer/* response is an integer number of milliseconds since the Unix epoch, UTC (for example createdAt, history[].at, verification.capturedAt, deliveredAt).
  • The database stores IST (Asia/Kolkata, UTC+05:30) wall-clock values. The server converts them to real UTC instants with utils.EpochMillis before sending, so the app does not need to adjust for IST.
  • Some fields are display strings that the server formats in IST: slot day ("Today", "Tomorrow", "Mon, 8 Sep"), slot window ("2:00 – 4:00 PM", with an en dash), and booking expectedDelivery ("Thu, 12 Sep"). Show these as they are. Do not parse them.
  • Slot ids contain the IST calendar date (slot_20260928_t1).

Source: utils/epoch.go:1-90.

1.4 Identifier formats

Identifier Format Example Notes
Booking reference DM- + 6 digits DM-482913 This is the reference used in every booking URL. It comes from a Postgres sequence and is then scrambled (keyed Feistel permutation), so consecutive bookings do not get adjacent numbers. After 900,000 bookings it grows to 7 or more digits. Older rows can still carry the legacy DM-BK-… format. Treat it as an opaque string.
Tracking number (one per destination / order) DMX + 8 digits DMX10482913 Created only when the miler completes the pickup (stage order_created). It is null before that. Legacy rows can carry DM-TRK-…. Treat it as an opaque string.
Customer id cust_ + integer cust_1042 Returned in the customer object.
Saved location id Decimal integer as a string "17" Used as :id in the /customer/locations/:id URLs.
Slot id slot_<YYYYMMDD>_<templateCode> slot_20260928_t2 Opaque. Send it back exactly as you received it.
Pagination cursor Decimal integer as a string "5120" This is the internal booking id. Treat it as opaque.

If the identifier sequences are unavailable (a database or migration problem), the server falls back to a time-and-random number in the same format.

Source: controllers/cxIdentifiers.go:63-95, controllers/cxIdentifierScramble.go:1-60, controllers/cxAuthController.go:137-144.

1.5 Money

  • All customer-facing amounts are whole Indian rupees (INR) as integers, not paise: fare.min, fare.max, amountPaid, and the min/max of the fare estimate.
  • The one exception is details.codAmount on a destination (request only). It is a JSON number (float) in rupees. It is the cash the miler collects for the customer at that door. It is not a Doormile charge.
  • paymentMethod is currently always the display string "UPI · Cash at doorstep". There is no in-app payment API.

Source: controllers/cxFareController.go:140-155, controllers/cxBookingView.go:126-131,185-189.

1.6 Phone number rules

Every phone number the customer types is normalised to E.164 India format before it is stored or compared. normalizePhone accepts:

Input shape (digits after stripping spaces, dashes, brackets) Result
Leading + and 11–15 digits +<digits> (any country code is accepted as is)
Exactly 10 digits +91<digits>
12 digits starting 91 +<digits>
11 digits starting 0 +91<last 10>
Anything else Rejected (400 invalid)

So 9876543210, +91 98765 43210, 919876543210 and 09876543210 all map to +919876543210, which is the same account.

  • The server does not check that a 10-digit number is a valid Indian mobile range.
  • Email identifiers (OTP flow only) are trimmed and lowercased. The only check is that the address has an @ that is not the first character, text after the @, and a . after the @.
  • details.recipientPhone on a destination goes through the same normaliser. If it fails normalisation, the raw trimmed string is stored anyway and no error is returned.

Source: controllers/cxAuthController.go:68-111, controllers/cxBookingController.go:795-801.


2. Quick reference: all endpoints

All paths below are under /api/v1 unless marked WS. "Auth" means Authorization: Bearer <access_token> with role 9 (customer).

# Method Path Auth Purpose
1 POST /customer/auth/otp/request No Send a 4-digit sign-in code to a phone or email
2 POST /customer/auth/signup No Create an account (name + phone) and send a code
3 POST /customer/auth/otp/verify No Exchange a code for a session (also completes a phone signup)
4 POST /customer/auth/refresh No (refresh token in body) Rotate the refresh token and get a new access token
5 POST /customer/auth/login No Interim PIN flow: decide the next screen for a phone number
6 POST /customer/auth/set-pin No Interim PIN flow: set the first PIN (creates the account if needed) and sign in
7 POST /customer/auth/verify-pin No Interim PIN flow: sign in with phone + PIN
8 GET /customer/auth/me Yes Get the signed-in customer (restore a session on cold start)
9 POST /customer/auth/logout Yes Revoke the session and remove the device push token
10 GET /customer/serviceability/states No List destination states
11 GET /customer/serviceability/states/:stateCode/districts No List districts in a state
12 GET /customer/pickup-slots No List pickup windows for today and tomorrow
13 GET /customer/config/booking-limits No Get the package and destination caps for one pickup
14 GET /customer/profile Yes Read the profile
15 PUT /customer/profile Yes Update name, email and default location
16 GET /customer/locations Yes List saved addresses
17 POST /customer/locations Yes Save an address (up to 10)
18 PUT /customer/locations/:id Yes Update a saved address
19 DELETE /customer/locations/:id Yes Delete a saved address (soft delete)
20 POST /customer/devices Yes Register a push token
21 DELETE /customer/devices/:token Yes Unregister a push token
22 GET /customer/places/reverse-geocode Yes Turn coordinates into a two-line address label
23 GET /customer/places/search Yes Search places, or list saved and recent places when q is empty
24 POST /customer/fare/estimate Yes Get a price range for a pickup
25 POST /customer/bookings Yes Create a pickup booking (idempotent)
26 GET /customer/bookings Yes List bookings (cursor pagination, status tabs)
27 GET /customer/bookings/:reference Yes Get one booking (tracking screen and receipt)
28 POST /customer/bookings/:reference/cancel Yes Cancel the whole pickup
29 PATCH /customer/bookings/:reference/destinations/:index Yes Fill in or correct one destination's details
30 GET /customer/orders/:trackingId Yes Get a booking by an order's tracking number (for push deep links)
31 POST /customer/ops/bookings/:reference/stage Yes + env flags QA only. Force a booking to a stage. Refused in production.
32 WS /ws/bookings/:bookingid/track None Live rider position before pickup (see the security note in §7.1)
33 WS /ws/bookings/:bookingid/chat JWT in ?token= Customer ↔ miler chat room
34 GET /pricing/meta No Pricing dropdown metadata. Not part of the customer contract (§5.12).
35 POST /pricing/check No Generic price check. Not part of the customer contract (§5.12).

The /customer group has 31 routes. With the 2 WebSocket routes and the 2 public pricing routes, this document covers 35 endpoints. The health probes GET /health and GET /ready also exist but are for infrastructure, not the app.

Source: routes/routes.go:105-178, 501-502, 545-552.


3. Authentication

3.1 Summary

There are two sign-in flows. Both end in the same session response (§3.4), and both give a role-9 JWT.

Flow Endpoints Status
OTP — a 4-digit code sent to a phone (SMS) or an email address otp/request, signup, otp/verify The intended long-term flow. It depends on a working SMS gateway (§3.5).
Interim PIN — a 4-digit PIN that the customer sets login, set-pin, verify-pin Added in commit f1dbf7e "until the SMS/OTP gateway is live". It does not prove the customer owns the phone number (§9.1).

Refresh, logout and me are the same for both flows.

3.2 OTP flow

New customer:       POST /auth/signup {name, phone, email?}  ─► code sent
                    POST /auth/otp/verify {identifier: phone, code}  ─► session

                    (or skip signup: /otp/request with the phone, then
                     /otp/verify with {identifier, code, name} — the account
                     is created on verify when a name is supplied)

Returning customer: POST /auth/otp/request {identifier}  ─► code sent
                    POST /auth/otp/verify {identifier, code}  ─► session

OTP rules (from controllers/cxAuthController.go:38-58, 169-248):

Rule Value
Code length 4 digits (codeLength: 4 is returned by otp/request)
Code lifetime 5 minutes
Resend cooldown 30 seconds per identifier. A request during the cooldown returns 200 with sent: false and the seconds left.
Codes per identifier 5 per rolling hour (counter starts on the first request). The 6th returns 429 rate_limited.
Wrong attempts 3 per issued code. On the 3rd wrong attempt the code is deleted and a new one must be requested.
Single use A correct code is deleted as soon as it is used.
Account enumeration otp/request answers the same way whether or not an account exists.

3.3 Interim PIN flow

POST /auth/login {phone}
   ├─ registered:false            ─► collect name + new PIN ─► POST /auth/set-pin {phone, new_pin, name}
   ├─ registered:true, pin_set:false ─► collect new PIN      ─► POST /auth/set-pin {phone, new_pin}
   └─ registered:true, pin_set:true  ─► collect PIN          ─► POST /auth/verify-pin {phone, pin}
  • A PIN is exactly 4 ASCII digits. It is stored as a bcrypt-style hash (utils.HashPassword).
  • set-pin never overwrites an existing PIN (409 pin_already_set).
  • There is no PIN reset or change endpoint for customers.
  • There is no per-account lockout for wrong PINs. The only protection is the per-IP authThrottle (§3.7).

Source: controllers/cxAuthController.go:365-519, routes/routes.go:117-123.

3.4 Session response (all sign-in endpoints and refresh)

otp/verify, set-pin, verify-pin and refresh all return:

{
  "success": true,
  "data": {
    "accessToken": "<access_token>",
    "refreshToken": "<refresh_token>",
    "expiresIn": 3600,
    "customer": {
      "id": "cust_1042",
      "name": "Priya Raman",
      "phone": "+919876543210",
      "email": "priya@example.com"
    }
  },
  "message": ""
}
Field Type Notes
accessToken string HS256 JWT. Lifetime 1 hour. Claims: UserID (the customer id), Email (holds the phone number), RoleID = 9, TenantID = 0, ConfigID, exp, iat. Treat it as opaque.
refreshToken string 64 hex characters (32 random bytes). Lifetime 60 days. Only a SHA-256 hash is stored on the server.
expiresIn integer Access-token lifetime in seconds. Always 3600.
customer.id string cust_<appcustomerid>
customer.name string First and last name joined with a space. Can be "".
customer.phone string E.164
customer.email string Never null. "" when unknown.

Source: controllers/cxAuthController.go:52-53, 131-144, 707-759, utils/helper.go:93-109.

3.5 Staging OTP and SMS delivery

  • SMS goes through internal/sms. If SMS_GATEWAY_URL is not configured, the "log sink" is used:
    • Non-production: the code is written to the server log (masked phone) and the API reports sent: true.
    • Production: sending fails, and otp/request / signup return 500 server_error. The readiness probe GET /api/v1/ready reports checks.sms.configured.
  • Staging bypass: if the environment variable CX_STAGING_OTP is set and ENV is not production, every issued code (SMS and email) is that fixed value instead of a random one. The value is deployment configuration and is not written here; ask the backend team for it. The code ignores it in production.
  • Email codes are sent over SMTP (internal/mail). SMTP settings come from the environment.

Source: internal/sms/sms.go:32-120, internal/sms/http_sender.go:140-190, controllers/cxAuthController.go:190-214.

3.6 Using the access token

  • Send Authorization: Bearer <access_token> on every authenticated call. The word Bearer and exactly one space are required.
  • Authenticated /customer/* routes run AuthMiddleware and then RoleCheckMiddleware(9). A miler, console or hub token is refused with 403.
  • These two middlewares do not use the customer envelope. Their failures look like this (no error.code):
Condition Status Body
No Authorization header 401 {"success": false, "message": "authorization header is required"}
Header not in Bearer <token> form 401 {"success": false, "message": "authorization header must be in format: Bearer <token>"}
Bad signature or expired token 401 {"success": false, "message": "invalid or expired token"}
Valid token, but not role 9 403 {"success": false, "message": "insufficient permissions for this resource"}

The app should treat any 401 as "refresh, then retry once; if that fails, sign in again", whether or not error.code is present.

  • The access token is not checked against the database on each request. Blocking a customer, or logging out, does not end an access token that was already issued. It keeps working until it expires (at most 1 hour). Only GET /auth/me re-checks the customer's Blocked status.
  • Because the middleware applies to the whole /customer prefix, an unknown path under /customer/ returns the 401 above when no token is sent, not a 404.

Source: middlewares/auth.go:12-113, routes/routes.go:134.

3.7 Throttling of auth endpoints

  • All seven public auth endpoints (otp/request, signup, otp/verify, refresh, login, set-pin, verify-pin) share one limiter: 10 requests per minute per client IP. The budget is also shared with /miler/login, /miler/verify-pin, /miler/set-pin, /miler/reset-pin, /admin/login and /hub/login (one authThrottle instance).
  • When the limit is reached: 429 {"success": false, "message": "too many attempts, please try again in a minute"}. This body has no error.code.
  • The client IP is the socket peer, or X-Forwarded-For only when TRUSTED_PROXIES is configured on the server. Mobile carriers often put many users behind one IP (CGNAT), so many real customers can share one budget (§9.2).
  • refresh counts against this budget. Do not refresh more often than needed.

Source: routes/routes.go:19-40, 110-123, main.go:116-133.

3.8 Refresh flow

  1. Call POST /customer/auth/refresh with {"refreshToken": "<refresh_token>"} when the access token has expired, or is about to (use expiresIn), or when a call returns 401.
  2. The server rotates the token. The token you sent is revoked, and a new pair is returned in the session shape (§3.4).
  3. If a revoked refresh token is sent again, the server revokes all of that customer's sessions on all devices (theft detection). This means:
    • Store the new refresh token atomically before using it.
    • Never send two refresh calls at the same time. Use a single in-flight refresh (a mutex). If two parallel calls send the same token, the second one signs the customer out everywhere.
  4. Any refresh failure (401 unauthorized, "Please sign in again") means the app must go back to the sign-in screen.

Source: controllers/cxAuthController.go:594-648.

3.9 Logout

POST /customer/auth/logout (authenticated). With refreshToken in the body, it revokes only that session. Without it, it revokes every session the customer has. With deviceToken, it also removes that push token. Details are in §5.1.9.


4. Conventions

4.1 Response envelope

Every /customer/* handler uses the helpers in utils/response_cx.go.

Success (single object): CxOK gives HTTP 200. CxCreated gives HTTP 201 with the same body.

{ "success": true, "data": { }, "message": "" }

Success (list): CxList gives HTTP 200.

{ "success": true, "data": [ ], "total": 12, "nextCursor": "5120", "message": "" }
  • data is always an array. It is never null.
  • total is an integer. For GET /bookings it is the total count for the selected tab. For other lists it is the length of data.
  • nextCursor is a string, or null on the last page (and on every list that is not paginated).

Error: CxFail.

{ "success": false, "message": "That pickup window just filled up", "error": { "code": "conflict" } }
  • message is customer-safe English. The code comments say it is meant to be shown to the user as is.
  • Branch on error.code, not on message.

Source: utils/response_cx.go:35-100.

Responses that do NOT use this envelope (the app must handle them too):

Source Status Body shape
Auth / role middleware (§3.6) 401 / 403 {success:false, message}
authThrottle (§3.7) 429 {success:false, message}
Global rate limiter (§4.5) 429 {success:false, message:"too many requests, please slow down"}
Idempotency lock (§4.4) 409 {success:false, code:"IDEMPOTENCY_IN_PROGRESS", message}
CityGateMiddleware (§4.7) 400 {error:"We are not yet operating in your city. Stay tuned!", code:"CITY_NOT_SUPPORTED"}
Fiber's global error handler (unknown route after auth, panics, framework errors) 404 / 500 / other {success:false, message}
ETag match 304 Empty body
WebSockets (§7) — Their own frame formats

Source: main.go:60-81, 191-205, middlewares/idempotency.go:505-512, middlewares/city_gate.go:424-427.

4.2 Error codes

The full set is defined in utils/response_cx.go:19-33. Every code below is used by at least one /customer/* handler.

error.code HTTP status Meaning Returned by (examples)
invalid 400 The request is malformed or fails validation. message says what to fix. Almost every handler
invalid_name 400 The name is missing or has fewer than 2 characters after trimming signup, otp/verify (new phone), set-pin (new phone), PUT /profile
invalid_otp 401 The code is wrong, expired, used up, or was never issued otp/verify
invalid_pin 401 Wrong PIN, or the phone has no account (the same message for both) verify-pin
pin_not_set 409 The account exists but has no PIN yet. Send the user to set-pin. verify-pin
pin_already_set 409 The account already has a PIN. Send the user to verify-pin. set-pin
unauthorized 401 The session is invalid. Sign in again. refresh, me
forbidden 403 The account is Blocked signup, login, set-pin, verify-pin, otp/verify, refresh, me
not_found 404 The resource does not exist or belongs to another customer. The QA override also uses it when disabled. Bookings, orders, locations, districts, otp/verify (email with no account), ops/.../stage
conflict 409 A state conflict: slot full, booking no longer cancellable, destination locked after pickup POST /bookings, cancel, PATCH destination
unserviceable 422 The pickup point is outside operating cities, or a destination district closed POST /bookings
rate_limited 429 Too many OTP codes for this identifier this hour. A Retry-After header is sent. otp/request, signup
server_error 500 Unexpected failure. message is always "Something went wrong". Any handler

4.3 Pagination

Only GET /customer/bookings is paginated. It uses keyset (cursor) pagination, not page numbers.

Query param Type Default Rules
limit integer 20 Values ≤ 0 or non-numeric fall back to 20. Values above 50 are capped at 50.
cursor string none Send back the previous response's nextCursor. A non-numeric value is ignored (you get the first page).
status string none (all bookings) active, completed or cancelled (case-insensitive). Any other value means no filter.

The response has total (count for the whole filtered tab, not the page) and nextCursor (null on the last page). Rows are newest first. The generic pageno/pagesize helper in utils/pagination.go is not used on the customer surface.

Source: controllers/cxBookingController.go:29-31, 443-515.

4.4 Idempotency (Idempotency-Key)

Item Behaviour
Endpoints POST /customer/auth/otp/verify and POST /customer/bookings only. The header is ignored everywhere else.
Header Idempotency-Key: <any stable unique string>. A UUID made when the user taps the button is recommended. Reuse the same key for retries of the same action only.
Optional If the header is missing, or Redis is unavailable, the request runs normally with no protection.
Scope On an authenticated request the key is scoped to the customer id. On otp/verify (no token), it is scoped to a hash of the request path plus the exact request body, so a retry must send a byte-identical body to be recognised.
What is stored Only 2xx responses (status and body). 4xx and 5xx are never stored, so fixing a typo and retrying with the same key works.
TTL 24 hours
Replay Same status code and the same body as the first success, plus the response header Idempotent-Replay: true. The handler does not run again, so no second booking or session is created.
Concurrent duplicate While the first request is still running (lock up to 30 s), a second request with the same key gets 409 {"success": false, "code": "IDEMPOTENCY_IN_PROGRESS", "message": "an identical request is still being processed"}. This is not the customer envelope. Wait and retry with the same key.

Warning for otp/verify: a replay returns the original tokens. If the app has since refreshed, that refresh token is already revoked, and sending it again triggers the "revoke all sessions" rule (§3.8). Only replay otp/verify to recover from a response that never arrived.

Source: middlewares/idempotency.go:15-135, routes/routes.go:114, 164.

4.5 Rate limits

Limiter Scope Limit Response on limit
Global Every route except /health, /ready, /ws/* 300 requests / minute / IP 429 {success:false, message:"too many requests, please slow down"}
authThrottle The 7 public customer auth endpoints (shared with miler, admin and hub logins) 10 requests / minute / IP, one shared budget 429 {success:false, message:"too many attempts, please try again in a minute"}
OTP issue cap Per identifier (phone or email) 5 codes / hour, 30 s cooldown 429 rate_limited with Retry-After, or 200 sent:false during the cooldown
OTP attempts Per issued code 3 wrong attempts The code is destroyed and later attempts return 401 invalid_otp

Source: main.go:191-205, routes/routes.go:23-32, controllers/cxAuthController.go:38-50.

4.6 Caching and ETags

Endpoint Cache-Control ETag / 304
GET /serviceability/states max-age=300 Yes
GET /serviceability/states/:stateCode/districts max-age=300 Yes
GET /bookings/:reference no-cache on 200; max-age=300 on a 304 Yes
GET /pickup-slots max-age=30 No
POST /fare/estimate max-age=60 No

The ETag is a hash of the data payload. Send it back in If-None-Match to get 304 Not Modified with an empty body when nothing changed. * and weak (W/) forms are accepted. Because a booking's milerDistanceKm and milerEtaMinutes change as the rider moves, the booking ETag changes often while a rider is on the way.

Source: controllers/cxCatalogueController.go:168-191, controllers/cxBookingController.go:531-537, controllers/cxFareController.go:86-88.

4.7 City gate (operating area)

Operating pincode prefixes (middlewares/city_gate.go:397-404):

Prefix City
641 Coimbatore
600 Chennai
560 Bengaluru
500 Hyderabad
629 Nagercoil

How it is enforced for customer bookings:

  • POST /customer/bookings has CityGateMiddleware in its chain. That middleware looks only for a top-level pickuppincode field in the body, which the customer request does not have. So normally it does nothing. Do not send pickuppincode. If you do and it is outside the list, you get 400 {"error": "...", "code": "CITY_NOT_SUPPORTED"} (not the customer envelope).
  • The real check is inside the handler. The server takes the pickup lat/lng, finds the nearest active hub that has a pincode and coordinates (pincodeForPoint), and checks that hub's pincode prefix. If it is not in the list, it returns 422 unserviceable "We are not collecting from that area yet".
  • The nearest-hub lookup has no distance limit (§9.2). In practice the check fails only for lat=0, lng=0, or when no active hub has coordinates.

Source: middlewares/city_gate.go:406-446, controllers/cxBookingController.go:155-164, controllers/cxFareController.go:232-258.


5. Endpoint reference

Paths are relative to /api/v1. "Envelope" means the customer envelope from §4.1.

5.1 Auth

5.1.1 POST /customer/auth/otp/request

  • Auth: No. Middlewares: authThrottle.
  • Purpose: Send a 4-digit sign-in code to a phone (SMS) or an email address.

Request body

Field Type Required Rules
identifier string Yes A phone number (normalised per §1.6) or an email address (contains @).
{ "identifier": "9876543210" }

Success — 200

{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30, "codeLength": 4 }, "message": "" }

During the 30-second cooldown the response is still 200, but with "sent": false and resendAfterSeconds set to the seconds left (plus 1). Show the countdown. It is not an error.

Errors

Status Code When
400 invalid The body cannot be parsed, or the identifier is not a valid phone or email
429 rate_limited More than 5 codes for this identifier in an hour. Retry-After: 3600.
500 server_error Redis is unavailable, or SMS/email sending failed (including production with no SMS gateway)
429 (no code) authThrottle

Rules: The response does not reveal whether an account exists. Codes can be requested for identifiers with no account. An email code can only sign in to an existing account whose stored email matches (§5.1.3).

Source: controllers/cxAuthController.go:252-296 (issue logic 169-217).

5.1.2 POST /customer/auth/signup

  • Auth: No. Middlewares: authThrottle.
  • Purpose: Create an account and send an SMS code in one call.

Request body

Field Type Required Rules
name string Yes Whitespace is collapsed. It needs at least 2 characters. The last word becomes the last name, and the rest becomes the first name.
phone string Yes Normalised per §1.6
email string No Trimmed and lowercased. The format is not checked. It is saved only when a new account is created.
{ "name": "Priya Raman", "phone": "+91 98765 43210", "email": "priya@example.com" }

Success — 200

{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30 }, "message": "" }

There is no codeLength here, unlike otp/request. During the cooldown the response is sent: false.

Errors

Status Code When
400 invalid The body cannot be parsed, or the phone is invalid
400 invalid_name The name is missing or too short
403 forbidden The phone belongs to a Blocked account
429 rate_limited OTP hourly cap
500 server_error Database, Redis or send failure

Rules and side effects

  • If the phone already has an account, this is treated as a sign-in. A code is sent, and the new name and email are ignored.
  • A new account row is created before the code is verified, with status Active, no PIN and configid 1001. An account that never verifies stays in the database. See §9.1 for how this interacts with set-pin.
  • Next step: POST /customer/auth/otp/verify with identifier = the phone.

Source: controllers/cxAuthController.go:298-363.

5.1.3 POST /customer/auth/otp/verify

  • Auth: No. Middlewares: authThrottle, Idempotency (§4.4).
  • Purpose: Exchange a code for a session. A phone signup can also be finished here in one step.

Request body

Field Type Required Rules
identifier string Yes The same phone or email the code was sent to
code string Yes Trimmed and compared exactly
name string Conditional Required when the identifier is a phone with no account yet. It is also used to fill in the name of an existing account that has no first name. It never overwrites an existing name.
{ "identifier": "9876543210", "code": "<code>", "name": "Priya Raman" }

Success — 200: the session response (§3.4).

Errors

Status Code When
400 invalid The body cannot be parsed, the identifier is invalid, or the code is empty
401 invalid_otp Wrong, expired, destroyed after 3 misses, or never issued. The attempt counts.
400 invalid_name New phone and no usable name. The code has already been used up, so the user must request a new one.
404 not_found Email identifier with no account whose email matches. The code has been used up.
403 forbidden The account is Blocked
409 (non-envelope IDEMPOTENCY_IN_PROGRESS) The same key is already in flight
500 server_error Could not create the account or the session

Side effects: Creates the account (phone only) when needed and stamps lastloginat. Each successful verify creates a new refresh-token row.

Source: controllers/cxAuthController.go:521-592.

5.1.4 POST /customer/auth/refresh

  • Auth: No (the refresh token is the credential). Middlewares: authThrottle.
  • Purpose: Rotate the refresh token and get a new access token.

Request body

Field Type Required Rules
refreshToken string Yes Trimmed
{ "refreshToken": "<refresh_token>" }

Success — 200: the session response (§3.4) with a new refreshToken.

Errors

Status Code When
400 invalid The body cannot be parsed
401 unauthorized Empty, unknown, expired or revoked token, or the account no longer exists. For a revoked token, all of the customer's sessions are revoked as well.
403 forbidden The account is Blocked
500 server_error Could not revoke the old token or issue the new one

Source: controllers/cxAuthController.go:594-648.

5.1.5 POST /customer/auth/login (interim PIN)

  • Auth: No. Middlewares: authThrottle.
  • Purpose: Tell the app which screen comes next for a phone number.

Request body

Field Type Required Rules
phone string Yes Normalised per §1.6
{ "phone": "9876543210" }

Success — 200. The keys use snake_case here (pin_set), unlike the rest of the customer surface.

No account:

{ "success": true, "data": { "phone": "+919876543210", "registered": false, "pin_set": false }, "message": "" }

Account exists:

{ "success": true, "data": { "phone": "+919876543210", "registered": true, "pin_set": true, "name": "Priya Raman" }, "message": "" }

name is present only when registered is true.

Errors

Status Code When
400 invalid The body cannot be parsed, or the phone is invalid
403 forbidden The account is Blocked

Note: This endpoint tells an anonymous caller whether a phone is registered, and returns the account holder's name (§9.2).

Source: controllers/cxAuthController.go:387-415.

5.1.6 POST /customer/auth/set-pin (interim PIN)

  • Auth: No. Middlewares: authThrottle.
  • Purpose: Set the first PIN, creating the account if the phone is new, and sign in.

Request body (snake_case new_pin)

Field Type Required Rules
phone string Yes Normalised per §1.6
new_pin string Yes Exactly 4 digits 0-9. Leading and trailing spaces are tolerated when validating, but the untrimmed value is hashed, so send the PIN with no spaces.
name string Required for a new phone Same rules as signup. Ignored for an existing account.
{ "phone": "9876543210", "new_pin": "<4-digit PIN>", "name": "Priya Raman" }

Success — 200: the session response (§3.4).

Errors

Status Code When
400 invalid The body cannot be parsed, the phone is invalid, or the PIN is not 4 digits
400 invalid_name New phone with a missing or short name
403 forbidden The account is Blocked
409 pin_already_set The account already has a PIN. Go to verify-pin.
500 server_error Hash, create or save failure

Side effects: A new phone creates an account (Active, configid 1001, no email). An existing account without a PIN gets this PIN. This includes any account made by OTP signup, by otp/verify, or by the console. No proof of phone ownership is required (§9.1).

Source: controllers/cxAuthController.go:417-480.

5.1.7 POST /customer/auth/verify-pin (interim PIN)

  • Auth: No. Middlewares: authThrottle.
  • Purpose: Sign in with phone and PIN.

Request body

Field Type Required Rules
phone string Yes Normalised per §1.6
pin string Yes Must not be blank. Trimmed and checked against the stored hash.
{ "phone": "9876543210", "pin": "<4-digit PIN>" }

Success — 200: the session response (§3.4). It also stamps lastloginat.

Errors

Status Code When
400 invalid The body cannot be parsed, the phone is invalid, or the PIN is blank ("Enter your PIN")
401 invalid_pin Unknown phone or wrong PIN: "That phone number or PIN is incorrect"
403 forbidden The account is Blocked
409 pin_not_set The account has no PIN yet. Go to set-pin.

Source: controllers/cxAuthController.go:482-519.

5.1.8 GET /customer/auth/me

  • Auth: Yes. Purpose: Restore a session on cold start. This is the only endpoint that re-checks Blocked status on each call.

Success — 200

{ "success": true, "data": { "id": "cust_1042", "name": "Priya Raman", "phone": "+919876543210", "email": "priya@example.com" }, "message": "" }

Errors: 401 unauthorized (the customer row no longer exists), 403 forbidden (the account is blocked), plus the middleware errors in §3.6.

Source: controllers/cxAuthController.go:689-701.

5.1.9 POST /customer/auth/logout

  • Auth: Yes. Purpose: End the session and stop pushes to this device.

Request body (optional; an unreadable body is ignored)

Field Type Required Rules
refreshToken string No If present, only this session is revoked (and only if it belongs to the caller). If absent, every session of this customer is revoked.
deviceToken string No If present, this push token is deleted for this customer.
{ "refreshToken": "<refresh_token>", "deviceToken": "<fcm_token>" }

Success — 200

{ "success": true, "data": { "signedOut": true }, "message": "" }

Errors: 500 server_error if revoking the given refresh token fails. A failure to delete the device token is only logged.

Notes: The access token is not revoked and works until it expires (≤ 1 h). Discard it on the client. An unknown or foreign refreshToken still returns signedOut: true.

Source: controllers/cxAuthController.go:650-687.


5.2 Catalogue, serviceability and configuration (public)

These four reads need no token, so the booking form can be explored before sign-in.

5.2.1 GET /customer/serviceability/states

  • Auth: No. Caching: ETag, max-age=300.
  • Purpose: List the states a destination can be in (states with status Active, sorted by displayorder and then name).

Success — 200 (list)

{
  "success": true,
  "data": [
    { "code": "TN", "name": "Tamil Nadu", "districtCount": 12, "transitTag": "Ultra-fast transit" },
    { "code": "KL", "name": "Kerala", "districtCount": 0, "transitTag": "Opening soon" }
  ],
  "total": 2, "nextCursor": null, "message": ""
}
Field Type Notes
code string State code (up to 8 characters)
name string
districtCount integer Counts available districts only. The app hides a state that shows 0.
transitTag string Display copy, up to 22 characters. Can be "".

An empty list is a valid answer (no service anywhere).

Errors: 500 server_error.

Source: controllers/cxCatalogueController.go:30-75.

5.2.2 GET /customer/serviceability/states/:stateCode/districts

  • Auth: No. Caching: ETag, max-age=300.
  • Purpose: List every district in a state, including unavailable ones. Available districts come first.

Path params

Name Type Rules
stateCode string Trimmed and uppercased. It must be an Active state.

Success — 200 (list)

{
  "success": true,
  "data": [
    { "code": "TN-CBE", "name": "Coimbatore", "available": true, "hub": "Coimbatore Central", "promise": "Next-day delivery", "lat": 11.0168, "lng": 76.9558 },
    { "code": "TN-NLG", "name": "The Nilgiris", "available": false, "note": "Opening soon" }
  ],
  "total": 2, "nextCursor": null, "message": ""
}
Field Type Present Notes
code string Always District code (up to 16 characters). Send it as districtCode.
name string Always
available boolean Always Only available districts can be booked.
note string Only when not empty Why it is closed
hub string Only when a serving hub is set and found Hub name
promise string Only when not empty Delivery promise copy
lat, lng number Only when the district centre is known (not 0,0) District centre. It is used for pricing and map placement.

The example codes above are illustrative. Real codes come from the serviceabledistricts table.

Errors: 400 invalid (empty code), 404 not_found "That state is no longer serviceable", 500 server_error.

Source: controllers/cxCatalogueController.go:77-140.

5.2.3 GET /customer/pickup-slots

  • Auth: No. Caching: max-age=30.
  • Purpose: List the pickup windows that can be offered at a location, for today and tomorrow (IST).

Query params

Name Type Required Notes
lat float No Pickup latitude. It picks the city templates (nearest active hub within 60 km) and the capacity zone.
lng float No Pickup longitude

If lat/lng are missing or unparseable they count as 0,0. Then only city-agnostic templates are used, capacity is counted city-wide, and milersNearby is left out.

Success — 200 (list)

{
  "success": true,
  "data": [
    { "id": "slot_20260928_t2", "day": "Today", "window": "2:00 – 4:00 PM", "available": true, "tag": "Fastest pickup", "milersNearby": 4, "caption": "Most riders free" },
    { "id": "slot_20260928_t3", "day": "Today", "window": "4:00 – 6:00 PM", "available": false, "note": "Fully booked", "milersNearby": 4 },
    { "id": "slot_20260929_t1", "day": "Tomorrow", "window": "10:00 AM – 12:00 PM", "available": true, "milersNearby": 4 }
  ],
  "total": 3, "nextCursor": null, "message": ""
}
Field Type Present Notes
id string Always Opaque slot id. Send it as slotId.
day string Always "Today", "Tomorrow" or "Mon, 8 Sep" (IST)
window string Always "2:00 – 4:00 PM". The meridiem is shown once when both ends share it.
available boolean Always false when the zone's booked count ≥ the template capacity
note string When not available "Fully booked"
tag string At most one slot, only when available Promotional label from the template
caption string Only when available and set
milersNearby integer Only when > 0 Riders reporting a position within 6 km (Redis GEO)

Rules

  • Windows that start within 45 minutes (or earlier) are left out entirely.
  • Capacity counts bookings whose preferred pickup start falls inside the window, within 12 km of the given point, excluding Cancelled and Converted_To_Consignment bookings. If that query fails, the window is shown as open.
  • This list is advisory. Capacity is checked again when the booking is created (409 conflict).

Errors: 500 server_error (template query failed).

Source: controllers/cxCatalogueController.go:193-296 (id format 302-304, capacity 390-430).

5.2.4 GET /customer/config/booking-limits

  • Auth: No.
  • Purpose: Get the caps for one pickup. Fetch it again when the pickup point moves.

Query params: lat, lng (float, optional). They resolve the city through the nearest active hub within 60 km.

Success — 200

{ "success": true, "data": { "maxPackages": 20, "maxDestinations": 1 }, "message": "" }

Rules: The city row in customerbookinglimits is used first, then the global row (applocationid IS NULL), then the built-in defaults: maxPackages 20, maxDestinations 1. The destination default is deliberately 1, so multi-destination stays off until ops adds a configuration row. A configured value of 0 or less is ignored. The same function is the authority at booking create.

Source: controllers/cxCatalogueController.go:494-563.


5.3 Profile

5.3.1 GET /customer/profile

  • Auth: Yes. Purpose: Read the profile.

Success — 200: the same shape as /auth/me: {id, name, phone, email}.

Errors: 404 not_found "We could not find your profile".

Source: controllers/customerController.go:40-49.

5.3.2 PUT /customer/profile

  • Auth: Yes. Purpose: Update the profile. A field that is left out is not changed.

Request body (camelCase; every field optional)

Field Type Rules
name string Same rules as signup. 400 invalid_name if too short.
email string Stored exactly as sent. Not validated, not lowercased, and not checked for uniqueness (§9.2).
defaultLatitude number Stored as is
defaultLongitude number Stored as is
defaultPincode string Stored as is
{ "name": "Priya R", "email": "priya@example.com" }

Success — 200: {id, name, phone, email}. The default location fields are stored but are not returned by any customer endpoint.

Errors: 400 invalid, 400 invalid_name, 404 not_found, 500 server_error.

Note: The phone number cannot be changed.

Source: controllers/customerController.go:51-100.


5.4 Saved locations

The request body for create and update uses the all-lowercase keys of dto.LocationCreateRequest (for example receivername, isdefault). The response uses camelCase (for example recipientName, isDefault). This mismatch is how the code works today.

Saved address object (response)

{
  "id": "17",
  "label": "Home",
  "title": "Home",
  "sub": "12 Race Course Rd, Near Park, Coimbatore, 641018",
  "recipientName": "Priya Raman",
  "recipientPhone": "9876543210",
  "lat": 11.0015,
  "lng": 76.9711,
  "isDefault": true
}
Field Type Notes
id string Location id
label string Can be ""
title string label, or address when the label is empty
sub string The non-empty parts of address, landmark, city and pincode, joined with ", "
recipientName, recipientPhone string Stored as sent. The phone is not normalised.
lat, lng number
isDefault boolean

Source: controllers/customerController.go:242-262, dto/booking.go:5-17.

5.4.1 GET /customer/locations

  • Auth: Yes. Purpose: List active saved addresses, default first, then newest.
  • Success — 200 (list): saved address objects. total = count, nextCursor = null.
  • Errors: 500 server_error.

Source: controllers/customerController.go:102-117.

5.4.2 POST /customer/locations

  • Auth: Yes. Purpose: Save an address.

Request body

Field Type Required Rules
address string Yes Must not be empty
pincode string Yes Must not be empty (the format is not checked)
latitude number Yes Must not be 0
longitude number Yes Must not be 0
label string No For example "Home" or "Work"
receivername string No
receiverphone string No Not normalised
landmark string No
city string No
state string No
isdefault boolean No When true, every other saved address of the customer is set to not-default.
{
  "label": "Home",
  "receivername": "Priya Raman",
  "receiverphone": "9876543210",
  "address": "12 Race Course Rd",
  "landmark": "Near Park",
  "city": "Coimbatore",
  "state": "Tamil Nadu",
  "pincode": "641018",
  "latitude": 11.0015,
  "longitude": 76.9711,
  "isdefault": true
}

Success — 201: a saved address object.

Errors

Status Code When
400 invalid The customer already has 10 active addresses ("You can save up to 10 addresses"). This is checked before the body is parsed.
400 invalid The body cannot be parsed, or a required field is missing ("An address needs a street, a pincode and a map location")
500 server_error Insert failed

Source: controllers/customerController.go:119-165.

5.4.3 PUT /customer/locations/:id

  • Auth: Yes. Purpose: Update a saved address that the caller owns.

Path params: id (integer string).

Request body: the same fields as create, none required. The update rules are mixed, so send the full object:

Field(s) If the field is left out or empty
label, address, pincode Kept (only overwritten when not empty)
latitude, longitude Kept (only overwritten when not 0)
receivername, receiverphone, landmark, city, state Cleared to ""
isdefault Set to false

Success — 200: a saved address object.

Errors: 400 invalid (id not numeric, or body cannot be parsed), 404 not_found (not the caller's, or does not exist), 500 server_error.

Note: The update also works on a soft-deleted address. It does not reactivate it.

Source: controllers/customerController.go:167-218.

5.4.4 DELETE /customer/locations/:id

  • Auth: Yes. Purpose: Soft-delete a saved address (its status is set to InActive).

Success — 200

{ "success": true, "data": { "id": "17", "deleted": true }, "message": "" }

Errors: 400 invalid (id not numeric), 404 not_found, 500 server_error. Deleting an address that is already deleted returns 200 again.

Source: controllers/customerController.go:220-240.


5.5 Devices (push registration)

5.5.1 POST /customer/devices

  • Auth: Yes. Purpose: Register an FCM push token for the signed-in customer. Call it after sign-in and whenever FCM gives a new token.

Request body

Field Type Required Rules
token string Yes Trimmed and must not be empty. It is unique across all customers.
platform string No android or ios (case-insensitive). Any other value is stored as "".
appVersion string No Trimmed
{ "token": "<fcm_token>", "platform": "android", "appVersion": "1.0.3" }

Success — 200

{ "success": true, "data": { "registered": true }, "message": "" }

Errors: 400 invalid (body cannot be parsed, or the token is empty), 500 server_error.

Rules: This is an upsert on the token. If the token was registered to another customer (shared handset), it moves to the caller. A customer can have many devices, and all of them receive pushes.

Source: controllers/cxDeviceController.go:22-67.

5.5.2 DELETE /customer/devices/:token

  • Auth: Yes. Purpose: Remove a push token (only if it belongs to the caller).

Path params: token. URL-encode it, because FCM tokens can contain :.

Success — 200

{ "success": true, "data": { "registered": false }, "message": "" }

This is returned even if nothing was deleted.

Errors: 400 invalid (empty token), 500 server_error.

Source: controllers/cxDeviceController.go:69-85.


5.6 Places

Both endpoints proxy a Nominatim-compatible geocoder (GEOCODER_URL, default public OSM Nominatim). The app is never given a maps key. Results are cached in Redis: reverse geocode for 7 days (key rounded to 5 decimals), search for 24 hours.

Place object: { "title": string (≤ 32 characters), "sub": string, "lat": number, "lng": number }. title and sub are never null.

5.6.1 GET /customer/places/reverse-geocode

  • Auth: Yes. Purpose: Turn coordinates into a two-line pickup label.

Query params

Name Type Required Rules
lat float Yes Must parse. lat=0 together with lng=0 is rejected.
lng float Yes Must parse

Success — 200

{ "success": true, "data": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 }, "message": "" }

If the geocoder fails or times out (4 s), the response is still 200, with title: "Selected location" and sub: "<lat>, <lng>" (5 decimals).

Errors: 400 invalid "We need a location to look up".

Source: controllers/cxPlacesController.go:55-91, 153-162, 218-254.

5.6.2 GET /customer/places/search

  • Auth: Yes. Purpose: Back the pickup-point search sheet.

Query params

Name Type Required Notes
q string No Search text. Empty or missing returns the customer's own places (see below). There is no length limit.
lat, lng float No Bias the results to about ±0.75° (about 80 km) around this point. The results are not strictly limited to that box.

Success — 200 (list): up to 6 place objects from the geocoder, limited to India (countrycodes=in).

When q is empty, the list has up to 4 places: the customer's active saved addresses (default first), then the pickup points of their 10 most recent bookings, de-duplicated by coordinates (4 decimals). Entries at 0,0 are skipped.

If the geocoder fails, the response is 200 with an empty list (not an error).

Source: controllers/cxPlacesController.go:93-130, 164-188, 265-321.


5.7 Fare estimate

5.7.1 POST /customer/fare/estimate

  • Auth: Yes. Caching: max-age=60.
  • Purpose: Get a price range for a whole pickup (one visit, all destinations). The app calls it whenever the route or the package count changes. This is never the final price. The miler weighs the parcels at the door.

Request body

Field Type Required Rules
pickup.lat number No 0 is allowed (then no distance is used)
pickup.lng number No
destinations array Yes At least 1. There is no upper limit (unlike booking create).
destinations[].stateCode string No Not validated here
destinations[].districtCode string No Looked up case-insensitively. An unknown district is priced with the fallback formula and zone National.
destinations[].packageCount integer No Values < 1 count as 1
{
  "pickup": { "lat": 11.0015, "lng": 76.9711 },
  "destinations": [
    { "stateCode": "TN", "districtCode": "TN-CHN", "packageCount": 2 },
    { "stateCode": "KA", "districtCode": "KA-BLR", "packageCount": 1 }
  ]
}

Success — 200

{
  "success": true,
  "data": { "min": 312, "max": 468, "paymentMethod": "UPI · Cash at doorstep", "parcel": "3 boxes (up to 3 kg each)", "routeKm": 356.4 },
  "message": ""
}
Field Type Notes
min, max integer (INR) min is rounded down and max is rounded up. max ≥ min.
paymentMethod string Always "UPI · Cash at doorstep"
parcel string "Standard box (up to 3 kg)" for 1 package, otherwise "<n> boxes (up to 3 kg each)"
routeKm number Straight-line distance from the pickup to the farthest district centre, 1 decimal place

How the price is computed

  • Each destination is one leg. The assumed weight is packageCount × 3 kg.
  • The zone (Local, Regional or National) comes from the nearest hub's pincode (pickup) and the district's pincodeprefix (resolveZone).
  • If doormilepricing rules exist for that zone, service type Normal and the weight (category General, or any category), the leg range is the lowest minprice to the highest maxprice among the matching rules.
  • Otherwise the fallback is base = 50 + 5 × km + 10 × kg, with a range of 0.8 × base to 1.2 × base.
  • Every destination after the first is multiplied by 1.35 (+35% multi-stop uplift).
  • The legs are added up.

Errors: 400 invalid (body cannot be parsed, or no destinations). Pricing failures fall back to the formula and never return an error.

Source: controllers/cxFareController.go:27-97, 99-204, 232-266.


5.8 Bookings

5.8.0 The booking object

Every endpoint that returns a booking (create, list rows, detail, patch, order lookup, QA override) uses this one shape, built by renderCxBooking. List rows and the detail response are identical, except that milersInZone is only calculated on single-booking reads.

{
  "reference": "DM-482913",
  "stage": "on_the_way",
  "status": "active",
  "cancellable": true,
  "createdAt": 1790583000000,
  "pickup": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 },
  "slotId": "slot_20260928_t2",
  "destinations": [
    {
      "stateCode": "TN",
      "stateName": "Tamil Nadu",
      "districtCode": "TN-CHN",
      "districtName": "Chennai",
      "packageCount": 2,
      "district": { "code": "TN-CHN", "name": "Chennai", "available": true, "hub": "Chennai Central", "promise": "Next-day delivery" },
      "details": {
        "street": "4th Cross Street",
        "building": "Flat 3B, Lotus Apartments",
        "landmark": "Opp. bus depot",
        "recipientName": "Arun Kumar",
        "recipientPhone": "+919876543210",
        "instructions": "Call before arriving",
        "pin": { "lat": 13.0418, "lng": 80.2341 }
      },
      "trackingId": null,
      "stage": null,
      "verification": null
    }
  ],
  "miler": { "name": "Ravi", "vehicle": "TN38XX0001", "phone": "<rider or proxy number>", "rating": 4.8, "trips": 212, "vehicleType": "Bike" },
  "deliveryAgent": null,
  "milerDistanceKm": 1.4,
  "milerEtaMinutes": 6,
  "milersInZone": 0,
  "routeKm": 498.2,
  "expectedDelivery": "",
  "fare": { "min": 180, "max": 260, "paymentMethod": "UPI · Cash at doorstep", "parcel": "2 boxes (up to 3 kg each)" },
  "amountPaid": null,
  "deliveredAt": null,
  "cancelReason": null,
  "history": [
    { "stage": "booked", "at": 1790583000000 },
    { "stage": "assigned", "at": 1790583300000 },
    { "stage": "on_the_way", "at": 1790583420000 }
  ]
}
Field Type Nullable Notes
reference string No DM-###### (§1.4)
stage string No One of the 9 stage keys (§6.1). For a booking with no stored stage, it is derived from the operational status (§6.2).
status string No active, completed or cancelled. It is forced to cancelled whenever the operational status is Cancelled, even if ops cancelled it.
cancellable boolean No status == "active" and stage rank ≤ arrived. Use it to show or hide the button. The server checks again on cancel.
createdAt integer (epoch ms) No
pickup object No {title, sub, lat, lng}. Always present. Bookings made in the console get a title and sub split from the flat address ("Pickup address" or "Address not recorded" as a last resort).
slotId string No Can be "" for bookings not made by the app
destinations array No Can be empty for bookings made in the console. See the table below.
miler object Yes The rider whose assignment is Assigned, Accepted or Completed. {name, vehicle, phone, rating, trips, vehicleType}. phone is the MILER_CALL_PROXY number if that is configured, otherwise the rider's real mobile number.
deliveryAgent object Yes The same shape as miler: the rider who recorded Out_for_Delivery on the first destination that has one
milerDistanceKm number Yes Only when stage is on_the_way or arrived and a miler exists. 0 at arrived, or when the rider has no position. Straight-line distance, 1 decimal.
milerEtaMinutes integer Yes The same condition. km / 18 km/h × 60 + 2. 0 at arrived.
milersInZone integer No Riders within 6 km of the pickup. Calculated only on a single-booking read, at stage booked, with no miler. Otherwise 0.
routeKm number No Stored from the quote at create
expectedDelivery string No The latest expecteddeliveryat across the destinations, formatted "Mon, 2 Jan" (IST). "" until one is set (at pickup-complete).
fare object No {min, max, paymentMethod, parcel} from the estimate stored at create. It stays on the booking permanently.
amountPaid integer (INR) Yes The sum of Paid payment rows. Present only from stage picked_up onward and only if a payment exists.
deliveredAt integer (epoch ms) Yes Set only when every destination is delivered (the latest delivery time)
cancelReason string Yes Set when a cancel reason is stored. A customer cancel with no reason leaves it null.
history array No [{stage, at}]. One entry per stage actually reached, with real timestamps. For per-order stages (in_transit onward) the timestamp is when the slowest order reached it. Cancel and release audit rows are left out. Order: entries are sorted by occurrence time, then by event id. At pickup-complete, order_created (and possibly in_transit/out_for_delivery) rows are written before the picked_up row, at almost the same moment. Sort by stage rank (§6.1) if strict order matters.

Destination object

Field Type Nullable Notes
stateCode, stateName, districtCode, districtName string No Names are copied at booking time
packageCount integer No
district object Yes Live district card {code, name, available, hub?, promise?}. null if the district row is gone.
details object No Only the fields that are filled in: street, building, landmark, recipientName, recipientPhone, instructions, pin{lat,lng}. Can be {}. codAmount is accepted in requests but never returned.
trackingId string Yes DMX######## from order_created onward
stage string Yes Per-order stage from order_created onward
verification object Yes From pickup: {weightKg: number, photos: [signed URL strings, valid 30 min], capturedAt: epoch ms, capturedBy: rider name or ""}. Photos that fail to sign are left out.

Source: controllers/cxBookingView.go:66-307, 368-508.

5.8.1 POST /customer/bookings

  • Auth: Yes. Middlewares (in order): Auth → Role 9 → CityGateMiddleware → Idempotency.
  • Purpose: Create a pickup booking: one visit, 1..N destinations. No tracking number exists yet.
  • Send Idempotency-Key on every create and reuse it for retries (§4.4).

Request body

Field Type Required Rules
pickup object Yes
pickup.title string Recommended Short label from place search. Not validated.
pickup.sub string Recommended Full address line. Not validated.
pickup.lat number Yes Must resolve to an operating city (§4.7). 0,0 returns 422.
pickup.lng number Yes
slotId string Yes From GET /pickup-slots
destinations array Yes 1 to maxDestinations (configured; default 1). Hard ceiling 25.
destinations[].stateCode string Yes Must not be blank. The stored state comes from the district row, not this value.
destinations[].districtCode string Yes Case-insensitive. Must exist in serviceabledistricts and be available.
destinations[].packageCount integer No < 1 becomes 1. The total across destinations must be ≤ maxPackages (default 20).
destinations[].details object No Optional address details (below)
estimate object No {min: int, max: int}, the range shown on the Review screen. See the price-tamper rule below.
remarks string No Free-text note (for example "Handle with care"). Stored in the booking notes and shown in the admin console.

details object (every field optional; also the body of the PATCH endpoint):

Field Type Rules
street string Trimmed
building string Trimmed
landmark string Trimmed
recipientName string Trimmed
recipientPhone string Normalised per §1.6. If that fails, the raw trimmed value is stored.
instructions string Trimmed
pin object {lat, lng} An exact destination pin. {0,0} clears it.
codAmount number Cash to collect at this door for the customer. Not validated (a negative number is accepted). Never returned.
{
  "pickup": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 },
  "slotId": "slot_20260928_t2",
  "destinations": [
    {
      "stateCode": "TN",
      "districtCode": "TN-CHN",
      "packageCount": 2,
      "details": {
        "building": "Flat 3B, Lotus Apartments",
        "street": "4th Cross Street",
        "recipientName": "Arun Kumar",
        "recipientPhone": "9876543210",
        "pin": { "lat": 13.0418, "lng": 80.2341 },
        "codAmount": 0
      }
    }
  ],
  "estimate": { "min": 180, "max": 260 },
  "remarks": "Handle with care"
}

Validation order (the first failure wins):

# Check Result on failure
1 The body parses 400 invalid "We could not read that request"
2 At least one destination 400 invalid "Add at least one destination"
3 slotId is not blank 400 invalid "Pick a pickup slot"
4 The slot date is not before today (IST) 400 invalid "That pickup time has passed — pick a new slot"
5 At most 25 destinations 400 invalid "That is more destinations than one pickup can carry"
6 The pickup point is in an operating city 422 unserviceable "We are not collecting from that area yet"
7 At most maxDestinations 400 invalid "Up to N destinations per pickup"
8 Every destination has a known district and a non-blank stateCode 400 invalid "Every destination needs a serviceable state and district"
9 Every district is available 422 unserviceable "That district is no longer available"
10 Total packages ≤ maxPackages 400 invalid "Up to N packages per pickup"
11 The slot id resolves to an active template 400 invalid "Pick a pickup slot"
12 The slot start is after now (IST) 400 invalid "That pickup time has passed — pick a new slot"
13 The slot has capacity in the pickup zone 409 conflict "That pickup window just filled up"

Other errors: 500 server_error (any database failure; the whole create is one transaction, so nothing partial is left behind), the non-envelope 409 IDEMPOTENCY_IN_PROGRESS, and the non-envelope 400 CITY_NOT_SUPPORTED (only if you send pickuppincode).

Success — 201: the booking object (§5.8.0), with stage: "booked" and status: "active".

Business rules and side effects

  • Price-tamper protection (commit ba2cd22): The server always calculates its own quote with the same function as /fare/estimate. The client estimate is kept only if min ≥ 0, max ≥ min, the server quote midpoint is > 0, and the client midpoint is within ±15% of the server midpoint. Otherwise the server quote is stored silently: no error, only a server log line. The stored range becomes fare.min/fare.max, and its midpoint becomes the booking's Estimatedprice (which feeds rider pay and billing). Always send the exact min/max from the most recent /fare/estimate response.
  • A zero or failed quote never blocks the booking.
  • The slot lead time (45 min) is not checked again at create. Only "the start is in the future" is checked.
  • The capacity check is not atomic. Two bookings at the same moment can both succeed in the last place of a window.
  • Created rows: one pickupbookings row (status Pending_Pickup, source Customer_App, destination 0 copied onto the flat delivery columns), one bookingdestinations row per destination (seq 0..N-1), one bookingparcels row per package (weight 0), one bookingserviceoptions row (Normal, estimated delivery = slot end + 24 h, SLA = slot end + 48 h), and a booked stage event.
  • After commit: automatic miler assignment starts in the background (assignment.AssignCustomerMiler), and the NATS event api.v1.bookings.create is published (best effort).
  • There is no per-customer limit on the number of bookings or active bookings.

Source: controllers/cxBookingController.go:48-118 (types and tamper check), 120-440 (handler), 895-922 (event).

5.8.2 GET /customer/bookings

  • Auth: Yes. Purpose: List bookings for the Orders tabs and the Home screen's recent list.

Query params: status, limit, cursor (§4.3).

Tab filters:

status Rows included
active customerstatus = 'active', or (no customer status and operational status is not Cancelled)
completed customerstatus = 'completed'
cancelled customerstatus = 'cancelled' or operational status Cancelled
any other value, or missing All bookings of the customer

Success — 200 (list): data is an array of booking objects. There is also total and nextCursor.

Errors: 500 server_error. If only the count fails, total falls back to the page size.

Known edge case: A booking cancelled by ops through a path that sets only the operational status (for example the admin "update status" endpoint) can match both the active and cancelled filters, because its stored customerstatus stays active. It is still rendered with status: "cancelled". Filter on the returned status on the client if needed.

Source: controllers/cxBookingController.go:442-515.

5.8.3 GET /customer/bookings/:reference

  • Auth: Yes. Caching: ETag / If-None-Match → 304.
  • Purpose: The canonical single booking read. It drives the tracking screen and the receipt, and the app polls it while tracking is open. The poll interval is decided by the app. The code comment says "every few seconds".

Path params: reference (for example DM-482913). It is matched exactly and is scoped to the caller.

Success — 200: the booking object. Cache-Control: no-cache.

Errors: 404 not_found "We could not find that pickup" (does not exist, or belongs to someone else).

Source: controllers/cxBookingController.go:517-538.

5.8.4 POST /customer/bookings/:reference/cancel

  • Auth: Yes. Purpose: Cancel the whole pickup. Cancelling part of a pickup is not supported.

Request body (optional; an unreadable body is ignored)

Field Type Required Notes
reason string No Trimmed and stored as cancelReason
{ "reason": "Changed my mind" }

Success — 200

{ "success": true, "data": { "reference": "DM-482913", "status": "cancelled", "cancelReason": "Changed my mind" }, "message": "" }

If the booking is already cancelled, the response is also 200, with the stored reason (a retry-safe no-op).

Errors

Status Code When
404 not_found Unknown reference, or not the caller's
409 conflict Stage is picked_up or later: "This pickup can no longer be cancelled"
500 server_error Transaction failure

Rules and side effects

  • Allowed at stages booked, assigned, on_the_way and arrived. If the booking has no stored stage, the stage is derived from the operational status.
  • In one transaction: the booking gets status Cancelled and customer status cancelled, a cancel audit event is written, any open (Assigned/Accepted) assignment becomes Cancelled ("cancelled by customer"), and the assigned rider is set back to Available.
  • After commit: the NATS event api.v1.bookings.cancel is published. No push is sent to the customer. Code for notifying the rider is not visible in this handler.
  • There is no cancellation fee logic.
  • The endpoint does not take an Idempotency-Key. It is naturally idempotent (the already-cancelled case returns 200).

Source: controllers/cxBookingController.go:570-657, internal/cxstage/stage.go:261-303.

5.8.5 PATCH /customer/bookings/:reference/destinations/:index

  • Auth: Yes. Purpose: Fill in or correct one destination's address details before the parcels are collected.

Path params

Name Type Rules
reference string The caller's booking
index integer 0-based position (seq) of the destination in destinations[]. It must be ≥ 0.

Request body: a details object (the same fields as §5.8.1).

  • A field that is left out is not changed.
  • A string field sent as null is also left unchanged (the code only writes non-nil values). To clear a text field, send "".
  • To clear the pin, send "pin": {"lat": 0, "lng": 0}.
  • stateCode, districtCode and packageCount cannot be changed.
{ "landmark": "Opp. bus depot", "recipientPhone": "9876543210", "instructions": "Call before arriving" }

Success — 200: the full updated booking object.

Errors

Status Code When
404 not_found index is not numeric or is negative, the booking is not found, or no destination exists at that index (bookings from the console have none)
409 conflict Stage is picked_up or later: "Your packages have been collected — these details can no longer be changed"
409 conflict The booking is cancelled: "This pickup was cancelled"
400 invalid The body cannot be parsed (including an empty body)
500 server_error Save failure

Side effects: For index 0, the booking's flat deliveryaddress (and the delivery lat/lng when a pin is set) is updated as well, so the rider app sees the change straight away. No push or event is sent.

Source: controllers/cxBookingController.go:659-741, 776-816.


5.9 Orders / tracking

5.9.1 GET /customer/orders/:trackingId

  • Auth: Yes. Purpose: Open a booking from a push deep link (doormile://track/<trackingId>).

Path params: trackingId, for example DMX10482913 (matched exactly).

Success — 200: the whole booking object (§5.8.0) that contains this order, not a separate order shape. Find the matching destination by destinations[].trackingId.

Errors: 404 not_found "We could not find that order". This is returned for an unknown tracking number and for one that belongs to another customer.

Note: Push deep links fall back to doormile://track/<bookingReference> when there is no tracking number yet (§8). That value is a booking reference, not a tracking id. Route it to GET /customer/bookings/:reference instead.

Source: controllers/cxBookingController.go:540-568.


5.10 Ops / QA endpoint (must not be used by the shipped app)

5.10.1 POST /customer/ops/bookings/:reference/stage

  • Auth: Yes (a normal customer token). Environment gate: works only when ENV is not production and CX_ALLOW_STAGE_OVERRIDE=true. Otherwise it returns 404 not_found "Not available".
  • Purpose: Move one of your own bookings to any stage, so that every tracking screen can be checked in QA. Remove any client code that calls this from release builds.

Request body

Field Type Required Rules
stage string Yes One of the 9 stage keys (§6.1)
reason string No Default "forced on staging for QA". Stored on every audit row.
{ "stage": "out_for_delivery", "reason": "design QA" }

Success — 200: the updated booking object.

Errors: 404 not_found (disabled, or booking not found), 400 invalid (body cannot be parsed, or unknown stage), 500 server_error.

Behaviour

  • Writes a stage event for every stage from booked up to the target, with no gaps. Per-order stages are written for each destination.
  • Creates tracking numbers for destinations that have none when the target is order_created or later.
  • Stages only move forward. You cannot force a stage lower than the current one.
  • It does not change the operational status, assignments, payments or verification. So miler, amountPaid and verification stay null.
  • It sends no pushes.
  • It does not work on bookings not made by the customer app.

Source: controllers/cxOpsController.go:16-160, routes/routes.go:173-178.


5.11 WebSockets

See §7.

5.12 Public pricing endpoints (not part of the customer contract)

These are registered at the API root, not under /customer. They use the older envelope {success, data} with no message/error.code, and field names in snake_case. The customer app does not need them, because /customer/fare/estimate covers the booking flow. They are listed here only because they are public.

Endpoint Auth Body / response
GET /pricing/meta No data: {zones:[{value,label}], categories:[{value,label}], service_types:[{value,label}]}
POST /pricing/check No Body {zone?, service_type, weight, category?, pickup_pincode?, delivery_pincode?}. zone is Local, Regional or National (or resolved from the two pincodes). service_type is Normal or Express. weight must be > 0. Errors are 400 {success:false, message, valid_*}.

Source: routes/routes.go:498-502, controllers/doormilePricingController.go:159-231, 457-479.


6. Booking lifecycle

6.1 Customer stages

Nine keys, lowercase snake_case, sent exactly as written. The client falls back to booked for an unknown key, so stages cannot be added or renamed without an app release.

Rank stage Level Meaning Cancel allowed Destination PATCH allowed
0 booked Booking Pickup requested, no rider yet Yes Yes
1 assigned Booking A rider has been assigned Yes Yes
2 on_the_way Booking The rider accepted and is heading to the pickup (distance and ETA shown) Yes Yes
3 arrived Booking The rider is at the pickup. Last cancellable stage. Yes Yes
4 picked_up Booking Parcels collected, weighed and photographed No No
5 order_created Booking One order and tracking number per destination No No
6 in_transit Per order The order is in the hub network No No
7 out_for_delivery Per order A delivery rider is carrying it No No
8 delivered Per order Handed over No No
  • Stages 0–5 belong to the booking. Stages 6–8 belong to each destination (destinations[].stage). The booking-level stage is the slowest destination's stage. A destination with no stage yet counts as order_created.
  • status: active until every destination is delivered (then completed). cancelled is terminal. A cancelled booking keeps the stage it had.
  • Stages only move forward. The exception is a rider release: when the rider cancels their assignment, the booking goes back to booked (the history keeps the earlier entries).

Source: constants/constants.go:206-275, internal/cxstage/stage.go:62-259.

6.2 What moves each stage

Stage Written by (backend event) Push to customer?
booked POST /customer/bookings No
assigned A rider is assigned: automatically (internal/assignment/crm_assignment.go:257) or by the console/hub (controllers/booking_assignment_service.go:80) Yes
on_the_way The rider accepts: POST /miler/assignments/:id/accept (controllers/milerController.go:612) No (see §8)
arrived The rider taps reached: POST /miler/bookings/:id/reached (controllers/milerController.go:869) Yes
picked_up, order_created POST /miler/bookings/:id/pickup-complete (controllers/milerController.go:1435-1490). It also writes in_transit or out_for_delivery straight away when the new consignment's status already implies it (hub-routed parcels are Inwarded_at_Hub; hyperlocal parcels go straight to Out_for_Delivery, while the hub-handover feature flag is off). Yes (picked_up only)
in_transit Base handover: POST /miler/consignments/:id/inward-at-hub (controllers/logisticsHandoverController.go:541) Yes
out_for_delivery POST /miler/consignments/:id/start-delivery (controllers/milerAppController.go:733) Yes
delivered POST /miler/consignments/:id/deliver (controllers/milerAppController.go:928) Yes
back to booked The rider cancels: POST /miler/bookings/:id/cancel (controllers/milerController.go:793) No
cancelled Customer cancel; admin cancel and bulk cancel (controllers/adminController.go:2964, 3052) Customer: no. Admin: only through the legacy device-token column (§8).

6.3 Mapping from backend status (bookings with no stored stage)

Bookings made in the console, and rows from before this surface existed, have no customerstage. Their stage is derived from pickupbookings.status:

Operational booking status Customer stage
Cancelled booked (and status = cancelled)
Picked_Up picked_up
Converted_To_Consignment order_created
Miler_Assigned, Pickup_Scheduled arrived if the arrival time is recorded, else assigned
Anything else (Pending_Pickup, Created, Arrived_At_Pickup, …) booked

Consignment status to per-order stage (used when consignment events are recorded):

Consignment status Per-order stage
Inwarded_at_Hub, Tripsheet_Loaded, In_Transit in_transit
Out_for_Delivery out_for_delivery
Delivered delivered
Created, Collected_By_Miler none (stays order_created)
RTO_Initiated, Returned_to_Sender, Missing, Damaged, and failed attempts none. The customer keeps seeing the last stage (§9.2).

Source: controllers/cxBookingView.go:370-402, controllers/cxConsignmentHooks.go:20-41.

6.4 Allowed customer actions by stage

Action Allowed when
Cancel status = active and stage rank ≤ 3 (arrived). Already cancelled returns 200 (no-op).
PATCH destination details Stage rank < 4 (before picked_up) and not cancelled
Change pickup point, slot, destinations or package counts after creation Not supported. No endpoint exists.

7. WebSockets and live tracking

WebSocket routes are at the server root (wss://<host>/ws/...), not under /api/v1. A non-upgrade HTTP request to /ws/* gets 426 Upgrade Required. They are exempt from the global rate limiter.

7.1 WS /ws/bookings/:bookingid/track — live rider position

SECURITY NOTE: THIS ENDPOINT HAS NO AUTHENTICATION. Anyone who knows or guesses a numeric internal booking id can open it and receive the assigned rider's name and live GPS position until pickup. Internal booking ids are sequential integers. See §9.2.

Item Value
URL wss://api.doormile.com/ws/bookings/<bookingid>/track
bookingid The internal numeric booking id, not the DM-… reference. The customer booking object does not contain it. The app can only get it from the push payload field booking_id (§8), or indirectly from the nextCursor value.
Auth None
Client → server Nothing is expected. Messages are read only to detect disconnects.
Server → client frequency One text frame every 2 seconds
Frame {"lat": number, "lon": number, "eta_minutes": number, "status": string, "miler_name": string}
status The operational booking status (for example Pending_Pickup, Miler_Assigned), not the customer stage
lat/lon/eta_minutes 0 when no rider is assigned or no position is known. The ETA assumes 20 km/h + 2 min (the REST booking object uses 18 km/h).
Closes when The status becomes Picked_Up, Converted_To_Consignment or Cancelled (one last frame, then close), the client disconnects, or the booking can no longer be read
Errors {"error": "invalid booking ID"} or {"error": "booking not found"}, then close

Example frame:

{ "lat": 11.0102, "lon": 76.9655, "eta_minutes": 6, "status": "Miler_Assigned", "miler_name": "Ravi" }

The recommended source for the tracking screen is polling GET /customer/bookings/:reference (with ETag). It is authenticated and carries milerDistanceKm/milerEtaMinutes.

Source: internal/ws/tracking.go:20-215, routes/routes.go:542-551.

7.2 WS /ws/bookings/:bookingid/chat — customer ↔ miler chat

Item Value
URL wss://api.doormile.com/ws/bookings/<bookingid>/chat?token=<access_token>&role=customer
Auth WsChatAuth: a JWT in the token query parameter (any valid token of any role). Missing token: 401 {"error": "token query parameter is required"}. Bad token: 401 {"error": "invalid or expired token"}.
Ownership check None. The server does not check that the caller owns the booking or matches role (§9.2).
role customer or miler. Each role slot can be held by one connection. A second connection gets {"error": "customer is already connected to this room"}.
Client → server Send plain text messages. Each message is forwarded to the other participant.
Server → client `{"sender": "customer"
History / persistence None. Messages sent while the other side is not connected are lost. Rooms are held in memory per server process.
Closes when The booking reaches Picked_Up, Converted_To_Consignment or Cancelled (checked every 5 s; NATS chat.room.closed.<id> is published), or both participants leave. It refuses to open for a booking that is already in one of those states.

Source: internal/ws/chat.go:17-271, middlewares/ws_auth.go:10-36.


8. Push notifications

8.1 Registration

  • Register with POST /customer/devices after every sign-in and on every FCM token refresh. Unregister with DELETE /customer/devices/:token, or pass deviceToken to /auth/logout.
  • Pushes are sent through Firebase Cloud Messaging (Admin SDK). If the server has no FIREBASE_SERVICE_ACCOUNT_PATH, every push is silently skipped.

Source: internal/notify/fcm.go:15-80.

8.2 Stage-change pushes (sent to every registered device of the customer)

Sent by cxstage.Notify after the database transaction commits:

Stage Title Body
assigned Miler assigned Your Miler is on the way to collect your packages.
arrived Your Miler has arrived They are at your pickup address now.
picked_up Packages collected Your Miler has collected and weighed your packages.
in_transit In transit Your package is on its way to the destination.
out_for_delivery Out for delivery Arriving today at the delivery address.
delivered Delivered Your package has been handed over.

booked, on_the_way and order_created do not trigger a push.

FCM data payload (all values are strings):

{
  "type": "stage_change",
  "reference": "DM-482913",
  "stage": "out_for_delivery",
  "title": "Out for delivery",
  "body": "Arriving today at the delivery address.",
  "trackingId": "DMX10482913",
  "deepLink": "doormile://track/DMX10482913",
  "booking_id": "5120"
}
  • trackingId is present only for per-order stages whose destination has a tracking number.
  • Otherwise deepLink is doormile://track/<reference> (the booking reference).
  • booking_id is the internal numeric id.

Source: internal/cxstage/stage.go:305-400.

8.3 Legacy pushes (not delivered to doormile_cx devices)

Several older code paths still send to the single appcustomers.device_token column: "Miler Accepted" on accept, "Parcel Picked Up", "Out for Delivery", the delivery push in milerAppController.go, and "Booking Cancelled" from admin cancel. No /customer/* endpoint writes that column, so for doormile_cx users these pushes are only sent if the column was filled by the retired app or the console. As a result:

  • A customer cancel sends no push.
  • An ops cancel usually sends no push to doormile_cx devices.

Source: controllers/milerController.go:640-652, 1595-1620, controllers/milerAppController.go:758-765, 959-961, controllers/adminController.go:2989-2990, 3074-3075.


9. Known limitations, open issues, and changes vs previous docs

9.1 Blockers and interim behaviour the app team must know

  1. Interim PIN auth does not prove the customer owns the phone. POST /auth/set-pin will (a) create an account for any phone number, and (b) set the first PIN on any existing account that has no PIN. That includes every account created through OTP signup or verify, or by the console. Whoever calls it first owns the account. This is an account-takeover and phone-squatting risk until OTP (with a real SMS gateway) replaces it. There is also no PIN reset or change endpoint for customers.
  2. SMS gateway. Without SMS_GATEWAY_URL, phone OTP fails in production (500) and only reaches the log elsewhere. Staging can use CX_STAGING_OTP (§3.5).
  3. Default maxDestinations is 1. Multi-destination bookings stay off until ops adds a customerbookinglimits row. Always read GET /config/booking-limits.
  4. The QA endpoint POST /customer/ops/bookings/:reference/stage must not ship in release builds. It is disabled in production (returns 404).
  5. Public OSM Nominatim is the default geocoder. It has a strict usage policy (about 1 request per second, and an identifying contact is required). With GEOCODER_EMAIL empty by default, production search and reverse geocode may be throttled. Failures degrade quietly: an empty list, or "Selected location".
  6. The rider's real phone number is exposed in miler.phone and deliveryAgent.phone unless MILER_CALL_PROXY is configured. The code marks this as "a setting to close before launch".

9.2 Other limitations and security concerns

  • The production ENV value is unverified. The staging OTP bypass and the QA stage override are only blocked when ENV=production. An older doc reports that api.doormile.com was not running with ENV=production. If that is still true and CX_STAGING_OTP is set there, anyone could sign in to any account with the fixed code. Ops should confirm this.
  • Unauthenticated WebSocket tracking (/ws/bookings/:bookingid/track). It streams a rider's name and live location for any booking id. The ids are sequential, so they can be enumerated. The endpoint is not rate-limited.
  • Chat WebSocket has no ownership check. Any valid JWT (any role, any customer) can join any booking's chat as customer or miler. The token travels in the URL query string, where it can end up in proxy logs.
  • The operating-city check is ineffective for far-away pickups. pincodeForPoint takes the nearest active hub with no distance limit, so a pickup anywhere in India resolves to some operating-city hub and passes. Only 0,0 fails.
  • PIN brute force. There is no per-account lockout on verify-pin. The only limit is 10/min per IP (4-digit PINs are 10,000 combinations).
  • /auth/login discloses whether a phone is registered and the account holder's name to anonymous callers.
  • The shared authThrottle (10/min per IP across all apps' logins and refresh) can block many real users behind one carrier NAT IP. If TRUSTED_PROXIES is not set behind a proxy, the whole fleet shares a single budget.
  • Access tokens cannot be revoked. Logout and blocking take effect only when the token expires (≤ 1 h). Only /auth/me re-checks Blocked. The Deleted customer status is never checked by any auth path.
  • Refresh rotation is strict. Two parallel refreshes sign the user out everywhere (§3.8).
  • Profile email is not validated, not lowercased and not unique. Email-OTP sign-in looks up the first account with that email, so two accounts can share an address and the sign-in result is unclear.
  • codAmount (money) and recipientPhone are not validated on booking create or PATCH.
  • /fare/estimate has no limit on the number of destinations and runs a pricing query for each one. It is also not Redis-cached, although a code comment says it is (matchedPricingRules calls loadFromPostgres). /places/search has no limit on the length of q.
  • A rider rejecting before accepting (RejectMilerAssignment) does not move the stage back. The customer can see assigned with miler: null until the booking is assigned again. A pre-pickup skip (MilerSkipPickup) also leaves the stage unchanged.
  • Failed delivery, RTO, missing and damaged are invisible to the customer. There is no stage key for them, so the order keeps showing its last stage (for example out_for_delivery).
  • Slot handling: the lead time is not checked again at create; the capacity check is not atomic; slot templates of other cities are not rejected at create.
  • Chat is in memory per process. With several backend replicas, the customer and the miler may land on different pods and never see each other.
  • The Replacedbyid refresh-chain column is never written, although the code comment says the chain is recorded.
  • No latency measurements exist. The code does not include integration tests against real Postgres, Redis or NATS for these endpoints (per CLAUDE.md §8.5). Treat the behaviour as verified by unit tests and reading the code only.
  • Hard-coded fallback secrets in config/config.go (backend concern, not an app concern). If JWT_SECRET_KEY (and the database and NATS credentials) are not set in the deployment environment, built-in default values from the source code are used. If the JWT default were ever used in production, anyone who can read the source could forge customer tokens. The deployment should be checked to confirm these variables are set.

9.3 Changes vs previous docs

The older documents were compared against the code at 44ba33e. The code wins in every case below. The main points for the app team:

Missing from all earlier docs

  • The interim PIN routes POST /customer/auth/login, /auth/set-pin and /auth/verify-pin (commit f1dbf7e). The error codes invalid_pin, pin_not_set and pin_already_set are also missing.
  • The route count is 31 customer routes (11 public + 20 authenticated), not 28.
  • The WebSocket routes (/ws/bookings/:bookingid/track, which has no auth, and /ws/bookings/:bookingid/chat).
  • The responses that do not use the customer envelope: middleware 401/403, throttle 429, idempotency 409 IDEMPOTENCY_IN_PROGRESS, and city gate 400 CITY_NOT_SUPPORTED. Several docs say "every /customer/* response puts its payload in data". That is not true for these.
  • The price-tamper behaviour from ba2cd22. A client estimate more than 15% away from the server quote is silently replaced, not rejected and not stored as sent. openapi-customer.yaml says it is "stored verbatim". The handbook says it is "rejected".

docs/customer-app-api-crisp.md (most payloads are wrong; do not use it)

  • The error envelope is shown as {success, error:{code, message}}. The real shape is {success, message, error:{code}}.
  • All error codes are invented upper-case values (INVALID_INPUT, SLOT_EXPIRED, SLOT_CAPACITY_FULL, BOOKING_NOT_CANCELLABLE, UNSERVICEABLE_PINCODE, INTERNAL_ERROR, …). The real codes are the lower-case set in §4.2.
  • OTP verify uses the field otp. The real field is code, and name is also accepted. customer.id is shown as an integer. It is the string cust_<n>.
  • Fare request and response: the doc uses pickup.latitude/longitude, per-package weightKg, and minRupees/maxRupees/breakdown. The code uses pickup.{lat,lng} and packageCount, and returns {min, max, paymentMethod, parcel, routeKm}.
  • Booking request: the doc uses pickup.latitude/longitude/contactName/contactPhone and puts recipient and address fields flat on the destination. The code uses pickup.{title,sub,lat,lng} and nests those fields under details{} (with pin{lat,lng}). Flat fields are silently dropped. estimate and remarks are missing from the doc.
  • Booking response: the doc shows pickup.latitude/longitude, index, and codAmount on destinations. The real object is §5.8.0.
  • The doc says booking-limits returns maxCodAmount. It does not.
  • The doc says pickup-slots supports ETag. It does not (only states, districts and booking detail do).
  • The doc says cancel is allowed "strictly before arrived". It is allowed through arrived. The stage diagram leaves out on_the_way and lists cancelled as a stage. cancelled is a status.

docs/customer-app-integration-handbook.md

  • It names the target app doormile_customer_app, which is retired. The target is doormile_cx. It says 28 routes.
  • unserviceable is listed as HTTP 400. It is 422.
  • forbidden is described as "not your resource". Resources you do not own return 404. 403 means a blocked account or the wrong role.
  • The throttle is described as shared across 4 endpoints. It is shared across all 7 customer auth routes plus the miler, admin and hub logins.
  • The example slot id is "2026-09-16T10:00". The real format is slot_YYYYMMDD_<code>. The example reference is DM2609160042. The real format is DM-######.
  • expectedDelivery is shown as an epoch number. It is a display string ("Mon, 2 Jan", or "").
  • History entries are shown with an actor field. Only {stage, at} is sent.
  • It says the stage walks back to booked when a rider "cancels or skips". Only the rider cancel does that.
  • It says logout revokes "this session". Without refreshToken, logout revokes all sessions.
  • The verify flow leaves out name, 400 invalid_name for a new phone, and 404 not_found for an email with no account.
  • It says staging is "not yet provisioned", but the OpenAPI spec and the testing doc list a staging host. The docs contradict each other.
  • Some file:line citations are out of date (for example sms.go:105 should be :110, and routes.go:105-171 should be 105-178).

docs/openapi-customer.yaml

  • The PIN operations are missing. The Error.code enum is missing the 3 PIN codes.
  • Unauthorized, Forbidden and RateLimited are modelled with error.code. The middleware and throttle responses do not have it.
  • remarks is missing from the booking create body. The example maxDestinations: 5 should be 1 (the default when no configuration row exists).
  • It says an "explicit null clears" a destination detail field. It does not. null is treated as "not sent". Send "" to clear a text field, or pin {0,0} to clear the pin.
  • Destination.district points to the full District schema. The booking's district card has only code, name, available, hub?, promise?.
  • details is described as including codAmount. codAmount is never returned.
  • PushPayload.trackingId is described as "null before order_created". The key is left out instead. The payload also has an undocumented booking_id key.
  • DELETE /devices returns data: {registered:false}, not a bare envelope.
  • Missing responses: otp/verify → 400 invalid_name, 404, 429. refresh → 403. Booking create → the past-slot 400, the >25 guard, CITY_NOT_SUPPORTED, IDEMPOTENCY_IN_PROGRESS. PATCH → 409 for a cancelled booking, 404 for a bad index. Ops → 400 "Unknown stage".
  • The pickup title has maxLength: 32, but the server does not enforce it.

docs/customer-app-api.md and docs/customer-api-testing.md

  • They say PIN auth was deleted. It was re-added as an interim flow.
  • They count 28 routes / 24 paths and "9 error codes". The real numbers are 31 routes and 13 codes.
  • They say null clears a PATCH field. It does not.
  • WebSockets are not covered.
  • Some line citations are out of date (for example the AutoMigrate call is at main.go:105, not :97).

docs/CHANGELOG.md (the customer entries name many endpoints that do not exist)

CHANGELOG says Code has
/customer/auth/send-otp, /verify-otp /auth/otp/request, /auth/otp/verify (and /auth/signup)
/customer/auth/logout-all No such route. Logout without refreshToken signs out everywhere.
/customer/catalogue, /customer/serviceability/limits, /serviceability/slots, a parcel category catalogue /config/booking-limits, /pickup-slots. There is no catalogue or category route.
/customer/places/autocomplete /places/search
/customer/devices/register, /deregister POST /devices, DELETE /devices/:token
/ops/bookings/:ref/stage /customer/ops/bookings/:reference/stage
Stages created, at_hub; cancelled as a stage The 9 stages in §6.1. cancelled is a status.
"Strict 5-minute cancellation window" Stage-based: allowed through arrived
"Weight-tiered, peak-hour pricing" No peak-hour logic (§5.7.1)
"Sqids/hash" identifiers A keyed Feistel permutation over Postgres sequences (§1.4)

Sensitive content found in the older docs (the values are not repeated here; the owners should remove them):

  • The fixed staging OTP value is written out in customer-app-api.md, customer-app-api-crisp.md, customer-app-integration-handbook.md and customer-api-testing.md.
  • Seeded test-account phone numbers, and realistic-looking recipient phone numbers, appear in customer-api-testing.md and customer-app-api-crisp.md.
  • A test customer's phone number and PIN also appear in the git history (the commit message of 29e189b, and scratch/seed_pin_customer.go).