# 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](#93-changes-vs-previous-docs). | Every endpoint section ends with a **Source:** line (`file:line`) so reviewers can check it against the code. --- ## Contents 1. [Overview](#1-overview) 2. [Quick reference: all endpoints](#2-quick-reference-all-endpoints) 3. [Authentication](#3-authentication) 4. [Conventions](#4-conventions) 5. [Endpoint reference](#5-endpoint-reference) 6. [Booking lifecycle](#6-booking-lifecycle) 7. [WebSockets and live tracking](#7-websockets-and-live-tracking) 8. [Push notifications](#8-push-notifications) 9. [Known limitations, open issues, and changes vs previous docs](#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 ` | Request | Required on authenticated endpoints (§3.6). | | `Idempotency-Key: ` | Request | Optional. Honoured only on `POST /customer/auth/otp/verify` and `POST /customer/bookings` (§4.4). | | `X-Request-Id: ` | 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: ""` | 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: ` | 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__` | `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 | `+` (any country code is accepted as is) | | Exactly 10 digits | `+91` | | 12 digits starting `91` | `+` | | 11 digits starting `0` | `+91` | | 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 ` 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: ```json { "success": true, "data": { "accessToken": "", "refreshToken": "", "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_` | | `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 ` 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 ` form | 401 | `{"success": false, "message": "authorization header must be in format: Bearer "}` | | 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": ""}` 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. ```json { "success": true, "data": { }, "message": "" } ``` **Success (list):** `CxList` gives HTTP 200. ```json { "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`. ```json { "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: `. 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 `@`). | ```json { "identifier": "9876543210" } ``` **Success — 200** ```json { "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. | ```json { "name": "Priya Raman", "phone": "+91 98765 43210", "email": "priya@example.com" } ``` **Success — 200** ```json { "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. | ```json { "identifier": "9876543210", "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 | ```json { "refreshToken": "" } ``` **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 | ```json { "phone": "9876543210" } ``` **Success — 200.** The keys use **snake_case** here (`pin_set`), unlike the rest of the customer surface. No account: ```json { "success": true, "data": { "phone": "+919876543210", "registered": false, "pin_set": false }, "message": "" } ``` Account exists: ```json { "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. | ```json { "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. | ```json { "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** ```json { "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. | ```json { "refreshToken": "", "deviceToken": "" } ``` **Success — 200** ```json { "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)** ```json { "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)** ```json { "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)** ```json { "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** ```json { "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 | ```json { "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)** ```json { "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. | ```json { "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** ```json { "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 | ```json { "token": "", "platform": "android", "appVersion": "1.0.3" } ``` **Success — 200** ```json { "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** ```json { "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** ```json { "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: ", "` (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 | ```json { "pickup": { "lat": 11.0015, "lng": 76.9711 }, "destinations": [ { "stateCode": "TN", "districtCode": "TN-CHN", "packageCount": 2 }, { "stateCode": "KA", "districtCode": "KA-BLR", "packageCount": 1 } ] } ``` **Success — 200** ```json { "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 `" 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. ```json { "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": "", "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. | ```json { "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` | ```json { "reason": "Changed my mind" } ``` **Success — 200** ```json { "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. ```json { "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/`). **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/` 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. | ```json { "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:///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//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: ```json { "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//chat?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"|"miler", "message": string, "timestamp": RFC3339 UTC string}` | | 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.` 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): ```json { "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/` (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_`. - 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_`. 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`).