# Doormile backend → customer app · what changed **For:** the `doormile_cx` app team **From:** Doormile backend **Date:** 15 Sep 2026 **Verified against:** production Postgres + Redis, and the code in this repo --- ## Read this first Two things decide what you can do today: 1. **Send the verification code as `code`, not `otp`.** That works against production right now. The server-side `otp` alias described below is written but **not deployed yet** — do not rely on it until we confirm it has shipped. 2. **Booking creation is currently blocked on our side**, for a reason that has nothing to do with your app. See [Still blocked](#still-blocked-on-our-side). Sign-in will work before booking does. Everything else here is context for why your existing integration was failing. --- ## 1. Sign-in was broken by a field name, and it was our documentation's fault Your app posted the code as `otp`. The server only ever read `code`. So the parsed value was always empty, the "no code supplied" branch always fired, and **every** sign-in returned: ``` 400 {"error":{"code":"invalid"},"message":"Enter the code we sent you"} ``` A correct code failed exactly the same way as a wrong one. No amount of SMS gateway credit would have changed it. **This was our fault, not yours.** Two of our documents disagreed: | Document | Said | Correct? | |---|---|---| | `customer-app-api-crisp.md` | `otp` | ❌ wrong — you built against this | | `openapi-customer.yaml` | `code` | ✅ right | `customer-app-api-crisp.md` has been corrected. ### What to send ```jsonc POST /api/v1/customer/auth/otp/verify { "identifier": "+919876543210", "code": "1234" // ← `code`, always } ``` **Response 200:** ```jsonc { "success": true, "data": { "accessToken": "eyJhbGciOi...", "refreshToken": "d8f1e2a3...", // 64 hex chars "expiresIn": 3600, // seconds "customer": { "id": "cust_294", "name": "...", "phone": "+91...", "email": "" } } } ``` ### About the `otp` alias We are adding server-side acceptance of `otp` as a **deprecated alias**, so builds already on customers' phones start working without an app release. When both keys are present, `code` wins. **It is not deployed yet.** Treat it as a safety net for old installs, not as a reason to keep sending `otp`. Please migrate to `code`. --- ## 2. Three request shapes in our docs did not match the server Every one of these failed **silently** — our parser ignores unknown keys, so a wrong field name produced a zero value, not an error. No 400, no log, just a booking with coordinates of `0` or a missing recipient. If you built any of these from `customer-app-api-crisp.md` before 11 Sep, they need changing. ### 2.1 `POST /customer/auth/otp/verify` | Was documented | Server actually reads | |---|---| | `otp` | `code` | ### 2.2 `POST /customer/fare/estimate` | Was documented | Server actually reads | |---|---| | `pickup.latitude` / `pickup.longitude` | `pickup.lat` / `pickup.lng` | | `pickup.stateCode` / `districtCode` | *not read — pickup has only lat/lng* | | `destinations[].packages[].weightKg` | `destinations[].packageCount` | Per-package weight is not an input. The estimate is priced on package **count**; real weight is not known until the miler weighs it at the door. **Correct request:** ```jsonc { "pickup": { "lat": 13.0827, "lng": 80.2707 }, "destinations": [ { "stateCode": "TN", "districtCode": "CHN", "packageCount": 2 }, { "stateCode": "KA", "districtCode": "BLR", "packageCount": 1 } ] } ``` ### 2.3 `POST /customer/bookings` — the one with the most wrong fields | Was documented | Server actually reads | If you send the old shape | |---|---|---| | `pickup.latitude` / `longitude` | `pickup.lat` / `lng` | pickup coordinates become **0** | | `pickup.contactName` / `contactPhone` | *not read at all* | silently dropped | | destination fields **flat** | nested under `details` | **every** recipient/address field dropped | | destination `latitude` / `longitude` | `details.pin.lat` / `lng` | drop coordinates become **0** | | `estimate` not documented | **is** read and stored | the quote shown to the customer is not recorded | **Correct request:** ```jsonc { "slotId": "slot_20260916_t2", "pickup": { "title": "Home", "sub": "Flat 4B, Green Towers, Anna Nagar", "lat": 13.0827, "lng": 80.2707 }, "destinations": [ { "stateCode": "TN", "districtCode": "CHN", "packageCount": 2, "details": { "street": "MG Road", "building": "12/A", "landmark": "Near Metro", "recipientName": "Priya S", "recipientPhone": "+919840123456", "instructions": "Ring the bell", "pin": { "lat": 13.0850, "lng": 80.2100 }, "codAmount": 450 } } ], "estimate": { "min": 240, "max": 310 }, "remarks": "Handle with care" } ``` **The booking *response* was also documented wrong** — it returns `pickup.lat` / `lng`, not `latitude` / `longitude`. If you parse the response for coordinates, check that too. --- ## 3. `remarks` now actually saves The top-level `remarks` field you were already sending was being dropped — the server's request struct had no field for it, so `BodyParser` discarded it and the booking's note was empty for every customer-app booking. The admin console displays and searches that column, so operators saw nothing. Fixed and **merged to main**. Keep sending it exactly as you are. --- ## 4. Auth flow, confirmed working end to end We created a test customer through the live API and verified the whole sequence. There is no separate "request OTP" step after signup — signup sends the code itself. ``` POST /api/v1/customer/auth/signup { name, phone, email? } → 200 {sent, resendAfterSeconds} POST /api/v1/customer/auth/otp/verify { identifier, code } → 200 {accessToken, refreshToken, ...} ``` For an existing account: ``` POST /api/v1/customer/auth/otp/request { identifier } → 200 POST /api/v1/customer/auth/otp/verify { identifier, code } → 200 ``` Two behaviours worth coding for, both confirmed by testing: - **The code is single-use.** After a successful verify it is deleted. Logging in again requires a fresh `otp/request` first — re-sending the same code returns `401 invalid_otp`. - **There is a 30-second resend cooldown.** Calling `otp/request` inside that window does not issue a new code. Respect `resendAfterSeconds` from the response rather than retrying blindly. ### Refresh ``` POST /api/v1/customer/auth/refresh { refreshToken } ``` Refresh tokens **rotate** — the presented one is revoked and replaced. Replaying an already-used refresh token **revokes every session for that customer**, so never keep an old one around as a fallback. Store only the newest. Access tokens last 1 hour (`expiresIn: 3600`). --- ## 5. Do not offer the Email tab yet ``` POST /api/v1/customer/auth/otp/request {"identifier":"someone@example.com"} → 500 {"error":{"code":"server_error"},"message":"Something went wrong"} ``` SMTP is not configured on production (`SMTP_HOST`, `SMTP_USER`, `SMTP_PASSWORD` are all unset). Email sign-in fails every time. **Please hide or disable the Email option** until we confirm SMTP is live. Offering a path that always fails is worse than not offering it. --- ## Still blocked on our side **You will not be able to create a booking yet, no matter what you send.** Both serviceability tables are empty on production: ``` serviceablestates 0 rows serviceabledistricts 0 rows ``` `CreateCxBooking` validates every destination against that catalogue, so with zero rows every booking is rejected with: ``` 400 {"message":"Every destination needs a serviceable state and district"} ``` And `GET /customer/serviceability/states` returns `200` with an **empty list**, so your state picker has nothing to show in the first place. This is ours to fix — the seed data exists (`seed_customer_app.sql`, 5 states including Tamil Nadu / Kerala / Karnataka / Telangana / Puducherry, 22 districts) and simply has not been applied to production. We will confirm when it has. **Until then:** sign-in and the catalogue endpoints are what you can integrate against. Booking creation will return a 400 that is not your bug. --- ## Summary — what you need to change | # | Change | Priority | |---|---|---| | 1 | Send the verification code as **`code`**, not `otp` | **Required** — nothing works without it | | 2 | Fare estimate: `pickup.lat`/`lng`, `packageCount` (no `packages[].weightKg`) | Required | | 3 | Booking: `pickup.lat`/`lng`, destination details nested under `details`, coords at `details.pin` | Required | | 4 | Parse the booking response's `pickup.lat`/`lng` (not `latitude`/`longitude`) | Required | | 5 | Send `estimate: {min, max}` on booking create | Recommended — it is the dispute record | | 6 | Hide the Email sign-in tab | Recommended | | 7 | Handle single-use codes + the 30s resend cooldown | Recommended | | 8 | Store only the newest refresh token, never replay an old one | Recommended | --- ## Status of the backend changes referenced here | Change | State | |---|---| | `remarks` saved on booking create | **Merged to main** | | Doc corrections (`customer-app-api-crisp.md`) | **In review** | | `otp` accepted as alias for `code` | **In review — not deployed** | | Failed OTP send no longer burns the cooldown / rate limit | **In review** | | Serviceability seed applied to production | **Not done** | | SMTP configured for email OTP | **Not done** | "In review" means written and tested but not yet on `api.doormile.com`. Build against `code` and the corrected shapes — those are correct regardless of deployment order. We will confirm when the alias and the seed are live. Questions → the backend team.