1496 lines
58 KiB
YAML
1496 lines
58 KiB
YAML
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"]
|