Files
doormile_backend/docs/openapi-customer.yaml

1496 lines
58 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"]