102 KiB
Doormile Customer App (doormile_cx) — API Reference
| Document version | 1.0 |
| Date | 2026-09-28 |
| Generated from code at commit | 44ba33e (working tree clean at time of writing) |
| Audience | doormile_cx mobile app developers |
| Source of truth | The Go code in this repository. Where this document and older docs disagree, this document follows the code. Differences are listed in §9.3. |
Every endpoint section ends with a Source: line (file:line) so reviewers can check it against the code.
Contents
- Overview
- Quick reference: all endpoints
- Authentication
- Conventions
- Endpoint reference
- Booking lifecycle
- WebSockets and live tracking
- Push notifications
- Known limitations, open issues, and changes vs previous docs
1. Overview
1.1 Base URL and environments
| Environment | Base URL | Notes |
|---|---|---|
| Production | https://api.doormile.com/api/v1 |
When the server runs with ENV=production, the staging OTP and the QA stage override are switched off (§3.5, §5.10). Check the environment: docs/customer-app-integration-handbook.md reports that api.doormile.com was not running with ENV=production when it was written. The code cannot confirm or deny this. |
| Staging / development | Depends on the deployment (not determinable from code). The path prefix is still /api/v1. |
Staging OTP and the QA stage override can be switched on here. |
All REST paths in this document are relative to the base URL. For example, POST /customer/bookings means POST https://api.doormile.com/api/v1/customer/bookings.
WebSocket paths are not under /api/v1. They sit at the server root: wss://api.doormile.com/ws/... (§7).
Source: routes/routes.go:35 (app.Group("/api/v1")), routes/routes.go:545-552 (WebSocket routes on app, not api).
1.2 Content type and headers
| Header | Direction | Notes |
|---|---|---|
Content-Type: application/json |
Request | Required on every request that has a body. The handlers use Fiber's BodyParser. A body that cannot be parsed returns 400 invalid ("We could not read that request"). |
Authorization: Bearer <access_token> |
Request | Required on authenticated endpoints (§3.6). |
Idempotency-Key: <unique string> |
Request | Optional. Honoured only on POST /customer/auth/otp/verify and POST /customer/bookings (§4.4). |
X-Request-Id: <string> |
Request and response | Optional on the request. The server echoes it on every response, including errors. If the client does not send one, the server creates a 16-hex-character id. Include it in support reports. Source: middlewares/requestid.go:18-33. |
If-None-Match: "<etag>" |
Request | Optional on the endpoints that send an ETag (§4.6). |
ETag, Cache-Control |
Response | See §4.6. |
Idempotent-Replay: true |
Response | Present when the response is a stored replay (§4.4). |
Retry-After: <seconds> |
Response | Sent with the OTP hourly-cap 429 (§5.1.1). |
1.3 Time and time zone
- Every timestamp in a
/customer/*response is an integer number of milliseconds since the Unix epoch, UTC (for examplecreatedAt,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.EpochMillisbefore 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"), slotwindow("2:00 – 4:00 PM", with an en dash), and bookingexpectedDelivery("Thu, 12 Sep"). Show these as they are. Do not parse them. - Slot ids contain the IST calendar date (
slot_20260928_t1).
Source: utils/epoch.go:1-90.
1.4 Identifier formats
| Identifier | Format | Example | Notes |
|---|---|---|---|
| Booking reference | DM- + 6 digits |
DM-482913 |
This is the reference used in every booking URL. It comes from a Postgres sequence and is then scrambled (keyed Feistel permutation), so consecutive bookings do not get adjacent numbers. After 900,000 bookings it grows to 7 or more digits. Older rows can still carry the legacy DM-BK-… format. Treat it as an opaque string. |
| Tracking number (one per destination / order) | DMX + 8 digits |
DMX10482913 |
Created only when the miler completes the pickup (stage order_created). It is null before that. Legacy rows can carry DM-TRK-…. Treat it as an opaque string. |
| Customer id | cust_ + integer |
cust_1042 |
Returned in the customer object. |
| Saved location id | Decimal integer as a string | "17" |
Used as :id in the /customer/locations/:id URLs. |
| Slot id | slot_<YYYYMMDD>_<templateCode> |
slot_20260928_t2 |
Opaque. Send it back exactly as you received it. |
| Pagination cursor | Decimal integer as a string | "5120" |
This is the internal booking id. Treat it as opaque. |
If the identifier sequences are unavailable (a database or migration problem), the server falls back to a time-and-random number in the same format.
Source: controllers/cxIdentifiers.go:63-95, controllers/cxIdentifierScramble.go:1-60, controllers/cxAuthController.go:137-144.
1.5 Money
- All customer-facing amounts are whole Indian rupees (INR) as integers, not paise:
fare.min,fare.max,amountPaid, and themin/maxof the fare estimate. - The one exception is
details.codAmounton 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. paymentMethodis currently always the display string"UPI · Cash at doorstep". There is no in-app payment API.
Source: controllers/cxFareController.go:140-155, controllers/cxBookingView.go:126-131,185-189.
1.6 Phone number rules
Every phone number the customer types is normalised to E.164 India format before it is stored or compared. normalizePhone accepts:
| Input shape (digits after stripping spaces, dashes, brackets) | Result |
|---|---|
Leading + and 11–15 digits |
+<digits> (any country code is accepted as is) |
| Exactly 10 digits | +91<digits> |
12 digits starting 91 |
+<digits> |
11 digits starting 0 |
+91<last 10> |
| Anything else | Rejected (400 invalid) |
So 9876543210, +91 98765 43210, 919876543210 and 09876543210 all map to +919876543210, which is the same account.
- The server does not check that a 10-digit number is a valid Indian mobile range.
- Email identifiers (OTP flow only) are trimmed and lowercased. The only check is that the address has an
@that is not the first character, text after the@, and a.after the@. details.recipientPhoneon a destination goes through the same normaliser. If it fails normalisation, the raw trimmed string is stored anyway and no error is returned.
Source: controllers/cxAuthController.go:68-111, controllers/cxBookingController.go:795-801.
2. Quick reference: all endpoints
All paths below are under /api/v1 unless marked WS. "Auth" means Authorization: Bearer <access_token> with role 9 (customer).
| # | Method | Path | Auth | Purpose |
|---|---|---|---|---|
| 1 | POST | /customer/auth/otp/request |
No | Send a 4-digit sign-in code to a phone or email |
| 2 | POST | /customer/auth/signup |
No | Create an account (name + phone) and send a code |
| 3 | POST | /customer/auth/otp/verify |
No | Exchange a code for a session (also completes a phone signup) |
| 4 | POST | /customer/auth/refresh |
No (refresh token in body) | Rotate the refresh token and get a new access token |
| 5 | POST | /customer/auth/login |
No | Interim PIN flow: decide the next screen for a phone number |
| 6 | POST | /customer/auth/set-pin |
No | Interim PIN flow: set the first PIN (creates the account if needed) and sign in |
| 7 | POST | /customer/auth/verify-pin |
No | Interim PIN flow: sign in with phone + PIN |
| 8 | GET | /customer/auth/me |
Yes | Get the signed-in customer (restore a session on cold start) |
| 9 | POST | /customer/auth/logout |
Yes | Revoke the session and remove the device push token |
| 10 | GET | /customer/serviceability/states |
No | List destination states |
| 11 | GET | /customer/serviceability/states/:stateCode/districts |
No | List districts in a state |
| 12 | GET | /customer/pickup-slots |
No | List pickup windows for today and tomorrow |
| 13 | GET | /customer/config/booking-limits |
No | Get the package and destination caps for one pickup |
| 14 | GET | /customer/profile |
Yes | Read the profile |
| 15 | PUT | /customer/profile |
Yes | Update name, email and default location |
| 16 | GET | /customer/locations |
Yes | List saved addresses |
| 17 | POST | /customer/locations |
Yes | Save an address (up to 10) |
| 18 | PUT | /customer/locations/:id |
Yes | Update a saved address |
| 19 | DELETE | /customer/locations/:id |
Yes | Delete a saved address (soft delete) |
| 20 | POST | /customer/devices |
Yes | Register a push token |
| 21 | DELETE | /customer/devices/:token |
Yes | Unregister a push token |
| 22 | GET | /customer/places/reverse-geocode |
Yes | Turn coordinates into a two-line address label |
| 23 | GET | /customer/places/search |
Yes | Search places, or list saved and recent places when q is empty |
| 24 | POST | /customer/fare/estimate |
Yes | Get a price range for a pickup |
| 25 | POST | /customer/bookings |
Yes | Create a pickup booking (idempotent) |
| 26 | GET | /customer/bookings |
Yes | List bookings (cursor pagination, status tabs) |
| 27 | GET | /customer/bookings/:reference |
Yes | Get one booking (tracking screen and receipt) |
| 28 | POST | /customer/bookings/:reference/cancel |
Yes | Cancel the whole pickup |
| 29 | PATCH | /customer/bookings/:reference/destinations/:index |
Yes | Fill in or correct one destination's details |
| 30 | GET | /customer/orders/:trackingId |
Yes | Get a booking by an order's tracking number (for push deep links) |
| 31 | POST | /customer/ops/bookings/:reference/stage |
Yes + env flags | QA only. Force a booking to a stage. Refused in production. |
| 32 | WS | /ws/bookings/:bookingid/track |
None | Live rider position before pickup (see the security note in §7.1) |
| 33 | WS | /ws/bookings/:bookingid/chat |
JWT in ?token= |
Customer ↔ miler chat room |
| 34 | GET | /pricing/meta |
No | Pricing dropdown metadata. Not part of the customer contract (§5.12). |
| 35 | POST | /pricing/check |
No | Generic price check. Not part of the customer contract (§5.12). |
The /customer group has 31 routes. With the 2 WebSocket routes and the 2 public pricing routes, this document covers 35 endpoints. The health probes GET /health and GET /ready also exist but are for infrastructure, not the app.
Source: routes/routes.go:105-178, 501-502, 545-552.
3. Authentication
3.1 Summary
There are two sign-in flows. Both end in the same session response (§3.4), and both give a role-9 JWT.
| Flow | Endpoints | Status |
|---|---|---|
| OTP — a 4-digit code sent to a phone (SMS) or an email address | otp/request, signup, otp/verify |
The intended long-term flow. It depends on a working SMS gateway (§3.5). |
| Interim PIN — a 4-digit PIN that the customer sets | login, set-pin, verify-pin |
Added in commit f1dbf7e "until the SMS/OTP gateway is live". It does not prove the customer owns the phone number (§9.1). |
Refresh, logout and me are the same for both flows.
3.2 OTP flow
New customer: POST /auth/signup {name, phone, email?} ─► code sent
POST /auth/otp/verify {identifier: phone, code} ─► session
(or skip signup: /otp/request with the phone, then
/otp/verify with {identifier, code, name} — the account
is created on verify when a name is supplied)
Returning customer: POST /auth/otp/request {identifier} ─► code sent
POST /auth/otp/verify {identifier, code} ─► session
OTP rules (from controllers/cxAuthController.go:38-58, 169-248):
| Rule | Value |
|---|---|
| Code length | 4 digits (codeLength: 4 is returned by otp/request) |
| Code lifetime | 5 minutes |
| Resend cooldown | 30 seconds per identifier. A request during the cooldown returns 200 with sent: false and the seconds left. |
| Codes per identifier | 5 per rolling hour (counter starts on the first request). The 6th returns 429 rate_limited. |
| Wrong attempts | 3 per issued code. On the 3rd wrong attempt the code is deleted and a new one must be requested. |
| Single use | A correct code is deleted as soon as it is used. |
| Account enumeration | otp/request answers the same way whether or not an account exists. |
3.3 Interim PIN flow
POST /auth/login {phone}
├─ registered:false ─► collect name + new PIN ─► POST /auth/set-pin {phone, new_pin, name}
├─ registered:true, pin_set:false ─► collect new PIN ─► POST /auth/set-pin {phone, new_pin}
└─ registered:true, pin_set:true ─► collect PIN ─► POST /auth/verify-pin {phone, pin}
- A PIN is exactly 4 ASCII digits. It is stored as a bcrypt-style hash (
utils.HashPassword). set-pinnever overwrites an existing PIN (409 pin_already_set).- There is no PIN reset or change endpoint for customers.
- There is no per-account lockout for wrong PINs. The only protection is the per-IP
authThrottle(§3.7).
Source: controllers/cxAuthController.go:365-519, routes/routes.go:117-123.
3.4 Session response (all sign-in endpoints and refresh)
otp/verify, set-pin, verify-pin and refresh all return:
{
"success": true,
"data": {
"accessToken": "<access_token>",
"refreshToken": "<refresh_token>",
"expiresIn": 3600,
"customer": {
"id": "cust_1042",
"name": "Priya Raman",
"phone": "+919876543210",
"email": "priya@example.com"
}
},
"message": ""
}
| Field | Type | Notes |
|---|---|---|
accessToken |
string | HS256 JWT. Lifetime 1 hour. Claims: UserID (the customer id), Email (holds the phone number), RoleID = 9, TenantID = 0, ConfigID, exp, iat. Treat it as opaque. |
refreshToken |
string | 64 hex characters (32 random bytes). Lifetime 60 days. Only a SHA-256 hash is stored on the server. |
expiresIn |
integer | Access-token lifetime in seconds. Always 3600. |
customer.id |
string | cust_<appcustomerid> |
customer.name |
string | First and last name joined with a space. Can be "". |
customer.phone |
string | E.164 |
customer.email |
string | Never null. "" when unknown. |
Source: controllers/cxAuthController.go:52-53, 131-144, 707-759, utils/helper.go:93-109.
3.5 Staging OTP and SMS delivery
- SMS goes through
internal/sms. IfSMS_GATEWAY_URLis 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/signupreturn500 server_error. The readiness probeGET /api/v1/readyreportschecks.sms.configured.
- Non-production: the code is written to the server log (masked phone) and the API reports
- Staging bypass: if the environment variable
CX_STAGING_OTPis set andENVis notproduction, every issued code (SMS and email) is that fixed value instead of a random one. The value is deployment configuration and is not written here; ask the backend team for it. The code ignores it in production. - Email codes are sent over SMTP (
internal/mail). SMTP settings come from the environment.
Source: internal/sms/sms.go:32-120, internal/sms/http_sender.go:140-190, controllers/cxAuthController.go:190-214.
3.6 Using the access token
- Send
Authorization: Bearer <access_token>on every authenticated call. The wordBearerand exactly one space are required. - Authenticated
/customer/*routes runAuthMiddlewareand thenRoleCheckMiddleware(9). A miler, console or hub token is refused with403. - These two middlewares do not use the customer envelope. Their failures look like this (no
error.code):
| Condition | Status | Body |
|---|---|---|
No Authorization header |
401 | {"success": false, "message": "authorization header is required"} |
Header not in Bearer <token> form |
401 | {"success": false, "message": "authorization header must be in format: Bearer <token>"} |
| Bad signature or expired token | 401 | {"success": false, "message": "invalid or expired token"} |
| Valid token, but not role 9 | 403 | {"success": false, "message": "insufficient permissions for this resource"} |
The app should treat any 401 as "refresh, then retry once; if that fails, sign in again", whether or not error.code is present.
- The access token is not checked against the database on each request. Blocking a customer, or logging out, does not end an access token that was already issued. It keeps working until it expires (at most 1 hour). Only
GET /auth/mere-checks the customer'sBlockedstatus. - Because the middleware applies to the whole
/customerprefix, 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/loginand/hub/login(oneauthThrottleinstance). - When the limit is reached:
429 {"success": false, "message": "too many attempts, please try again in a minute"}. This body has noerror.code. - The client IP is the socket peer, or
X-Forwarded-Foronly whenTRUSTED_PROXIESis configured on the server. Mobile carriers often put many users behind one IP (CGNAT), so many real customers can share one budget (§9.2). refreshcounts 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
- Call
POST /customer/auth/refreshwith{"refreshToken": "<refresh_token>"}when the access token has expired, or is about to (useexpiresIn), or when a call returns 401. - The server rotates the token. The token you sent is revoked, and a new pair is returned in the session shape (§3.4).
- 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.
- Any refresh failure (
401 unauthorized, "Please sign in again") means the app must go back to the sign-in screen.
Source: controllers/cxAuthController.go:594-648.
3.9 Logout
POST /customer/auth/logout (authenticated). With refreshToken in the body, it revokes only that session. Without it, it revokes every session the customer has. With deviceToken, it also removes that push token. Details are in §5.1.9.
4. Conventions
4.1 Response envelope
Every /customer/* handler uses the helpers in utils/response_cx.go.
Success (single object): CxOK gives HTTP 200. CxCreated gives HTTP 201 with the same body.
{ "success": true, "data": { }, "message": "" }
Success (list): CxList gives HTTP 200.
{ "success": true, "data": [ ], "total": 12, "nextCursor": "5120", "message": "" }
datais always an array. It is nevernull.totalis an integer. ForGET /bookingsit is the total count for the selected tab. For other lists it is the length ofdata.nextCursoris a string, ornullon the last page (and on every list that is not paginated).
Error: CxFail.
{ "success": false, "message": "That pickup window just filled up", "error": { "code": "conflict" } }
messageis customer-safe English. The code comments say it is meant to be shown to the user as is.- Branch on
error.code, not onmessage.
Source: utils/response_cx.go:35-100.
Responses that do NOT use this envelope (the app must handle them too):
| Source | Status | Body shape |
|---|---|---|
| Auth / role middleware (§3.6) | 401 / 403 | {success:false, message} |
authThrottle (§3.7) |
429 | {success:false, message} |
| Global rate limiter (§4.5) | 429 | {success:false, message:"too many requests, please slow down"} |
| Idempotency lock (§4.4) | 409 | {success:false, code:"IDEMPOTENCY_IN_PROGRESS", message} |
CityGateMiddleware (§4.7) |
400 | {error:"We are not yet operating in your city. Stay tuned!", code:"CITY_NOT_SUPPORTED"} |
| Fiber's global error handler (unknown route after auth, panics, framework errors) | 404 / 500 / other | {success:false, message} |
ETag match |
304 | Empty body |
| WebSockets (§7) | — | Their own frame formats |
Source: main.go:60-81, 191-205, middlewares/idempotency.go:505-512, middlewares/city_gate.go:424-427.
4.2 Error codes
The full set is defined in utils/response_cx.go:19-33. Every code below is used by at least one /customer/* handler.
error.code |
HTTP status | Meaning | Returned by (examples) |
|---|---|---|---|
invalid |
400 | The request is malformed or fails validation. message says what to fix. |
Almost every handler |
invalid_name |
400 | The name is missing or has fewer than 2 characters after trimming | signup, otp/verify (new phone), set-pin (new phone), PUT /profile |
invalid_otp |
401 | The code is wrong, expired, used up, or was never issued | otp/verify |
invalid_pin |
401 | Wrong PIN, or the phone has no account (the same message for both) | verify-pin |
pin_not_set |
409 | The account exists but has no PIN yet. Send the user to set-pin. | verify-pin |
pin_already_set |
409 | The account already has a PIN. Send the user to verify-pin. | set-pin |
unauthorized |
401 | The session is invalid. Sign in again. | refresh, me |
forbidden |
403 | The account is Blocked |
signup, login, set-pin, verify-pin, otp/verify, refresh, me |
not_found |
404 | The resource does not exist or belongs to another customer. The QA override also uses it when disabled. | Bookings, orders, locations, districts, otp/verify (email with no account), ops/.../stage |
conflict |
409 | A state conflict: slot full, booking no longer cancellable, destination locked after pickup | POST /bookings, cancel, PATCH destination |
unserviceable |
422 | The pickup point is outside operating cities, or a destination district closed | POST /bookings |
rate_limited |
429 | Too many OTP codes for this identifier this hour. A Retry-After header is sent. |
otp/request, signup |
server_error |
500 | Unexpected failure. message is always "Something went wrong". |
Any handler |
4.3 Pagination
Only GET /customer/bookings is paginated. It uses keyset (cursor) pagination, not page numbers.
| Query param | Type | Default | Rules |
|---|---|---|---|
limit |
integer | 20 | Values ≤ 0 or non-numeric fall back to 20. Values above 50 are capped at 50. |
cursor |
string | none | Send back the previous response's nextCursor. A non-numeric value is ignored (you get the first page). |
status |
string | none (all bookings) | active, completed or cancelled (case-insensitive). Any other value means no filter. |
The response has total (count for the whole filtered tab, not the page) and nextCursor (null on the last page). Rows are newest first. The generic pageno/pagesize helper in utils/pagination.go is not used on the customer surface.
Source: controllers/cxBookingController.go:29-31, 443-515.
4.4 Idempotency (Idempotency-Key)
| Item | Behaviour |
|---|---|
| Endpoints | POST /customer/auth/otp/verify and POST /customer/bookings only. The header is ignored everywhere else. |
| Header | Idempotency-Key: <any stable unique string>. A UUID made when the user taps the button is recommended. Reuse the same key for retries of the same action only. |
| Optional | If the header is missing, or Redis is unavailable, the request runs normally with no protection. |
| Scope | On an authenticated request the key is scoped to the customer id. On otp/verify (no token), it is scoped to a hash of the request path plus the exact request body, so a retry must send a byte-identical body to be recognised. |
| What is stored | Only 2xx responses (status and body). 4xx and 5xx are never stored, so fixing a typo and retrying with the same key works. |
| TTL | 24 hours |
| Replay | Same status code and the same body as the first success, plus the response header Idempotent-Replay: true. The handler does not run again, so no second booking or session is created. |
| Concurrent duplicate | While the first request is still running (lock up to 30 s), a second request with the same key gets 409 {"success": false, "code": "IDEMPOTENCY_IN_PROGRESS", "message": "an identical request is still being processed"}. This is not the customer envelope. Wait and retry with the same key. |
Warning for otp/verify: a replay returns the original tokens. If the app has since refreshed, that refresh token is already revoked, and sending it again triggers the "revoke all sessions" rule (§3.8). Only replay otp/verify to recover from a response that never arrived.
Source: middlewares/idempotency.go:15-135, routes/routes.go:114, 164.
4.5 Rate limits
| Limiter | Scope | Limit | Response on limit |
|---|---|---|---|
| Global | Every route except /health, /ready, /ws/* |
300 requests / minute / IP | 429 {success:false, message:"too many requests, please slow down"} |
authThrottle |
The 7 public customer auth endpoints (shared with miler, admin and hub logins) | 10 requests / minute / IP, one shared budget | 429 {success:false, message:"too many attempts, please try again in a minute"} |
| OTP issue cap | Per identifier (phone or email) | 5 codes / hour, 30 s cooldown | 429 rate_limited with Retry-After, or 200 sent:false during the cooldown |
| OTP attempts | Per issued code | 3 wrong attempts | The code is destroyed and later attempts return 401 invalid_otp |
Source: main.go:191-205, routes/routes.go:23-32, controllers/cxAuthController.go:38-50.
4.6 Caching and ETags
| Endpoint | Cache-Control | ETag / 304 |
|---|---|---|
GET /serviceability/states |
max-age=300 |
Yes |
GET /serviceability/states/:stateCode/districts |
max-age=300 |
Yes |
GET /bookings/:reference |
no-cache on 200; max-age=300 on a 304 |
Yes |
GET /pickup-slots |
max-age=30 |
No |
POST /fare/estimate |
max-age=60 |
No |
The ETag is a hash of the data payload. Send it back in If-None-Match to get 304 Not Modified with an empty body when nothing changed. * and weak (W/) forms are accepted. Because a booking's milerDistanceKm and milerEtaMinutes change as the rider moves, the booking ETag changes often while a rider is on the way.
Source: controllers/cxCatalogueController.go:168-191, controllers/cxBookingController.go:531-537, controllers/cxFareController.go:86-88.
4.7 City gate (operating area)
Operating pincode prefixes (middlewares/city_gate.go:397-404):
| Prefix | City |
|---|---|
641 |
Coimbatore |
600 |
Chennai |
560 |
Bengaluru |
500 |
Hyderabad |
629 |
Nagercoil |
How it is enforced for customer bookings:
POST /customer/bookingshasCityGateMiddlewarein its chain. That middleware looks only for a top-levelpickuppincodefield in the body, which the customer request does not have. So normally it does nothing. Do not sendpickuppincode. If you do and it is outside the list, you get400 {"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 returns422 unserviceable"We are not collecting from that area yet". - The nearest-hub lookup has no distance limit (§9.2). In practice the check fails only for
lat=0, lng=0, or when no active hub has coordinates.
Source: middlewares/city_gate.go:406-446, controllers/cxBookingController.go:155-164, controllers/cxFareController.go:232-258.
5. Endpoint reference
Paths are relative to /api/v1. "Envelope" means the customer envelope from §4.1.
5.1 Auth
5.1.1 POST /customer/auth/otp/request
- Auth: No. Middlewares:
authThrottle. - Purpose: Send a 4-digit sign-in code to a phone (SMS) or an email address.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
identifier |
string | Yes | A phone number (normalised per §1.6) or an email address (contains @). |
{ "identifier": "9876543210" }
Success — 200
{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30, "codeLength": 4 }, "message": "" }
During the 30-second cooldown the response is still 200, but with "sent": false and resendAfterSeconds set to the seconds left (plus 1). Show the countdown. It is not an error.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid |
The body cannot be parsed, or the identifier is not a valid phone or email |
| 429 | rate_limited |
More than 5 codes for this identifier in an hour. Retry-After: 3600. |
| 500 | server_error |
Redis is unavailable, or SMS/email sending failed (including production with no SMS gateway) |
| 429 | (no code) | authThrottle |
Rules: The response does not reveal whether an account exists. Codes can be requested for identifiers with no account. An email code can only sign in to an existing account whose stored email matches (§5.1.3).
Source: controllers/cxAuthController.go:252-296 (issue logic 169-217).
5.1.2 POST /customer/auth/signup
- Auth: No. Middlewares:
authThrottle. - Purpose: Create an account and send an SMS code in one call.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | Yes | Whitespace is collapsed. It needs at least 2 characters. The last word becomes the last name, and the rest becomes the first name. |
phone |
string | Yes | Normalised per §1.6 |
email |
string | No | Trimmed and lowercased. The format is not checked. It is saved only when a new account is created. |
{ "name": "Priya Raman", "phone": "+91 98765 43210", "email": "priya@example.com" }
Success — 200
{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30 }, "message": "" }
There is no codeLength here, unlike otp/request. During the cooldown the response is sent: false.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid |
The body cannot be parsed, or the phone is invalid |
| 400 | invalid_name |
The name is missing or too short |
| 403 | forbidden |
The phone belongs to a Blocked account |
| 429 | rate_limited |
OTP hourly cap |
| 500 | server_error |
Database, Redis or send failure |
Rules and side effects
- If the phone already has an account, this is treated as a sign-in. A code is sent, and the new name and email are ignored.
- A new account row is created before the code is verified, with status
Active, no PIN andconfigid1001. An account that never verifies stays in the database. See §9.1 for how this interacts withset-pin. - Next step:
POST /customer/auth/otp/verifywithidentifier= the phone.
Source: controllers/cxAuthController.go:298-363.
5.1.3 POST /customer/auth/otp/verify
- Auth: No. Middlewares:
authThrottle,Idempotency(§4.4). - Purpose: Exchange a code for a session. A phone signup can also be finished here in one step.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
identifier |
string | Yes | The same phone or email the code was sent to |
code |
string | Yes | Trimmed and compared exactly |
name |
string | Conditional | Required when the identifier is a phone with no account yet. It is also used to fill in the name of an existing account that has no first name. It never overwrites an existing name. |
{ "identifier": "9876543210", "code": "<code>", "name": "Priya Raman" }
Success — 200: the session response (§3.4).
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid |
The body cannot be parsed, the identifier is invalid, or the code is empty |
| 401 | invalid_otp |
Wrong, expired, destroyed after 3 misses, or never issued. The attempt counts. |
| 400 | invalid_name |
New phone and no usable name. The code has already been used up, so the user must request a new one. |
| 404 | not_found |
Email identifier with no account whose email matches. The code has been used up. |
| 403 | forbidden |
The account is Blocked |
| 409 | (non-envelope IDEMPOTENCY_IN_PROGRESS) |
The same key is already in flight |
| 500 | server_error |
Could not create the account or the session |
Side effects: Creates the account (phone only) when needed and stamps lastloginat. Each successful verify creates a new refresh-token row.
Source: controllers/cxAuthController.go:521-592.
5.1.4 POST /customer/auth/refresh
- Auth: No (the refresh token is the credential). Middlewares:
authThrottle. - Purpose: Rotate the refresh token and get a new access token.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
refreshToken |
string | Yes | Trimmed |
{ "refreshToken": "<refresh_token>" }
Success — 200: the session response (§3.4) with a new refreshToken.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid |
The body cannot be parsed |
| 401 | unauthorized |
Empty, unknown, expired or revoked token, or the account no longer exists. For a revoked token, all of the customer's sessions are revoked as well. |
| 403 | forbidden |
The account is Blocked |
| 500 | server_error |
Could not revoke the old token or issue the new one |
Source: controllers/cxAuthController.go:594-648.
5.1.5 POST /customer/auth/login (interim PIN)
- Auth: No. Middlewares:
authThrottle. - Purpose: Tell the app which screen comes next for a phone number.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
phone |
string | Yes | Normalised per §1.6 |
{ "phone": "9876543210" }
Success — 200. The keys use snake_case here (pin_set), unlike the rest of the customer surface.
No account:
{ "success": true, "data": { "phone": "+919876543210", "registered": false, "pin_set": false }, "message": "" }
Account exists:
{ "success": true, "data": { "phone": "+919876543210", "registered": true, "pin_set": true, "name": "Priya Raman" }, "message": "" }
name is present only when registered is true.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid |
The body cannot be parsed, or the phone is invalid |
| 403 | forbidden |
The account is Blocked |
Note: This endpoint tells an anonymous caller whether a phone is registered, and returns the account holder's name (§9.2).
Source: controllers/cxAuthController.go:387-415.
5.1.6 POST /customer/auth/set-pin (interim PIN)
- Auth: No. Middlewares:
authThrottle. - Purpose: Set the first PIN, creating the account if the phone is new, and sign in.
Request body (snake_case new_pin)
| Field | Type | Required | Rules |
|---|---|---|---|
phone |
string | Yes | Normalised per §1.6 |
new_pin |
string | Yes | Exactly 4 digits 0-9. Leading and trailing spaces are tolerated when validating, but the untrimmed value is hashed, so send the PIN with no spaces. |
name |
string | Required for a new phone | Same rules as signup. Ignored for an existing account. |
{ "phone": "9876543210", "new_pin": "<4-digit PIN>", "name": "Priya Raman" }
Success — 200: the session response (§3.4).
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid |
The body cannot be parsed, the phone is invalid, or the PIN is not 4 digits |
| 400 | invalid_name |
New phone with a missing or short name |
| 403 | forbidden |
The account is Blocked |
| 409 | pin_already_set |
The account already has a PIN. Go to verify-pin. |
| 500 | server_error |
Hash, create or save failure |
Side effects: A new phone creates an account (Active, configid 1001, no email). An existing account without a PIN gets this PIN. This includes any account made by OTP signup, by otp/verify, or by the console. No proof of phone ownership is required (§9.1).
Source: controllers/cxAuthController.go:417-480.
5.1.7 POST /customer/auth/verify-pin (interim PIN)
- Auth: No. Middlewares:
authThrottle. - Purpose: Sign in with phone and PIN.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
phone |
string | Yes | Normalised per §1.6 |
pin |
string | Yes | Must not be blank. Trimmed and checked against the stored hash. |
{ "phone": "9876543210", "pin": "<4-digit PIN>" }
Success — 200: the session response (§3.4). It also stamps lastloginat.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid |
The body cannot be parsed, the phone is invalid, or the PIN is blank ("Enter your PIN") |
| 401 | invalid_pin |
Unknown phone or wrong PIN: "That phone number or PIN is incorrect" |
| 403 | forbidden |
The account is Blocked |
| 409 | pin_not_set |
The account has no PIN yet. Go to set-pin. |
Source: controllers/cxAuthController.go:482-519.
5.1.8 GET /customer/auth/me
- Auth: Yes. Purpose: Restore a session on cold start. This is the only endpoint that re-checks
Blockedstatus on each call.
Success — 200
{ "success": true, "data": { "id": "cust_1042", "name": "Priya Raman", "phone": "+919876543210", "email": "priya@example.com" }, "message": "" }
Errors: 401 unauthorized (the customer row no longer exists), 403 forbidden (the account is blocked), plus the middleware errors in §3.6.
Source: controllers/cxAuthController.go:689-701.
5.1.9 POST /customer/auth/logout
- Auth: Yes. Purpose: End the session and stop pushes to this device.
Request body (optional; an unreadable body is ignored)
| Field | Type | Required | Rules |
|---|---|---|---|
refreshToken |
string | No | If present, only this session is revoked (and only if it belongs to the caller). If absent, every session of this customer is revoked. |
deviceToken |
string | No | If present, this push token is deleted for this customer. |
{ "refreshToken": "<refresh_token>", "deviceToken": "<fcm_token>" }
Success — 200
{ "success": true, "data": { "signedOut": true }, "message": "" }
Errors: 500 server_error if revoking the given refresh token fails. A failure to delete the device token is only logged.
Notes: The access token is not revoked and works until it expires (≤ 1 h). Discard it on the client. An unknown or foreign refreshToken still returns signedOut: true.
Source: controllers/cxAuthController.go:650-687.
5.2 Catalogue, serviceability and configuration (public)
These four reads need no token, so the booking form can be explored before sign-in.
5.2.1 GET /customer/serviceability/states
- Auth: No. Caching:
ETag,max-age=300. - Purpose: List the states a destination can be in (states with status
Active, sorted bydisplayorderand then name).
Success — 200 (list)
{
"success": true,
"data": [
{ "code": "TN", "name": "Tamil Nadu", "districtCount": 12, "transitTag": "Ultra-fast transit" },
{ "code": "KL", "name": "Kerala", "districtCount": 0, "transitTag": "Opening soon" }
],
"total": 2, "nextCursor": null, "message": ""
}
| Field | Type | Notes |
|---|---|---|
code |
string | State code (up to 8 characters) |
name |
string | |
districtCount |
integer | Counts available districts only. The app hides a state that shows 0. |
transitTag |
string | Display copy, up to 22 characters. Can be "". |
An empty list is a valid answer (no service anywhere).
Errors: 500 server_error.
Source: controllers/cxCatalogueController.go:30-75.
5.2.2 GET /customer/serviceability/states/:stateCode/districts
- Auth: No. Caching:
ETag,max-age=300. - Purpose: List every district in a state, including unavailable ones. Available districts come first.
Path params
| Name | Type | Rules |
|---|---|---|
stateCode |
string | Trimmed and uppercased. It must be an Active state. |
Success — 200 (list)
{
"success": true,
"data": [
{ "code": "TN-CBE", "name": "Coimbatore", "available": true, "hub": "Coimbatore Central", "promise": "Next-day delivery", "lat": 11.0168, "lng": 76.9558 },
{ "code": "TN-NLG", "name": "The Nilgiris", "available": false, "note": "Opening soon" }
],
"total": 2, "nextCursor": null, "message": ""
}
| Field | Type | Present | Notes |
|---|---|---|---|
code |
string | Always | District code (up to 16 characters). Send it as districtCode. |
name |
string | Always | |
available |
boolean | Always | Only available districts can be booked. |
note |
string | Only when not empty | Why it is closed |
hub |
string | Only when a serving hub is set and found | Hub name |
promise |
string | Only when not empty | Delivery promise copy |
lat, lng |
number | Only when the district centre is known (not 0,0) | District centre. It is used for pricing and map placement. |
The example codes above are illustrative. Real codes come from the serviceabledistricts table.
Errors: 400 invalid (empty code), 404 not_found "That state is no longer serviceable", 500 server_error.
Source: controllers/cxCatalogueController.go:77-140.
5.2.3 GET /customer/pickup-slots
- Auth: No. Caching:
max-age=30. - Purpose: List the pickup windows that can be offered at a location, for today and tomorrow (IST).
Query params
| Name | Type | Required | Notes |
|---|---|---|---|
lat |
float | No | Pickup latitude. It picks the city templates (nearest active hub within 60 km) and the capacity zone. |
lng |
float | No | Pickup longitude |
If lat/lng are missing or unparseable they count as 0,0. Then only city-agnostic templates are used, capacity is counted city-wide, and milersNearby is left out.
Success — 200 (list)
{
"success": true,
"data": [
{ "id": "slot_20260928_t2", "day": "Today", "window": "2:00 – 4:00 PM", "available": true, "tag": "Fastest pickup", "milersNearby": 4, "caption": "Most riders free" },
{ "id": "slot_20260928_t3", "day": "Today", "window": "4:00 – 6:00 PM", "available": false, "note": "Fully booked", "milersNearby": 4 },
{ "id": "slot_20260929_t1", "day": "Tomorrow", "window": "10:00 AM – 12:00 PM", "available": true, "milersNearby": 4 }
],
"total": 3, "nextCursor": null, "message": ""
}
| Field | Type | Present | Notes |
|---|---|---|---|
id |
string | Always | Opaque slot id. Send it as slotId. |
day |
string | Always | "Today", "Tomorrow" or "Mon, 8 Sep" (IST) |
window |
string | Always | "2:00 – 4:00 PM". The meridiem is shown once when both ends share it. |
available |
boolean | Always | false when the zone's booked count ≥ the template capacity |
note |
string | When not available | "Fully booked" |
tag |
string | At most one slot, only when available | Promotional label from the template |
caption |
string | Only when available and set | |
milersNearby |
integer | Only when > 0 | Riders reporting a position within 6 km (Redis GEO) |
Rules
- Windows that start within 45 minutes (or earlier) are left out entirely.
- Capacity counts bookings whose preferred pickup start falls inside the window, within 12 km of the given point, excluding
CancelledandConverted_To_Consignmentbookings. If that query fails, the window is shown as open. - This list is advisory. Capacity is checked again when the booking is created (
409 conflict).
Errors: 500 server_error (template query failed).
Source: controllers/cxCatalogueController.go:193-296 (id format 302-304, capacity 390-430).
5.2.4 GET /customer/config/booking-limits
- Auth: No.
- Purpose: Get the caps for one pickup. Fetch it again when the pickup point moves.
Query params: lat, lng (float, optional). They resolve the city through the nearest active hub within 60 km.
Success — 200
{ "success": true, "data": { "maxPackages": 20, "maxDestinations": 1 }, "message": "" }
Rules: The city row in customerbookinglimits is used first, then the global row (applocationid IS NULL), then the built-in defaults: maxPackages 20, maxDestinations 1. The destination default is deliberately 1, so multi-destination stays off until ops adds a configuration row. A configured value of 0 or less is ignored. The same function is the authority at booking create.
Source: controllers/cxCatalogueController.go:494-563.
5.3 Profile
5.3.1 GET /customer/profile
- Auth: Yes. Purpose: Read the profile.
Success — 200: the same shape as /auth/me: {id, name, phone, email}.
Errors: 404 not_found "We could not find your profile".
Source: controllers/customerController.go:40-49.
5.3.2 PUT /customer/profile
- Auth: Yes. Purpose: Update the profile. A field that is left out is not changed.
Request body (camelCase; every field optional)
| Field | Type | Rules |
|---|---|---|
name |
string | Same rules as signup. 400 invalid_name if too short. |
email |
string | Stored exactly as sent. Not validated, not lowercased, and not checked for uniqueness (§9.2). |
defaultLatitude |
number | Stored as is |
defaultLongitude |
number | Stored as is |
defaultPincode |
string | Stored as is |
{ "name": "Priya R", "email": "priya@example.com" }
Success — 200: {id, name, phone, email}. The default location fields are stored but are not returned by any customer endpoint.
Errors: 400 invalid, 400 invalid_name, 404 not_found, 500 server_error.
Note: The phone number cannot be changed.
Source: controllers/customerController.go:51-100.
5.4 Saved locations
The request body for create and update uses the all-lowercase keys of dto.LocationCreateRequest (for example receivername, isdefault). The response uses camelCase (for example recipientName, isDefault). This mismatch is how the code works today.
Saved address object (response)
{
"id": "17",
"label": "Home",
"title": "Home",
"sub": "12 Race Course Rd, Near Park, Coimbatore, 641018",
"recipientName": "Priya Raman",
"recipientPhone": "9876543210",
"lat": 11.0015,
"lng": 76.9711,
"isDefault": true
}
| Field | Type | Notes |
|---|---|---|
id |
string | Location id |
label |
string | Can be "" |
title |
string | label, or address when the label is empty |
sub |
string | The non-empty parts of address, landmark, city and pincode, joined with ", " |
recipientName, recipientPhone |
string | Stored as sent. The phone is not normalised. |
lat, lng |
number | |
isDefault |
boolean |
Source: controllers/customerController.go:242-262, dto/booking.go:5-17.
5.4.1 GET /customer/locations
- Auth: Yes. Purpose: List active saved addresses, default first, then newest.
- Success — 200 (list): saved address objects.
total= count,nextCursor=null. - Errors:
500 server_error.
Source: controllers/customerController.go:102-117.
5.4.2 POST /customer/locations
- Auth: Yes. Purpose: Save an address.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
address |
string | Yes | Must not be empty |
pincode |
string | Yes | Must not be empty (the format is not checked) |
latitude |
number | Yes | Must not be 0 |
longitude |
number | Yes | Must not be 0 |
label |
string | No | For example "Home" or "Work" |
receivername |
string | No | |
receiverphone |
string | No | Not normalised |
landmark |
string | No | |
city |
string | No | |
state |
string | No | |
isdefault |
boolean | No | When true, every other saved address of the customer is set to not-default. |
{
"label": "Home",
"receivername": "Priya Raman",
"receiverphone": "9876543210",
"address": "12 Race Course Rd",
"landmark": "Near Park",
"city": "Coimbatore",
"state": "Tamil Nadu",
"pincode": "641018",
"latitude": 11.0015,
"longitude": 76.9711,
"isdefault": true
}
Success — 201: a saved address object.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid |
The customer already has 10 active addresses ("You can save up to 10 addresses"). This is checked before the body is parsed. |
| 400 | invalid |
The body cannot be parsed, or a required field is missing ("An address needs a street, a pincode and a map location") |
| 500 | server_error |
Insert failed |
Source: controllers/customerController.go:119-165.
5.4.3 PUT /customer/locations/:id
- Auth: Yes. Purpose: Update a saved address that the caller owns.
Path params: id (integer string).
Request body: the same fields as create, none required. The update rules are mixed, so send the full object:
| Field(s) | If the field is left out or empty |
|---|---|
label, address, pincode |
Kept (only overwritten when not empty) |
latitude, longitude |
Kept (only overwritten when not 0) |
receivername, receiverphone, landmark, city, state |
Cleared to "" |
isdefault |
Set to false |
Success — 200: a saved address object.
Errors: 400 invalid (id not numeric, or body cannot be parsed), 404 not_found (not the caller's, or does not exist), 500 server_error.
Note: The update also works on a soft-deleted address. It does not reactivate it.
Source: controllers/customerController.go:167-218.
5.4.4 DELETE /customer/locations/:id
- Auth: Yes. Purpose: Soft-delete a saved address (its status is set to
InActive).
Success — 200
{ "success": true, "data": { "id": "17", "deleted": true }, "message": "" }
Errors: 400 invalid (id not numeric), 404 not_found, 500 server_error. Deleting an address that is already deleted returns 200 again.
Source: controllers/customerController.go:220-240.
5.5 Devices (push registration)
5.5.1 POST /customer/devices
- Auth: Yes. Purpose: Register an FCM push token for the signed-in customer. Call it after sign-in and whenever FCM gives a new token.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
token |
string | Yes | Trimmed and must not be empty. It is unique across all customers. |
platform |
string | No | android or ios (case-insensitive). Any other value is stored as "". |
appVersion |
string | No | Trimmed |
{ "token": "<fcm_token>", "platform": "android", "appVersion": "1.0.3" }
Success — 200
{ "success": true, "data": { "registered": true }, "message": "" }
Errors: 400 invalid (body cannot be parsed, or the token is empty), 500 server_error.
Rules: This is an upsert on the token. If the token was registered to another customer (shared handset), it moves to the caller. A customer can have many devices, and all of them receive pushes.
Source: controllers/cxDeviceController.go:22-67.
5.5.2 DELETE /customer/devices/:token
- Auth: Yes. Purpose: Remove a push token (only if it belongs to the caller).
Path params: token. URL-encode it, because FCM tokens can contain :.
Success — 200
{ "success": true, "data": { "registered": false }, "message": "" }
This is returned even if nothing was deleted.
Errors: 400 invalid (empty token), 500 server_error.
Source: controllers/cxDeviceController.go:69-85.
5.6 Places
Both endpoints proxy a Nominatim-compatible geocoder (GEOCODER_URL, default public OSM Nominatim). The app is never given a maps key. Results are cached in Redis: reverse geocode for 7 days (key rounded to 5 decimals), search for 24 hours.
Place object: { "title": string (≤ 32 characters), "sub": string, "lat": number, "lng": number }. title and sub are never null.
5.6.1 GET /customer/places/reverse-geocode
- Auth: Yes. Purpose: Turn coordinates into a two-line pickup label.
Query params
| Name | Type | Required | Rules |
|---|---|---|---|
lat |
float | Yes | Must parse. lat=0 together with lng=0 is rejected. |
lng |
float | Yes | Must parse |
Success — 200
{ "success": true, "data": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 }, "message": "" }
If the geocoder fails or times out (4 s), the response is still 200, with title: "Selected location" and sub: "<lat>, <lng>" (5 decimals).
Errors: 400 invalid "We need a location to look up".
Source: controllers/cxPlacesController.go:55-91, 153-162, 218-254.
5.6.2 GET /customer/places/search
- Auth: Yes. Purpose: Back the pickup-point search sheet.
Query params
| Name | Type | Required | Notes |
|---|---|---|---|
q |
string | No | Search text. Empty or missing returns the customer's own places (see below). There is no length limit. |
lat, lng |
float | No | Bias the results to about ±0.75° (about 80 km) around this point. The results are not strictly limited to that box. |
Success — 200 (list): up to 6 place objects from the geocoder, limited to India (countrycodes=in).
When q is empty, the list has up to 4 places: the customer's active saved addresses (default first), then the pickup points of their 10 most recent bookings, de-duplicated by coordinates (4 decimals). Entries at 0,0 are skipped.
If the geocoder fails, the response is 200 with an empty list (not an error).
Source: controllers/cxPlacesController.go:93-130, 164-188, 265-321.
5.7 Fare estimate
5.7.1 POST /customer/fare/estimate
- Auth: Yes. Caching:
max-age=60. - Purpose: Get a price range for a whole pickup (one visit, all destinations). The app calls it whenever the route or the package count changes. This is never the final price. The miler weighs the parcels at the door.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
pickup.lat |
number | No | 0 is allowed (then no distance is used) |
pickup.lng |
number | No | |
destinations |
array | Yes | At least 1. There is no upper limit (unlike booking create). |
destinations[].stateCode |
string | No | Not validated here |
destinations[].districtCode |
string | No | Looked up case-insensitively. An unknown district is priced with the fallback formula and zone National. |
destinations[].packageCount |
integer | No | Values < 1 count as 1 |
{
"pickup": { "lat": 11.0015, "lng": 76.9711 },
"destinations": [
{ "stateCode": "TN", "districtCode": "TN-CHN", "packageCount": 2 },
{ "stateCode": "KA", "districtCode": "KA-BLR", "packageCount": 1 }
]
}
Success — 200
{
"success": true,
"data": { "min": 312, "max": 468, "paymentMethod": "UPI · Cash at doorstep", "parcel": "3 boxes (up to 3 kg each)", "routeKm": 356.4 },
"message": ""
}
| Field | Type | Notes |
|---|---|---|
min, max |
integer (INR) | min is rounded down and max is rounded up. max ≥ min. |
paymentMethod |
string | Always "UPI · Cash at doorstep" |
parcel |
string | "Standard box (up to 3 kg)" for 1 package, otherwise "<n> boxes (up to 3 kg each)" |
routeKm |
number | Straight-line distance from the pickup to the farthest district centre, 1 decimal place |
How the price is computed
- Each destination is one leg. The assumed weight is
packageCount × 3 kg. - The zone (
Local,RegionalorNational) comes from the nearest hub's pincode (pickup) and the district'spincodeprefix(resolveZone). - If
doormilepricingrules exist for that zone, service typeNormaland the weight (categoryGeneral, or any category), the leg range is the lowestminpriceto the highestmaxpriceamong the matching rules. - Otherwise the fallback is
base = 50 + 5 × km + 10 × kg, with a range of0.8 × baseto1.2 × base. - Every destination after the first is multiplied by 1.35 (+35% multi-stop uplift).
- The legs are added up.
Errors: 400 invalid (body cannot be parsed, or no destinations). Pricing failures fall back to the formula and never return an error.
Source: controllers/cxFareController.go:27-97, 99-204, 232-266.
5.8 Bookings
5.8.0 The booking object
Every endpoint that returns a booking (create, list rows, detail, patch, order lookup, QA override) uses this one shape, built by renderCxBooking. List rows and the detail response are identical, except that milersInZone is only calculated on single-booking reads.
{
"reference": "DM-482913",
"stage": "on_the_way",
"status": "active",
"cancellable": true,
"createdAt": 1790583000000,
"pickup": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 },
"slotId": "slot_20260928_t2",
"destinations": [
{
"stateCode": "TN",
"stateName": "Tamil Nadu",
"districtCode": "TN-CHN",
"districtName": "Chennai",
"packageCount": 2,
"district": { "code": "TN-CHN", "name": "Chennai", "available": true, "hub": "Chennai Central", "promise": "Next-day delivery" },
"details": {
"street": "4th Cross Street",
"building": "Flat 3B, Lotus Apartments",
"landmark": "Opp. bus depot",
"recipientName": "Arun Kumar",
"recipientPhone": "+919876543210",
"instructions": "Call before arriving",
"pin": { "lat": 13.0418, "lng": 80.2341 }
},
"trackingId": null,
"stage": null,
"verification": null
}
],
"miler": { "name": "Ravi", "vehicle": "TN38XX0001", "phone": "<rider or proxy number>", "rating": 4.8, "trips": 212, "vehicleType": "Bike" },
"deliveryAgent": null,
"milerDistanceKm": 1.4,
"milerEtaMinutes": 6,
"milersInZone": 0,
"routeKm": 498.2,
"expectedDelivery": "",
"fare": { "min": 180, "max": 260, "paymentMethod": "UPI · Cash at doorstep", "parcel": "2 boxes (up to 3 kg each)" },
"amountPaid": null,
"deliveredAt": null,
"cancelReason": null,
"history": [
{ "stage": "booked", "at": 1790583000000 },
{ "stage": "assigned", "at": 1790583300000 },
{ "stage": "on_the_way", "at": 1790583420000 }
]
}
| Field | Type | Nullable | Notes |
|---|---|---|---|
reference |
string | No | DM-###### (§1.4) |
stage |
string | No | One of the 9 stage keys (§6.1). For a booking with no stored stage, it is derived from the operational status (§6.2). |
status |
string | No | active, completed or cancelled. It is forced to cancelled whenever the operational status is Cancelled, even if ops cancelled it. |
cancellable |
boolean | No | status == "active" and stage rank ≤ arrived. Use it to show or hide the button. The server checks again on cancel. |
createdAt |
integer (epoch ms) | No | |
pickup |
object | No | {title, sub, lat, lng}. Always present. Bookings made in the console get a title and sub split from the flat address ("Pickup address" or "Address not recorded" as a last resort). |
slotId |
string | No | Can be "" for bookings not made by the app |
destinations |
array | No | Can be empty for bookings made in the console. See the table below. |
miler |
object | Yes | The rider whose assignment is Assigned, Accepted or Completed. {name, vehicle, phone, rating, trips, vehicleType}. phone is the MILER_CALL_PROXY number if that is configured, otherwise the rider's real mobile number. |
deliveryAgent |
object | Yes | The same shape as miler: the rider who recorded Out_for_Delivery on the first destination that has one |
milerDistanceKm |
number | Yes | Only when stage is on_the_way or arrived and a miler exists. 0 at arrived, or when the rider has no position. Straight-line distance, 1 decimal. |
milerEtaMinutes |
integer | Yes | The same condition. km / 18 km/h × 60 + 2. 0 at arrived. |
milersInZone |
integer | No | Riders within 6 km of the pickup. Calculated only on a single-booking read, at stage booked, with no miler. Otherwise 0. |
routeKm |
number | No | Stored from the quote at create |
expectedDelivery |
string | No | The latest expecteddeliveryat across the destinations, formatted "Mon, 2 Jan" (IST). "" until one is set (at pickup-complete). |
fare |
object | No | {min, max, paymentMethod, parcel} from the estimate stored at create. It stays on the booking permanently. |
amountPaid |
integer (INR) | Yes | The sum of Paid payment rows. Present only from stage picked_up onward and only if a payment exists. |
deliveredAt |
integer (epoch ms) | Yes | Set only when every destination is delivered (the latest delivery time) |
cancelReason |
string | Yes | Set when a cancel reason is stored. A customer cancel with no reason leaves it null. |
history |
array | No | [{stage, at}]. One entry per stage actually reached, with real timestamps. For per-order stages (in_transit onward) the timestamp is when the slowest order reached it. Cancel and release audit rows are left out. Order: entries are sorted by occurrence time, then by event id. At pickup-complete, order_created (and possibly in_transit/out_for_delivery) rows are written before the picked_up row, at almost the same moment. Sort by stage rank (§6.1) if strict order matters. |
Destination object
| Field | Type | Nullable | Notes |
|---|---|---|---|
stateCode, stateName, districtCode, districtName |
string | No | Names are copied at booking time |
packageCount |
integer | No | |
district |
object | Yes | Live district card {code, name, available, hub?, promise?}. null if the district row is gone. |
details |
object | No | Only the fields that are filled in: street, building, landmark, recipientName, recipientPhone, instructions, pin{lat,lng}. Can be {}. codAmount is accepted in requests but never returned. |
trackingId |
string | Yes | DMX######## from order_created onward |
stage |
string | Yes | Per-order stage from order_created onward |
verification |
object | Yes | From pickup: {weightKg: number, photos: [signed URL strings, valid 30 min], capturedAt: epoch ms, capturedBy: rider name or ""}. Photos that fail to sign are left out. |
Source: controllers/cxBookingView.go:66-307, 368-508.
5.8.1 POST /customer/bookings
- Auth: Yes. Middlewares (in order): Auth → Role 9 →
CityGateMiddleware→Idempotency. - Purpose: Create a pickup booking: one visit, 1..N destinations. No tracking number exists yet.
- Send
Idempotency-Keyon every create and reuse it for retries (§4.4).
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
pickup |
object | Yes | |
pickup.title |
string | Recommended | Short label from place search. Not validated. |
pickup.sub |
string | Recommended | Full address line. Not validated. |
pickup.lat |
number | Yes | Must resolve to an operating city (§4.7). 0,0 returns 422. |
pickup.lng |
number | Yes | |
slotId |
string | Yes | From GET /pickup-slots |
destinations |
array | Yes | 1 to maxDestinations (configured; default 1). Hard ceiling 25. |
destinations[].stateCode |
string | Yes | Must not be blank. The stored state comes from the district row, not this value. |
destinations[].districtCode |
string | Yes | Case-insensitive. Must exist in serviceabledistricts and be available. |
destinations[].packageCount |
integer | No | < 1 becomes 1. The total across destinations must be ≤ maxPackages (default 20). |
destinations[].details |
object | No | Optional address details (below) |
estimate |
object | No | {min: int, max: int}, the range shown on the Review screen. See the price-tamper rule below. |
remarks |
string | No | Free-text note (for example "Handle with care"). Stored in the booking notes and shown in the admin console. |
details object (every field optional; also the body of the PATCH endpoint):
| Field | Type | Rules |
|---|---|---|
street |
string | Trimmed |
building |
string | Trimmed |
landmark |
string | Trimmed |
recipientName |
string | Trimmed |
recipientPhone |
string | Normalised per §1.6. If that fails, the raw trimmed value is stored. |
instructions |
string | Trimmed |
pin |
object {lat, lng} |
An exact destination pin. {0,0} clears it. |
codAmount |
number | Cash to collect at this door for the customer. Not validated (a negative number is accepted). Never returned. |
{
"pickup": { "title": "12 Race Course Road", "sub": "Race Course, Coimbatore, 641018", "lat": 11.0015, "lng": 76.9711 },
"slotId": "slot_20260928_t2",
"destinations": [
{
"stateCode": "TN",
"districtCode": "TN-CHN",
"packageCount": 2,
"details": {
"building": "Flat 3B, Lotus Apartments",
"street": "4th Cross Street",
"recipientName": "Arun Kumar",
"recipientPhone": "9876543210",
"pin": { "lat": 13.0418, "lng": 80.2341 },
"codAmount": 0
}
}
],
"estimate": { "min": 180, "max": 260 },
"remarks": "Handle with care"
}
Validation order (the first failure wins):
| # | Check | Result on failure |
|---|---|---|
| 1 | The body parses | 400 invalid "We could not read that request" |
| 2 | At least one destination | 400 invalid "Add at least one destination" |
| 3 | slotId is not blank |
400 invalid "Pick a pickup slot" |
| 4 | The slot date is not before today (IST) | 400 invalid "That pickup time has passed — pick a new slot" |
| 5 | At most 25 destinations | 400 invalid "That is more destinations than one pickup can carry" |
| 6 | The pickup point is in an operating city | 422 unserviceable "We are not collecting from that area yet" |
| 7 | At most maxDestinations |
400 invalid "Up to N destinations per pickup" |
| 8 | Every destination has a known district and a non-blank stateCode |
400 invalid "Every destination needs a serviceable state and district" |
| 9 | Every district is available |
422 unserviceable "That district is no longer available" |
| 10 | Total packages ≤ maxPackages |
400 invalid "Up to N packages per pickup" |
| 11 | The slot id resolves to an active template | 400 invalid "Pick a pickup slot" |
| 12 | The slot start is after now (IST) | 400 invalid "That pickup time has passed — pick a new slot" |
| 13 | The slot has capacity in the pickup zone | 409 conflict "That pickup window just filled up" |
Other errors: 500 server_error (any database failure; the whole create is one transaction, so nothing partial is left behind), the non-envelope 409 IDEMPOTENCY_IN_PROGRESS, and the non-envelope 400 CITY_NOT_SUPPORTED (only if you send pickuppincode).
Success — 201: the booking object (§5.8.0), with stage: "booked" and status: "active".
Business rules and side effects
- Price-tamper protection (commit
ba2cd22): The server always calculates its own quote with the same function as/fare/estimate. The clientestimateis kept only ifmin ≥ 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 becomesfare.min/fare.max, and its midpoint becomes the booking'sEstimatedprice(which feeds rider pay and billing). Always send the exactmin/maxfrom the most recent/fare/estimateresponse. - 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
pickupbookingsrow (statusPending_Pickup, sourceCustomer_App, destination 0 copied onto the flat delivery columns), onebookingdestinationsrow per destination (seq0..N-1), onebookingparcelsrow per package (weight 0), onebookingserviceoptionsrow (Normal, estimated delivery = slot end + 24 h, SLA = slot end + 48 h), and abookedstage event. - After commit: automatic miler assignment starts in the background (
assignment.AssignCustomerMiler), and the NATS eventapi.v1.bookings.createis published (best effort). - There is no per-customer limit on the number of bookings or active bookings.
Source: controllers/cxBookingController.go:48-118 (types and tamper check), 120-440 (handler), 895-922 (event).
5.8.2 GET /customer/bookings
- Auth: Yes. Purpose: List bookings for the Orders tabs and the Home screen's recent list.
Query params: status, limit, cursor (§4.3).
Tab filters:
status |
Rows included |
|---|---|
active |
customerstatus = 'active', or (no customer status and operational status is not Cancelled) |
completed |
customerstatus = 'completed' |
cancelled |
customerstatus = 'cancelled' or operational status Cancelled |
| any other value, or missing | All bookings of the customer |
Success — 200 (list): data is an array of booking objects. There is also total and nextCursor.
Errors: 500 server_error. If only the count fails, total falls back to the page size.
Known edge case: A booking cancelled by ops through a path that sets only the operational status (for example the admin "update status" endpoint) can match both the active and cancelled filters, because its stored customerstatus stays active. It is still rendered with status: "cancelled". Filter on the returned status on the client if needed.
Source: controllers/cxBookingController.go:442-515.
5.8.3 GET /customer/bookings/:reference
- Auth: Yes. Caching:
ETag/If-None-Match→304. - Purpose: The canonical single booking read. It drives the tracking screen and the receipt, and the app polls it while tracking is open. The poll interval is decided by the app. The code comment says "every few seconds".
Path params: reference (for example DM-482913). It is matched exactly and is scoped to the caller.
Success — 200: the booking object. Cache-Control: no-cache.
Errors: 404 not_found "We could not find that pickup" (does not exist, or belongs to someone else).
Source: controllers/cxBookingController.go:517-538.
5.8.4 POST /customer/bookings/:reference/cancel
- Auth: Yes. Purpose: Cancel the whole pickup. Cancelling part of a pickup is not supported.
Request body (optional; an unreadable body is ignored)
| Field | Type | Required | Notes |
|---|---|---|---|
reason |
string | No | Trimmed and stored as cancelReason |
{ "reason": "Changed my mind" }
Success — 200
{ "success": true, "data": { "reference": "DM-482913", "status": "cancelled", "cancelReason": "Changed my mind" }, "message": "" }
If the booking is already cancelled, the response is also 200, with the stored reason (a retry-safe no-op).
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found |
Unknown reference, or not the caller's |
| 409 | conflict |
Stage is picked_up or later: "This pickup can no longer be cancelled" |
| 500 | server_error |
Transaction failure |
Rules and side effects
- Allowed at stages
booked,assigned,on_the_wayandarrived. If the booking has no stored stage, the stage is derived from the operational status. - In one transaction: the booking gets status
Cancelledand customer statuscancelled, a cancel audit event is written, any open (Assigned/Accepted) assignment becomesCancelled("cancelled by customer"), and the assigned rider is set back toAvailable. - After commit: the NATS event
api.v1.bookings.cancelis 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
nullis 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,districtCodeandpackageCountcannot be changed.
{ "landmark": "Opp. bus depot", "recipientPhone": "9876543210", "instructions": "Call before arriving" }
Success — 200: the full updated booking object.
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found |
index is not numeric or is negative, the booking is not found, or no destination exists at that index (bookings from the console have none) |
| 409 | conflict |
Stage is picked_up or later: "Your packages have been collected — these details can no longer be changed" |
| 409 | conflict |
The booking is cancelled: "This pickup was cancelled" |
| 400 | invalid |
The body cannot be parsed (including an empty body) |
| 500 | server_error |
Save failure |
Side effects: For index 0, the booking's flat deliveryaddress (and the delivery lat/lng when a pin is set) is updated as well, so the rider app sees the change straight away. No push or event is sent.
Source: controllers/cxBookingController.go:659-741, 776-816.
5.9 Orders / tracking
5.9.1 GET /customer/orders/:trackingId
- Auth: Yes. Purpose: Open a booking from a push deep link (
doormile://track/<trackingId>).
Path params: trackingId, for example DMX10482913 (matched exactly).
Success — 200: the whole booking object (§5.8.0) that contains this order, not a separate order shape. Find the matching destination by destinations[].trackingId.
Errors: 404 not_found "We could not find that order". This is returned for an unknown tracking number and for one that belongs to another customer.
Note: Push deep links fall back to doormile://track/<bookingReference> when there is no tracking number yet (§8). That value is a booking reference, not a tracking id. Route it to GET /customer/bookings/:reference instead.
Source: controllers/cxBookingController.go:540-568.
5.10 Ops / QA endpoint (must not be used by the shipped app)
5.10.1 POST /customer/ops/bookings/:reference/stage
- Auth: Yes (a normal customer token). Environment gate: works only when
ENVis notproductionandCX_ALLOW_STAGE_OVERRIDE=true. Otherwise it returns404 not_found"Not available". - Purpose: Move one of your own bookings to any stage, so that every tracking screen can be checked in QA. Remove any client code that calls this from release builds.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
stage |
string | Yes | One of the 9 stage keys (§6.1) |
reason |
string | No | Default "forced on staging for QA". Stored on every audit row. |
{ "stage": "out_for_delivery", "reason": "design QA" }
Success — 200: the updated booking object.
Errors: 404 not_found (disabled, or booking not found), 400 invalid (body cannot be parsed, or unknown stage), 500 server_error.
Behaviour
- Writes a stage event for every stage from
bookedup 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_createdor 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. Somiler,amountPaidandverificationstaynull. - 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-levelstageis the slowest destination's stage. A destination with no stage yet counts asorder_created. status:activeuntil every destination isdelivered(thencompleted).cancelledis terminal. A cancelled booking keeps the stage it had.- Stages only move forward. The exception is a rider release: when the rider cancels their assignment, the booking goes back to
booked(the history keeps the earlier entries).
Source: constants/constants.go:206-275, internal/cxstage/stage.go:62-259.
6.2 What moves each stage
| Stage | Written by (backend event) | Push to customer? |
|---|---|---|
booked |
POST /customer/bookings |
No |
assigned |
A rider is assigned: automatically (internal/assignment/crm_assignment.go:257) or by the console/hub (controllers/booking_assignment_service.go:80) |
Yes |
on_the_way |
The rider accepts: POST /miler/assignments/:id/accept (controllers/milerController.go:612) |
No (see §8) |
arrived |
The rider taps reached: POST /miler/bookings/:id/reached (controllers/milerController.go:869) |
Yes |
picked_up, order_created |
POST /miler/bookings/:id/pickup-complete (controllers/milerController.go:1435-1490). It also writes in_transit or out_for_delivery straight away when the new consignment's status already implies it (hub-routed parcels are Inwarded_at_Hub; hyperlocal parcels go straight to Out_for_Delivery, while the hub-handover feature flag is off). |
Yes (picked_up only) |
in_transit |
Base handover: POST /miler/consignments/:id/inward-at-hub (controllers/logisticsHandoverController.go:541) |
Yes |
out_for_delivery |
POST /miler/consignments/:id/start-delivery (controllers/milerAppController.go:733) |
Yes |
delivered |
POST /miler/consignments/:id/deliver (controllers/milerAppController.go:928) |
Yes |
back to booked |
The rider cancels: POST /miler/bookings/:id/cancel (controllers/milerController.go:793) |
No |
| cancelled | Customer cancel; admin cancel and bulk cancel (controllers/adminController.go:2964, 3052) |
Customer: no. Admin: only through the legacy device-token column (§8). |
6.3 Mapping from backend status (bookings with no stored stage)
Bookings made in the console, and rows from before this surface existed, have no customerstage. Their stage is derived from pickupbookings.status:
| Operational booking status | Customer stage |
|---|---|
Cancelled |
booked (and status = cancelled) |
Picked_Up |
picked_up |
Converted_To_Consignment |
order_created |
Miler_Assigned, Pickup_Scheduled |
arrived if the arrival time is recorded, else assigned |
Anything else (Pending_Pickup, Created, Arrived_At_Pickup, …) |
booked |
Consignment status to per-order stage (used when consignment events are recorded):
| Consignment status | Per-order stage |
|---|---|
Inwarded_at_Hub, Tripsheet_Loaded, In_Transit |
in_transit |
Out_for_Delivery |
out_for_delivery |
Delivered |
delivered |
Created, Collected_By_Miler |
none (stays order_created) |
RTO_Initiated, Returned_to_Sender, Missing, Damaged, and failed attempts |
none. The customer keeps seeing the last stage (§9.2). |
Source: controllers/cxBookingView.go:370-402, controllers/cxConsignmentHooks.go:20-41.
6.4 Allowed customer actions by stage
| Action | Allowed when |
|---|---|
| Cancel | status = active and stage rank ≤ 3 (arrived). Already cancelled returns 200 (no-op). |
| PATCH destination details | Stage rank < 4 (before picked_up) and not cancelled |
| Change pickup point, slot, destinations or package counts after creation | Not supported. No endpoint exists. |
7. WebSockets and live tracking
WebSocket routes are at the server root (wss://<host>/ws/...), not under /api/v1. A non-upgrade HTTP request to /ws/* gets 426 Upgrade Required. They are exempt from the global rate limiter.
7.1 WS /ws/bookings/:bookingid/track — live rider position
SECURITY NOTE: THIS ENDPOINT HAS NO AUTHENTICATION. Anyone who knows or guesses a numeric internal booking id can open it and receive the assigned rider's name and live GPS position until pickup. Internal booking ids are sequential integers. See §9.2.
| Item | Value |
|---|---|
| URL | wss://api.doormile.com/ws/bookings/<bookingid>/track |
bookingid |
The internal numeric booking id, not the DM-… reference. The customer booking object does not contain it. The app can only get it from the push payload field booking_id (§8), or indirectly from the nextCursor value. |
| Auth | None |
| Client → server | Nothing is expected. Messages are read only to detect disconnects. |
| Server → client frequency | One text frame every 2 seconds |
| Frame | {"lat": number, "lon": number, "eta_minutes": number, "status": string, "miler_name": string} |
status |
The operational booking status (for example Pending_Pickup, Miler_Assigned), not the customer stage |
lat/lon/eta_minutes |
0 when no rider is assigned or no position is known. The ETA assumes 20 km/h + 2 min (the REST booking object uses 18 km/h). |
| Closes when | The status becomes Picked_Up, Converted_To_Consignment or Cancelled (one last frame, then close), the client disconnects, or the booking can no longer be read |
| Errors | {"error": "invalid booking ID"} or {"error": "booking not found"}, then close |
Example frame:
{ "lat": 11.0102, "lon": 76.9655, "eta_minutes": 6, "status": "Miler_Assigned", "miler_name": "Ravi" }
The recommended source for the tracking screen is polling GET /customer/bookings/:reference (with ETag). It is authenticated and carries milerDistanceKm/milerEtaMinutes.
Source: internal/ws/tracking.go:20-215, routes/routes.go:542-551.
7.2 WS /ws/bookings/:bookingid/chat — customer ↔ miler chat
| Item | Value |
|---|---|
| URL | wss://api.doormile.com/ws/bookings/<bookingid>/chat?token=<access_token>&role=customer |
| Auth | WsChatAuth: a JWT in the token query parameter (any valid token of any role). Missing token: 401 {"error": "token query parameter is required"}. Bad token: 401 {"error": "invalid or expired token"}. |
| Ownership check | None. The server does not check that the caller owns the booking or matches role (§9.2). |
role |
customer or miler. Each role slot can be held by one connection. A second connection gets {"error": "customer is already connected to this room"}. |
| Client → server | Send plain text messages. Each message is forwarded to the other participant. |
| Server → client | `{"sender": "customer" |
| History / persistence | None. Messages sent while the other side is not connected are lost. Rooms are held in memory per server process. |
| Closes when | The booking reaches Picked_Up, Converted_To_Consignment or Cancelled (checked every 5 s; NATS chat.room.closed.<id> is published), or both participants leave. It refuses to open for a booking that is already in one of those states. |
Source: internal/ws/chat.go:17-271, middlewares/ws_auth.go:10-36.
8. Push notifications
8.1 Registration
- Register with
POST /customer/devicesafter every sign-in and on every FCM token refresh. Unregister withDELETE /customer/devices/:token, or passdeviceTokento/auth/logout. - Pushes are sent through Firebase Cloud Messaging (Admin SDK). If the server has no
FIREBASE_SERVICE_ACCOUNT_PATH, every push is silently skipped.
Source: internal/notify/fcm.go:15-80.
8.2 Stage-change pushes (sent to every registered device of the customer)
Sent by cxstage.Notify after the database transaction commits:
| Stage | Title | Body |
|---|---|---|
assigned |
Miler assigned | Your Miler is on the way to collect your packages. |
arrived |
Your Miler has arrived | They are at your pickup address now. |
picked_up |
Packages collected | Your Miler has collected and weighed your packages. |
in_transit |
In transit | Your package is on its way to the destination. |
out_for_delivery |
Out for delivery | Arriving today at the delivery address. |
delivered |
Delivered | Your package has been handed over. |
booked, on_the_way and order_created do not trigger a push.
FCM data payload (all values are strings):
{
"type": "stage_change",
"reference": "DM-482913",
"stage": "out_for_delivery",
"title": "Out for delivery",
"body": "Arriving today at the delivery address.",
"trackingId": "DMX10482913",
"deepLink": "doormile://track/DMX10482913",
"booking_id": "5120"
}
trackingIdis present only for per-order stages whose destination has a tracking number.- Otherwise
deepLinkisdoormile://track/<reference>(the booking reference). booking_idis 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_cxdevices.
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
- Interim PIN auth does not prove the customer owns the phone.
POST /auth/set-pinwill (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. - SMS gateway. Without
SMS_GATEWAY_URL, phone OTP fails in production (500) and only reaches the log elsewhere. Staging can useCX_STAGING_OTP(§3.5). - Default
maxDestinationsis 1. Multi-destination bookings stay off until ops adds acustomerbookinglimitsrow. Always readGET /config/booking-limits. - The QA endpoint
POST /customer/ops/bookings/:reference/stagemust not ship in release builds. It is disabled in production (returns 404). - 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_EMAILempty by default, production search and reverse geocode may be throttled. Failures degrade quietly: an empty list, or "Selected location". - The rider's real phone number is exposed in
miler.phoneanddeliveryAgent.phoneunlessMILER_CALL_PROXYis configured. The code marks this as "a setting to close before launch".
9.2 Other limitations and security concerns
- The production
ENVvalue is unverified. The staging OTP bypass and the QA stage override are only blocked whenENV=production. An older doc reports thatapi.doormile.comwas not running withENV=production. If that is still true andCX_STAGING_OTPis 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
customerormiler. 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.
pincodeForPointtakes the nearest active hub with no distance limit, so a pickup anywhere in India resolves to some operating-city hub and passes. Only0,0fails. - 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/logindiscloses 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 andrefresh) can block many real users behind one carrier NAT IP. IfTRUSTED_PROXIESis 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/mere-checksBlocked. TheDeletedcustomer 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) andrecipientPhoneare not validated on booking create or PATCH./fare/estimatehas 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 (matchedPricingRulescallsloadFromPostgres)./places/searchhas no limit on the length ofq.- A rider rejecting before accepting (
RejectMilerAssignment) does not move the stage back. The customer can seeassignedwithmiler: nulluntil 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
Replacedbyidrefresh-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). IfJWT_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-pinand/auth/verify-pin(commitf1dbf7e). The error codesinvalid_pin,pin_not_setandpin_already_setare 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 400CITY_NOT_SUPPORTED. Several docs say "every/customer/*response puts its payload indata". That is not true for these. - The price-tamper behaviour from
ba2cd22. A clientestimatemore than 15% away from the server quote is silently replaced, not rejected and not stored as sent.openapi-customer.yamlsays 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 iscode, andnameis also accepted.customer.idis shown as an integer. It is the stringcust_<n>. - Fare request and response: the doc uses
pickup.latitude/longitude, per-packageweightKg, andminRupees/maxRupees/breakdown. The code usespickup.{lat,lng}andpackageCount, and returns{min, max, paymentMethod, parcel, routeKm}. - Booking request: the doc uses
pickup.latitude/longitude/contactName/contactPhoneand puts recipient and address fields flat on the destination. The code usespickup.{title,sub,lat,lng}and nests those fields underdetails{}(withpin{lat,lng}). Flat fields are silently dropped.estimateandremarksare missing from the doc. - Booking response: the doc shows
pickup.latitude/longitude,index, andcodAmounton 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 outon_the_wayand listscancelledas a stage.cancelledis a status.
docs/customer-app-integration-handbook.md
- It names the target app
doormile_customer_app, which is retired. The target isdoormile_cx. It says 28 routes. unserviceableis listed as HTTP 400. It is 422.forbiddenis described as "not your resource". Resources you do not own return 404.403means 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 isslot_YYYYMMDD_<code>. The example reference isDM2609160042. The real format isDM-######. expectedDeliveryis shown as an epoch number. It is a display string ("Mon, 2 Jan", or"").- History entries are shown with an
actorfield. Only{stage, at}is sent. - It says the stage walks back to
bookedwhen 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_namefor a new phone, and404 not_foundfor 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:105should be:110, androutes.go:105-171should be105-178).
docs/openapi-customer.yaml
- The PIN operations are missing. The
Error.codeenum is missing the 3 PIN codes. Unauthorized,ForbiddenandRateLimitedare modelled witherror.code. The middleware and throttle responses do not have it.remarksis missing from the booking create body. The examplemaxDestinations: 5should be 1 (the default when no configuration row exists).- It says an "explicit
nullclears" a destination detail field. It does not.nullis treated as "not sent". Send""to clear a text field, orpin {0,0}to clear the pin. Destination.districtpoints to the fullDistrictschema. The booking's district card has onlycode, name, available, hub?, promise?.detailsis described as includingcodAmount.codAmountis never returned.PushPayload.trackingIdis described as "null before order_created". The key is left out instead. The payload also has an undocumentedbooking_idkey.DELETE /devicesreturnsdata: {registered:false}, not a bare envelope.- Missing responses:
otp/verify→ 400invalid_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
titlehasmaxLength: 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
nullclears 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.mdandcustomer-api-testing.md. - Seeded test-account phone numbers, and realistic-looking recipient phone numbers, appear in
customer-api-testing.mdandcustomer-app-api-crisp.md. - A test customer's phone number and PIN also appear in the git history (the commit message of
29e189b, andscratch/seed_pin_customer.go).