openapi: 3.1.0 info: title: Doormile — Customer App API version: "1.0.0" description: | The `/customer/*` namespace consumed by the doormile_cx Flutter app. **A customer books a pickup, not a shipment.** One booking has 1..N destinations; each destination becomes its own order, with its own tracking number and its own journey, when the miler completes the pickup. No tracking number exists at booking time — `POST /customer/bookings` returns a reference only. **Weight is never collected from the customer.** The miler weighs and photographs each package at the door, and that is when the price settles. Everything shown before then is an estimate range. **Doormile is the carrier, not the seller.** Any money at the destination door is the customer's own collection, made on their behalf. Every response — auth included — puts its payload in `data`. Timestamps are UTC epoch milliseconds. Money is whole rupees, never paise. Implementation notes, decisions and the answers to the open questions: `docs/customer-app-api.md`. servers: - url: https://api.doormile.com/api/v1 description: Production - url: https://staging-api.doormile.com/api/v1 description: Staging — same contract, seeded to mirror the client mock tags: - name: Auth description: 4-digit code to a phone or an email address. No password anywhere. - name: Catalogue description: Serviceability, pickup slots and limits. Drives the whole booking form. - name: Places description: Geocoding, proxied — the app is never handed a map key. - name: Fare description: Estimate range for one pickup visit. - name: Bookings description: Create, list, track, cancel and complete the details of a pickup. - name: Devices description: Push registration. - name: Account description: Profile and saved addresses. Answers §13.5 of the requirements. - name: Ops description: Staging-only QA support. security: - bearerAuth: [] paths: # ── §4 Auth ──────────────────────────────────────────────────────────────── /customer/auth/otp/request: post: tags: [Auth] summary: Send a sign-in code security: [] description: | Answers identically whether or not the identifier has an account. Telling an anonymous caller "no account found" would turn this endpoint into a directory of who is registered. `sent: false` with a `resendAfterSeconds` is not an error — the caller is inside the 30-second cooldown and the screen already renders a countdown. It does not consume one of the five hourly codes. requestBody: required: true content: application/json: schema: type: object required: [identifier] properties: identifier: type: string description: E.164 phone or email. `+91 98765 43210` with spaces is also accepted and normalised. examples: ["+919876543210", "you@example.com"] responses: "200": description: Code sent, or the caller is inside the cooldown content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: type: object required: [sent, resendAfterSeconds, codeLength] properties: sent: { type: boolean } resendAfterSeconds: type: integer description: Server-driven. The client stops hardcoding 30. examples: [30] codeLength: { type: integer, examples: [4] } "400": { $ref: "#/components/responses/Invalid" } "429": { $ref: "#/components/responses/RateLimited" } /customer/auth/signup: post: tags: [Auth] summary: Create the account and send the code security: [] description: | An existing phone number is **not** an error: it is treated as a sign-in and a code is sent. The app has no "account already exists" screen, and inventing one would strand a returning customer who tapped Sign up out of habit. The account row is created here, but no session exists until the code comes back — an abandoned signup leaves a row and nothing else. requestBody: required: true content: application/json: schema: type: object required: [name, phone] properties: name: { type: string, minLength: 2, examples: ["Joe Oommen"] } phone: { type: string, examples: ["+919876543210"] } email: { type: string, examples: ["joe@example.com"] } responses: "200": description: Account ready and code sent content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: type: object properties: sent: { type: boolean } resendAfterSeconds: { type: integer } "400": description: Validation failed. `invalid_name` when the name is under two characters. content: application/json: schema: { $ref: "#/components/schemas/Error" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } /customer/auth/otp/verify: post: tags: [Auth] summary: Exchange a code for a session security: [] parameters: - $ref: "#/components/parameters/IdempotencyKey" description: | A code is single-use and dies after three wrong attempts. Verifying a phone with no account behind it completes a signup, and `name` is required in that case. requestBody: required: true content: application/json: schema: type: object required: [identifier, code] properties: identifier: { type: string } code: { type: string, examples: ["4821"] } name: type: string description: Required only when this verify is completing a signup. responses: "200": description: Signed in content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Session" } "401": description: "`invalid_otp` — wrong or expired code" content: application/json: schema: { $ref: "#/components/schemas/Error" } "403": { $ref: "#/components/responses/Forbidden" } /customer/auth/refresh: post: tags: [Auth] summary: Rotate the session security: [] description: | Rotation, not reuse: the presented token is revoked and a new pair issued, so a token captured from an old device stops working the moment the real device refreshes. A **revoked** token coming back revokes the customer's whole session chain — a stale client and a stolen one are indistinguishable, and killing the chain costs the honest customer one sign-in and costs an attacker the session. requestBody: required: true content: application/json: schema: type: object required: [refreshToken] properties: refreshToken: { type: string } responses: "200": description: New access/refresh pair content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Session" } "401": { $ref: "#/components/responses/Unauthorized" } /customer/auth/logout: post: tags: [Auth] summary: Sign out description: | Revokes the refresh token and unregisters the device's push token, so a signed-out phone stops receiving another person's parcel updates. With no `refreshToken` supplied it signs out everywhere, rather than leaving a session the customer believes they ended. requestBody: content: application/json: schema: type: object properties: refreshToken: { type: string } deviceToken: { type: string } responses: "200": description: Signed out content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: type: object properties: signedOut: { type: boolean } "401": { $ref: "#/components/responses/Unauthorized" } /customer/auth/me: get: tags: [Auth] summary: The signed-in customer, for cold-start session restore responses: "200": description: The customer content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Customer" } "401": { $ref: "#/components/responses/Unauthorized" } # ── §5 Catalogue ─────────────────────────────────────────────────────────── /customer/serviceability/states: get: tags: [Catalogue] summary: States a destination may be sent to security: [] description: | `districtCount` counts **available** districts only; the client hides any state showing 0. A state with nothing open is still returned, so it can carry its "Opening soon" transit tag. An empty list is a legitimate answer — the app has a designed no-service state for it. parameters: - $ref: "#/components/parameters/IfNoneMatch" responses: "200": description: Serviceable states headers: ETag: { schema: { type: string } } content: application/json: schema: allOf: - $ref: "#/components/schemas/ListEnvelope" - type: object properties: data: type: array items: type: object required: [code, name, districtCount] properties: code: { type: string, examples: ["TN"] } name: { type: string, examples: ["Tamil Nadu"] } districtCount: { type: integer, examples: [6] } transitTag: type: string maxLength: 22 examples: ["Ultra-fast transit"] "304": { description: Unchanged since the caller's ETag } /customer/serviceability/states/{stateCode}/districts: get: tags: [Catalogue] summary: Districts in a state, unavailable ones included security: [] description: | Unavailable districts are returned deliberately. The picker filters them out, but the app shows their names in a quiet "coming soon" line — omitting them would delete real copy from the screen. `note` says why one is closed, so the app never has to invent a reason. parameters: - name: stateCode in: path required: true schema: { type: string } examples: { tamilNadu: { value: "TN" } } - $ref: "#/components/parameters/IfNoneMatch" responses: "200": description: Districts headers: ETag: { schema: { type: string } } content: application/json: schema: allOf: - $ref: "#/components/schemas/ListEnvelope" - type: object properties: data: type: array items: { $ref: "#/components/schemas/District" } "304": { description: Unchanged since the caller's ETag } "404": description: Unknown or retired state content: application/json: schema: { $ref: "#/components/schemas/Error" } /customer/pickup-slots: get: tags: [Catalogue] summary: Pickup windows offered at a location security: [] description: | Capacity- and location-aware. The id resolves to a real pickup window on the booking, which is what the assignment engine consumes — a slot the customer can pick is a slot ops can staff. Windows already past, or starting within 45 minutes, are **not returned at all** rather than returned as unavailable: a greyed-out 8am slot at 6pm is noise. At most one slot carries `tag`, and only when it can actually be booked. The list is advisory and cached 30s; capacity is re-checked at confirm time and a lost race returns `409`. parameters: - { name: lat, in: query, schema: { type: number }, description: Pickup point latitude } - { name: lng, in: query, schema: { type: number }, description: Pickup point longitude } responses: "200": description: Available windows, roughly today and tomorrow content: application/json: schema: allOf: - $ref: "#/components/schemas/ListEnvelope" - type: object properties: data: type: array items: { $ref: "#/components/schemas/PickupSlot" } /customer/config/booking-limits: get: tags: [Catalogue] summary: Caps on a single pickup security: [] description: | Nothing in the UI hardcodes these; they live server-side so ops can vary them by city without an app release. Keyed off the pickup location when one is supplied, so the client can re-fetch when the pickup point moves. Never returns 0 for either — a zero cap would reject every booking on the platform. parameters: - { name: lat, in: query, schema: { type: number } } - { name: lng, in: query, schema: { type: number } } responses: "200": description: Limits content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: type: object required: [maxPackages, maxDestinations] properties: maxPackages: { type: integer, examples: [20] } maxDestinations: { type: integer, examples: [5] } # ── §6 Places ────────────────────────────────────────────────────────────── /customer/places/reverse-geocode: get: tags: [Places] summary: Turn device coordinates into a pickup label description: | Proxied, never keyed — the legacy rider app shipped a Maps key in the binary and it had to be revoked, so the customer app is handed results rather than credentials. On an upstream failure this returns a coordinate-derived label rather than an error: the coordinates are what the rider navigates to, the text is what the customer reads, and they can edit it. A blocked booking form would be worse. parameters: - { name: lat, in: query, required: true, schema: { type: number } } - { name: lng, in: query, required: true, schema: { type: number } } responses: "200": description: The place content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Place" } "400": { $ref: "#/components/responses/Invalid" } /customer/places/search: get: tags: [Places] summary: Search for a pickup point description: | An empty `q` is not an error: the search sheet opens on it and is answered with the customer's own saved and recently used places (at most 4). Results are biased to `lat`/`lng`. parameters: - { name: q, in: query, schema: { type: string } } - { name: lat, in: query, schema: { type: number } } - { name: lng, in: query, schema: { type: number } } responses: "200": description: Matches, or recent places when `q` is empty content: application/json: schema: allOf: - $ref: "#/components/schemas/ListEnvelope" - type: object properties: data: type: array items: { $ref: "#/components/schemas/Place" } # ── §7 Fare ──────────────────────────────────────────────────────────────── /customer/fare/estimate: post: tags: [Fare] summary: Estimate the whole pickup description: | An estimate **range**, never a final price — the miler weighs each package at the door and that is when the price settles. Called on every route and package-count change, so it is cheap and cacheable. The result is the combined price for one visit, including the multi-stop uplift for additional destinations. A failure here must not block a booking; the client swallows it. requestBody: required: true content: application/json: schema: type: object required: [destinations] properties: pickup: type: object properties: lat: { type: number } lng: { type: number } destinations: type: array minItems: 1 items: type: object required: [stateCode, districtCode, packageCount] properties: stateCode: { type: string, examples: ["TN"] } districtCode: { type: string, examples: ["TN-MAA"] } packageCount: { type: integer, minimum: 1 } responses: "200": description: The estimate content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/FareEstimate" } "400": { $ref: "#/components/responses/Invalid" } # ── §9 Bookings ──────────────────────────────────────────────────────────── /customer/bookings: post: tags: [Bookings] summary: Create the pickup parameters: - $ref: "#/components/parameters/IdempotencyKey" description: | Returns a `reference` (`DM-######`) and **no tracking number on any destination** — those are minted per destination when the miler completes the pickup. Only `stateCode`, `districtCode` and `packageCount` are required per destination. Street, building, landmark, recipient and instructions are optional and may be completed later by the customer (`PATCH .../destinations/{index}`) or by the miler at the door. The UI renders a missing one as "Not added — the Miler can confirm this at pickup". `estimate` is what the customer was shown on Review. It is stored verbatim for dispute audit and kept on the booking forever, including after settlement — the receipt renders `amountPaid − fare.min` as the weight adjustment. requestBody: required: true content: application/json: schema: type: object required: [pickup, slotId, destinations] properties: pickup: type: object required: [title, sub, lat, lng] properties: title: { type: string, maxLength: 32, examples: ["12 Nehru Street"] } sub: { type: string, examples: ["Gandhipuram, Coimbatore 641012"] } lat: { type: number } lng: { type: number } slotId: { type: string, examples: ["slot_20260905_t1"] } destinations: type: array minItems: 1 items: type: object required: [stateCode, districtCode, packageCount] properties: stateCode: { type: string } districtCode: { type: string } packageCount: { type: integer, minimum: 1 } details: { $ref: "#/components/schemas/DeliveryDetailsInput" } estimate: type: object properties: min: { type: integer } max: { type: integer } responses: "201": description: | The full booking, with `stage: "booked"`, `status: "active"`, `cancellable: true`, a one-entry history and no `trackingId`. content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Booking" } "400": description: | Validation failed. The `message` is shown verbatim — e.g. "Every destination needs a serviceable state and district", "Pick a pickup slot", "Up to 20 packages per pickup", "Up to 5 destinations per pickup". content: application/json: schema: { $ref: "#/components/schemas/Error" } "409": description: "`conflict` — that pickup window just filled up" content: application/json: schema: { $ref: "#/components/schemas/Error" } "422": description: "`unserviceable` — a district closed, or the pickup point is outside an operating city" content: application/json: schema: { $ref: "#/components/schemas/Error" } get: tags: [Bookings] summary: List the customer's pickups, newest first description: | Backs the Orders tabs, Home's recent list and pull-to-refresh. **Keyset** pagination, not offset: an offset drifts when a new booking lands mid-scroll and shows the customer the same row twice. parameters: - name: status in: query schema: { type: string, enum: [active, completed, cancelled] } - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 50 } } - { name: cursor, in: query, schema: { type: string }, description: "`nextCursor` from the previous page" } responses: "200": description: A page of bookings content: application/json: schema: allOf: - $ref: "#/components/schemas/ListEnvelope" - type: object properties: data: type: array items: { $ref: "#/components/schemas/Booking" } "401": { $ref: "#/components/responses/Unauthorized" } /customer/bookings/{reference}: get: tags: [Bookings] summary: The canonical booking object description: | The tracking screen and the receipt are both rendered from this, and it is polled while tracking is open. Supports `If-None-Match` → `304`, so most of those polls are a header exchange. parameters: - $ref: "#/components/parameters/Reference" - $ref: "#/components/parameters/IfNoneMatch" responses: "200": description: The booking content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Booking" } "304": { description: Unchanged since the caller's ETag } "404": { $ref: "#/components/responses/NotFound" } /customer/bookings/{reference}/cancel: post: tags: [Bookings] summary: Cancel the whole pickup description: | Cancels every destination — there is no partial cancellation in v1. Allowed up to and including `arrived`; from `picked_up` onward returns `409`. `cancellable` on the booking mirrors the same policy so the UI can hide the button, but **the server is the authority and re-checks** — the button state is rendered from a response that may be seconds old. Free of charge in that window; no fee is computed or recorded anywhere. Cancelling a booking that is already cancelled returns `200`, not a conflict: the customer asked for a state it is already in, and a retry over a flaky network must not read as a failure. parameters: - $ref: "#/components/parameters/Reference" requestBody: content: application/json: schema: type: object properties: reason: type: string description: | Optional. Free text, or one of the app's five presets: Booked by mistake · Package not ready · Sending it another day · Changed the destination · Other. responses: "200": description: Cancelled content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: type: object properties: reference: { type: string } status: { type: string, const: cancelled } cancelReason: { type: [string, "null"] } "404": { $ref: "#/components/responses/NotFound" } "409": description: "`conflict` — this pickup can no longer be cancelled" content: application/json: schema: { $ref: "#/components/schemas/Error" } /customer/bookings/{reference}/destinations/{index}: patch: tags: [Bookings] summary: Fill in a destination's details after booking description: | Any subset of fields. An omitted key leaves the stored value alone; an explicit `null` clears it, which is how the customer removes a landmark they no longer want the rider to use. Accepted until the parcels are collected; from `picked_up` onward returns `409`, because after that the shipment's addresses are frozen on the consignment and an edit would change what the customer sees without changing where the parcel goes. The write lands on the same record the miler app reads addresses from, so a correction made while the rider is en route reaches them. parameters: - $ref: "#/components/parameters/Reference" - name: index in: path required: true schema: { type: integer, minimum: 0 } description: 0-based position of the destination within the booking. Stable for its lifetime. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/DeliveryDetailsInput" } responses: "200": description: The updated booking content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Booking" } "404": { $ref: "#/components/responses/NotFound" } "409": description: "`conflict` — the packages have been collected" content: application/json: schema: { $ref: "#/components/schemas/Error" } /customer/orders/{trackingId}: get: tags: [Bookings] summary: One order by tracking number description: | For push deep links (`doormile://track/DMX10482913`). Returns the full booking object focused on that order — the client already parses this shape, and a second shape for the same data is a second parser to keep in step. A tracking number belonging to another customer answers `404`, not `403`: confirming that a tracking number is real tells an enumerating caller something they should not learn. parameters: - name: trackingId in: path required: true schema: { type: string } examples: { order: { value: "DMX10482913" } } responses: "200": description: The booking that owns this order content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Booking" } "404": { $ref: "#/components/responses/NotFound" } # ── §10 Devices ──────────────────────────────────────────────────────────── /customer/devices: post: tags: [Devices] summary: Register a push token description: | One row per device token, not one column per customer — a phone and a tablet both have to receive the delivery notification. A token that already exists is **reassigned** to the calling customer. A shared handset, or one customer signing out and another in, is the only case that matters here, and reassignment is the only outcome that does not send one person's parcel updates to another person's phone. requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: { type: string } platform: { type: string, enum: [android, ios] } appVersion: { type: string, examples: ["1.0.0+12"] } responses: "200": description: Registered content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: type: object properties: registered: { type: boolean } "400": { $ref: "#/components/responses/Invalid" } /customer/devices/{token}: delete: tags: [Devices] summary: Unregister a push token parameters: - { name: token, in: path, required: true, schema: { type: string } } responses: "200": description: Unregistered content: application/json: schema: { $ref: "#/components/schemas/Envelope" } # ── Account (§13.5) ──────────────────────────────────────────────────────── /customer/profile: get: tags: [Account] summary: The signed-in customer's profile responses: "200": description: The customer content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Customer" } "401": { $ref: "#/components/responses/Unauthorized" } put: tags: [Account] summary: Update the profile description: | Every field is optional and nullable-by-omission: an omitted key leaves the stored value alone, an explicit value overwrites it. This matters — the previous version cleared `email` and the last name on any call that did not resend them, so editing a name silently wiped the email. requestBody: required: true content: application/json: schema: type: object properties: name: { type: string, minLength: 2 } email: { type: string } defaultLatitude: { type: number } defaultLongitude: { type: number } defaultPincode: { type: string } responses: "200": description: The updated customer content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Customer" } "400": description: "`invalid_name` when the name is under two characters" content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } /customer/locations: get: tags: [Account] summary: Saved addresses description: | Returned in the same `title`/`sub` shape as a place search result, so an address picked from Saved and one picked from Search are the same object to the client. responses: "200": description: Saved addresses, default first content: application/json: schema: allOf: - $ref: "#/components/schemas/ListEnvelope" - type: object properties: data: type: array items: { $ref: "#/components/schemas/SavedAddress" } "401": { $ref: "#/components/responses/Unauthorized" } post: tags: [Account] summary: Save an address description: Capped at 10 per customer. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SavedAddressInput" } responses: "201": description: Saved content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/SavedAddress" } "400": description: "`invalid` — missing fields, or the 10-address cap is reached" content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } /customer/locations/{id}: put: tags: [Account] summary: Update a saved address parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SavedAddressInput" } responses: "200": description: Updated content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/SavedAddress" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [Account] summary: Remove a saved address description: Soft delete — the row is retained, the address stops being offered. parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "200": description: Removed content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: type: object properties: id: { type: string } deleted: { type: boolean } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } # ── §11 QA ───────────────────────────────────────────────────────────────── /customer/ops/bookings/{reference}/stage: post: tags: [Ops] summary: Force a booking to a stage (staging only) description: | Exists so every tracking state is reachable for design QA and the app's debug stepper can be deleted. Reaching `out_for_delivery` honestly needs a rider to accept, drive, weigh a parcel, hand it to a hub and start a delivery run. **Double-gated**: refused unless `ENV != production` **and** `CX_ALLOW_STAGE_OVERRIDE=true`. Two independent switches, because either being wrong in production would let any customer mark their own parcel delivered. Returns `404` when disabled, so its existence is not advertised. It walks every intermediate stage rather than jumping, and writes through the same recorder every real transition uses — so QA sees the real projection over real event rows, not a special rendering path that could pass while production is broken. parameters: - $ref: "#/components/parameters/Reference" requestBody: required: true content: application/json: schema: type: object required: [stage] properties: stage: { $ref: "#/components/schemas/Stage" } reason: type: string description: Recorded on the audit row, so a forced transition stays distinguishable from a real one forever after. responses: "200": description: The booking at the requested stage content: application/json: schema: allOf: - $ref: "#/components/schemas/Envelope" - type: object properties: data: { $ref: "#/components/schemas/Booking" } "404": { description: Disabled in this environment, or unknown reference } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT parameters: Reference: name: reference in: path required: true schema: { type: string, pattern: "^DM-" } examples: { booking: { value: "DM-482913" } } IdempotencyKey: name: Idempotency-Key in: header required: false schema: { type: string } description: | Any stable unique string the client picks per logical action. The first request runs; a retry with the same key replays the stored response for 24 hours, so a dropped ack on a bad network never becomes a duplicate pickup. IfNoneMatch: name: If-None-Match in: header required: false schema: { type: string } responses: Invalid: description: "`invalid` — validation failed" content: application/json: schema: { $ref: "#/components/schemas/Error" } Unauthorized: description: "`unauthorized` — missing or expired access token" content: application/json: schema: { $ref: "#/components/schemas/Error" } Forbidden: description: "`forbidden` — the token is valid but the resource is not the caller's" content: application/json: schema: { $ref: "#/components/schemas/Error" } NotFound: description: "`not_found`" content: application/json: schema: { $ref: "#/components/schemas/Error" } RateLimited: description: "`rate_limited` — includes `Retry-After`" headers: Retry-After: { schema: { type: integer } } content: application/json: schema: { $ref: "#/components/schemas/Error" } schemas: Envelope: type: object required: [success, data, message] properties: success: { type: boolean, const: true } data: {} message: type: string description: Always present on success, always empty. The field is never omitted. const: "" ListEnvelope: type: object required: [success, data, total, nextCursor] properties: success: { type: boolean, const: true } data: type: array description: Always an array, never null — the client types it as a list. total: { type: integer } nextCursor: type: [string, "null"] description: Null on the last page. message: { type: string } Error: type: object required: [success, message, error] properties: success: { type: boolean, const: false } message: type: string description: | Customer-safe English, shown verbatim in the app's single error state. Never an enum key, a stack trace or an HTML page. error: type: object required: [code] properties: code: type: string enum: [invalid, invalid_name, invalid_otp, unauthorized, forbidden, not_found, conflict, unserviceable, rate_limited, server_error] Customer: type: object required: [id, name, phone, email] properties: id: { type: string, examples: ["cust_10241"] } name: { type: string, examples: ["Joe Oommen"] } phone: { type: string, examples: ["+919876543210"] } email: type: string description: Never null — the client types it as a non-nullable String. Empty when unknown. Session: type: object required: [accessToken, refreshToken, expiresIn, customer] properties: accessToken: { type: string } refreshToken: { type: string } expiresIn: { type: integer, description: Seconds. Access tokens last an hour; refresh tokens 60 days. } customer: { $ref: "#/components/schemas/Customer" } District: type: object required: [code, name, available] properties: code: { type: string, examples: ["TN-CBE"] } name: { type: string, examples: ["Coimbatore"] } available: type: boolean description: False districts are not offered in the picker but are still listed. note: { type: string, examples: ["Opening soon", "Paused this week"] } hub: { type: string, examples: ["Coimbatore Central Hub"] } promise: { type: string, examples: ["Next-day delivery", "2-day delivery"] } lat: type: number description: | The district's centre. Only `stateCode` and `districtCode` are required at booking time, so for most destinations this is the ONLY geography the parcel has until the miler corrects it at the door — it is what places the destination on a map and what the fare estimate is priced against. **Omitted** rather than sent as 0 when unknown: null island is a real coordinate and renders as a pin off the coast of Africa. examples: [13.082680] lng: { type: number, examples: [80.270718] } PickupSlot: type: object required: [id, day, window, available] properties: id: type: string description: Opaque; sent back as `slotId`. Encodes its date, so a stale slot resolves to that date and is rejected rather than silently booking today. examples: ["slot_20260905_t1"] day: { type: string, examples: ["Today", "Tomorrow", "Mon, 8 Sep"] } window: { type: string, description: "En dash, spaced.", examples: ["2:00 – 4:00 PM"] } available: { type: boolean } note: { type: string, examples: ["Fully booked"] } tag: type: string description: At most one slot in the list carries this, and only when it can actually be booked. examples: ["Fastest pickup"] milersNearby: { type: integer, description: Omitted at 0; the client hides the line. } caption: { type: string, examples: ["Arriving in approx. 45 mins"] } Place: type: object required: [title, sub, lat, lng] properties: title: { type: string, maxLength: 32, examples: ["Brookefields Mall"] } sub: { type: string, examples: ["Brookebond Road, Coimbatore 641001"] } lat: { type: number } lng: { type: number } FareEstimate: type: object required: [min, max, paymentMethod, parcel, routeKm] properties: min: { type: integer, description: "Whole rupees. 49 renders as ₹49.", examples: [167] } max: { type: integer, examples: [267] } paymentMethod: { type: string, examples: ["UPI · Cash at doorstep"] } parcel: { type: string, examples: ["3 boxes (up to 3 kg each)"] } routeKm: { type: number, examples: [6.4] } Stage: type: string description: | The nine operational stages. The client rolls these into seven milestones itself and silently falls back to `booked` on an unknown key, so a new value needs a client release. Stages 0–5 belong to the booking. Stages 6–8 belong to each order and may differ between destinations of the same booking. enum: [booked, assigned, on_the_way, arrived, picked_up, order_created, in_transit, out_for_delivery, delivered] DeliveryDetailsInput: type: object description: Every field optional. `null` clears a stored value; an omitted key leaves it alone. properties: street: { type: [string, "null"] } building: { type: [string, "null"] } landmark: { type: [string, "null"] } recipientName: { type: [string, "null"] } recipientPhone: { type: [string, "null"] } instructions: { type: [string, "null"] } pin: type: [object, "null"] properties: lat: { type: number } lng: { type: number } codAmount: type: [number, "null"] description: | Money the miler collects at this door **on the customer's behalf**. Doormile is the carrier, not the seller — this is never Doormile's money. Agent: type: object description: A miler, or the rider making the final delivery. properties: name: { type: string, examples: ["Arun Kumar"] } vehicle: { type: string, examples: ["TN 37 BX 4412"] } phone: type: string description: A masked-calling proxy when `MILER_CALL_PROXY` is configured, otherwise the rider's real number. rating: { type: number, examples: [4.9] } trips: { type: integer, examples: [1240] } vehicleType: { type: string, examples: ["E-Scooter"] } Verification: type: object description: | What the miler recorded at the door. Null until `picked_up` — the weight and photographs cannot exist before someone was standing next to the parcel. required: [weightKg, photos, capturedAt, capturedBy] properties: weightKg: type: number description: The weight the price settled on. Rendered as "2.8 kg". examples: [2.8] photos: type: array description: Signed URLs, 30-minute TTL. One per package. Never permanent CDN links — a parcel photo can show the inside of someone's doorway. items: { type: string, format: uri } capturedAt: { type: integer, description: Epoch milliseconds, UTC. } capturedBy: { type: string, examples: ["Arun Kumar"] } Destination: type: object required: [stateCode, stateName, districtCode, districtName, packageCount] properties: stateCode: { type: string, examples: ["TN"] } stateName: type: string description: Always populated. The client renders "Chennai, Tamil Nadu" from these and never looks a code up. examples: ["Tamil Nadu"] districtCode: { type: string, examples: ["TN-MAA"] } districtName: { type: string, examples: ["Chennai"] } packageCount: { type: integer, examples: [2] } district: oneOf: - { $ref: "#/components/schemas/District" } - { type: "null" } details: type: object description: Only the fields actually filled in. A missing one renders as "Not added — the Miler can confirm this at pickup". trackingId: type: [string, "null"] description: Null until `order_created`. examples: ["DMX10482913"] stage: oneOf: - { $ref: "#/components/schemas/Stage" } - { type: "null" } description: Null until `order_created`; this order's own journey from there on. verification: oneOf: - { $ref: "#/components/schemas/Verification" } - { type: "null" } Booking: type: object description: | The single most important response in this API — the tracking screen and the receipt are both rendered from it. required: [reference, stage, status, cancellable, createdAt, pickup, slotId, destinations, routeKm, fare, history] properties: reference: { type: string, examples: ["DM-482913"] } stage: { $ref: "#/components/schemas/Stage" } status: type: string enum: [active, completed, cancelled] description: Derived, but sent explicitly — a client that has to infer it will eventually infer it differently. cancellable: type: boolean description: Mirrors the server policy so the UI can hide the button. The server re-checks on the cancel call; this is a hint, never the authority. createdAt: { type: integer, description: Epoch milliseconds, UTC. } pickup: type: object description: Present on EVERY booking, cancelled ones included. The client types it non-nullable and throws on null. required: [title, sub, lat, lng] properties: title: { type: string } sub: { type: string } lat: { type: number } lng: { type: number } slotId: type: string description: Present on every booking, as above. destinations: type: array items: { $ref: "#/components/schemas/Destination" } miler: oneOf: - { $ref: "#/components/schemas/Agent" } - { type: "null" } description: Present from `assigned`. deliveryAgent: oneOf: - { $ref: "#/components/schemas/Agent" } - { type: "null" } description: Present from `out_for_delivery`. milerDistanceKm: type: [number, "null"] description: Live, from the rider's current position. Set during `on_the_way` and `arrived` only — after pickup it would describe a journey that already ended. milerEtaMinutes: { type: [integer, "null"] } milersInZone: type: integer description: How many riders are nearby. Shown while still finding a Miler. routeKm: { type: number, examples: [6.4] } expectedDelivery: type: string description: Display string, formatted server-side in IST. The latest promise across the destinations. examples: ["Thu, 12 Sep"] fare: type: object description: | Kept on the booking forever, including after settlement — the receipt renders `amountPaid − fare.min` as the weight adjustment, so losing the original estimate would lose the explanation for the difference. properties: min: { type: integer } max: { type: integer } paymentMethod: { type: string } parcel: { type: string } amountPaid: type: [integer, "null"] description: Settled total in whole rupees. Present from `picked_up`. deliveredAt: type: [integer, "null"] description: When the LAST parcel landed. Null while any is still moving. cancelReason: { type: [string, "null"] } history: type: array description: | Append-only and ordered oldest-first. One entry per stage actually reached, with the real timestamp. **Nothing is synthesised or backfilled** — a booking from before this surface existed has a short history, and a short honest one beats a long invented one. items: type: object required: [stage, at] properties: stage: { $ref: "#/components/schemas/Stage" } at: { type: integer, description: Epoch milliseconds, UTC. } SavedAddress: type: object description: Same two-line shape as a Place, plus the recipient and the default flag. required: [id, title, sub, lat, lng, isDefault] properties: id: { type: string } label: { type: string, examples: ["Home", "Office"] } title: { type: string } sub: { type: string } recipientName: { type: string } recipientPhone: { type: string } lat: { type: number } lng: { type: number } isDefault: { type: boolean } SavedAddressInput: type: object required: [address, pincode, latitude, longitude] properties: label: { type: string } address: { type: string } landmark: { type: string } city: { type: string } state: { type: string } pincode: { type: string } latitude: { type: number } longitude: { type: number } receivername: { type: string } receiverphone: { type: string } isdefault: { type: boolean } PushPayload: type: object description: | FCM/APNs data payload for a customer-visible milestone change. Not an endpoint — documented here because the client parses it. `on_the_way` and `order_created` deliberately do NOT produce a notification; they roll up on the timeline. A customer buzzed for every operational transition stops reading them and misses the one that mattered. properties: type: { type: string, const: stage_change } reference: { type: string, examples: ["DM-482913"] } trackingId: type: [string, "null"] description: Null before `order_created`. stage: { $ref: "#/components/schemas/Stage" } title: { type: string, examples: ["Out for delivery"] } body: { type: string, examples: ["Arriving today at the delivery address."] } deepLink: type: string description: Sent from day one even though the client has no intent filter yet — a notification already in someone's tray should open the right screen once it does. examples: ["doormile://track/DM-482913"]