# Doormile Express Console — API reference The console surface only (`/admin/*`). 94 routes: 1 login + 93 authenticated. Everything is under `https://api.doormile.com/api/v1`. Verified against the build deployed 2026-08-06 12:41 IST. Miler-app, hub-console, customer-app and CRM routes are not in this document. --- ## Auth ``` POST /api/v1/admin/login { "email": "developer@doormile.com", "password": "admin@123" } ``` Returns `{ success, token, user: { id, name, email, role, tenantid } }`. Send it on every other call as `Authorization: Bearer `. The token is a JWT carrying `userid`, `email`, `roleid`, `tenantid`, `configid`. Roles allowed on this group: **1 admin, 3 manager, 4 executive**. Anything else gets 401. ### Tenant scoping — read this before wiring any list screen `tenantid` in the token decides what the account can see: | Token `tenantid` | Who | Sees | |---|---|---| | `0` / null | Doormile's own staff | everything, all tenants | | set (e.g. `13`) | a client's console login | only that tenant's rows | Scoping is applied server-side. Build the UI as if the API returns exactly what the account is allowed to see, because it does. **`?tenantid=` — the staff-only filter.** Doormile staff pass it to narrow any of the list/summary endpoints to one client: ``` GET /admin/milers?tenantid=13 → DailyGrubs' 5 riders GET /admin/dashboard?tenantid=13 → DailyGrubs' counters GET /admin/reports?tenantid=13&from=…&to=… ``` Supported on `dashboard`, `reports`, `milers`, `milers/summary`, `customers`, `bookings`, `consignments`. Omit it for the whole network. A **client** login passing another tenant's id gets **403** — not their own data silently relabelled. Passing their own id, or omitting it, works normally. Reading one resource by id that belongs to another tenant returns **404**, not 403, so ids outside your own fleet aren't probeable. This covers bookings, consignments and riders, on reads *and* writes — `POST /admin/bookings/:id/ assign-miler` on someone else's booking is refused before the write, not after. Note the group is registered under a stale `// all open, no token required` comment in `routes.go`; the comment is wrong, `AuthMiddleware` is applied. --- ## Dashboard, profile, reports | Method | Path | Notes | |---|---|---| | GET | `/admin/dashboard` | counts + today's numbers | | GET | `/admin/reports` | `?from=YYYY-MM-DD&to=YYYY-MM-DD`, defaults to today (IST) | | GET | `/admin/profile` | current account | | GET | `/admin/me` | alias of the above | | PUT | `/admin/profile/password` | `{ "current_password": "...", "new_password": "..." }` — snake_case | ## App users (staff logins) | Method | Path | |---|---| | GET | `/admin/users` | | POST | `/admin/users` | | PUT | `/admin/users/:id` | | DELETE | `/admin/users/:id` | ## Partners (fleet / rider suppliers) Not the same thing as a tenant. A partner supplies vehicles and riders; a tenant is a client Doormile delivers for. | Method | Path | Body | |---|---|---| | GET | `/admin/partners` | | | POST | `/admin/partners` | `{ partnername, partnertypeid, contactno, status }` | | GET | `/admin/partners/:id` | | | PUT | `/admin/partners/:id` | | | DELETE | `/admin/partners/:id` | hard delete — no soft-delete column | ## Tenants (client companies) | Method | Path | Body | |---|---|---| | GET | `/admin/tenants` | | | POST | `/admin/tenants` | `{ tenantname, primaryemail, primarycontact, status, requiredeliveryotp }` | | GET | `/admin/tenants/:id` | | | PUT | `/admin/tenants/:id` | `requiredeliveryotp` is a pointer — omit it to leave the setting alone | | DELETE | `/admin/tenants/:id` | hard delete | | GET | `/admin/tenants/:id/locations` | the client's sites (kitchens, branches, depots) | | POST | `/admin/tenants/:id/locations` | `{ locationname, address, city, state, pincode, latitude, longitude, isprimary, status }` | | PUT | `/admin/tenantlocations/:id` | note: **not** nested under the tenant | `requiredeliveryotp` is opt-in per tenant and **off by default**. DailyGrubs runs without delivery OTP by decision. ## Tenant customers (a client's own end customers) | Method | Path | Body | |---|---|---| | GET | `/admin/tenantcustomers` | | | POST | `/admin/tenantcustomers` | `{ firstname, lastname, phone, email }` | | GET | `/admin/tenantcustomers/:id` | | | PUT | `/admin/tenantcustomers/:id` | | | DELETE | `/admin/tenantcustomers/:id` | | ## B2C app customers | Method | Path | |---|---| | GET | `/admin/customers` | | PATCH | `/admin/customers/:id` | Tenant-scoped through their bookings — a client login sees only customers who have ordered through them. ## Hubs | Method | Path | Body | |---|---|---| | GET | `/admin/hubs` | | | POST | `/admin/hubs` | `{ hubname, hubtype, applocationid, contactno, address, latitude, longitude, pincode, status }` | | GET | `/admin/hubs/:id` | | | PUT | `/admin/hubs/:id` | | | DELETE | `/admin/hubs/:id` | soft delete | `hubtype`: `sorting_center` \| `delivery_hub`. `applocationid` is the city — Nagercoil is 5; read the rest from `GET /admin/hubs` rather than hardcoding. ## Vehicles | Method | Path | Body | |---|---|---| | GET | `/admin/vehicles` | | | POST | `/admin/vehicles` | `{ vehicleno, vehicletype, maxweight, maxvolume, partnerid, batterypercentage, status }` | | GET | `/admin/vehicles/:id` | | | PUT | `/admin/vehicles/:id` | | | DELETE | `/admin/vehicles/:id` | soft delete | ## Milers (riders) | Method | Path | Body / notes | |---|---|---| | GET | `/admin/milers` | tenant-scoped; `?applocationid=&hubid=&tenantid=` | | GET | `/admin/milers/summary` | **the roster table** — see below | | POST | `/admin/milers` | see below | | GET | `/admin/milers/:id` | 404 outside your fleet | | GET | `/admin/milers/:id/logs` | GPS trail — see below | | GET | `/admin/milers/:id/activity` | one rider's detail — see below | | PUT | `/admin/milers/:id` | | | PUT | `/admin/milers/:id/block` | | | PUT | `/admin/milers/:id/assign-vehicle` | `{ vehicleid }` | | POST | `/admin/milers/:id/notify` | `{ title, message }` — `:id` is the **milerprofileid** | Every `:id` here is the **milerprofileid**, not the userid. ```jsonc POST /admin/milers { "authname": "Murali", "email": "murali@dailygrubs.com", "contactno": "9876543210", "password": "1234", "displayname": "Murali S", "tenantid": 13, "defaultvehicletype": "Bike", "applocationid": 1, "hubid": 4 // optional; without it the rider is invisible to the hub console // configid defaults to 1001 — the partition the miler app logs in against. // Do not override it. Riders created without it could never log in. } ``` ### The rider screens These are the express-console equivalents of jupiter's `getridersummary` and its rider/delivery logs. **`GET /admin/milers/summary?from=&to=&applocationid=&hubid=&tenantid=`** One row per rider — live state on the left, range totals on the right. Defaults to today. This is the rider list screen. ```jsonc { "milerprofileid": 78, "userid": 41, "displayname": "Murali P", "phone": "…", "availabilitystatus": "Offline", "defaultvehicletype": "Bike", "hubid": null, "hubname": "", "rating": 5, "onduty": false, "dutystartedat": null, "currentlatitude": 11.0163, "currentlongitude": 77.0147, "lastlocationupdatedat": "…", "lastpingat": "2026-08-05T18:10:00Z", "assigned": 0, "accepted": 0, "rejected": 0, "completed": 1, "cancelled": 0, "delivered": 1, "riderkms": 0, "ridercharges": 0 } ``` `lastpingat` comes from Redis telemetry, `lastlocationupdatedat` from the profile — they differ, and the first is the better staleness signal. **`GET /admin/milers/:id/logs?from=&to=&limit=`** The GPS trail. `limit` defaults to 500, caps at 5000. Returns `{ data: [MilerLog…], total, distancekm, miler: {…}, from, to }` where `distancekm` is the haversine sum over consecutive points. Telemetry coords are **strings**, since that's what the rider app sends. **`GET /admin/milers/:id/activity?from=&to=`** One rider's detail page: `assignments[]`, the `bookings[]` behind them, `dutylogs[]`, `breaklogs[]`, `lastpingat`, and `totals: { assignments, delivered, riderkms, ridercharges, dutyminutes }`. ## Bookings | Method | Path | Notes | |---|---|---| | GET | `/admin/bookings` | tenant-scoped list | | POST | `/admin/expressbooking` | create one — passes CityGate | | POST | `/admin/expressbooking/bulk` | `{ "bookings": [ ... ] }`, max 200, per-row results | | GET | `/admin/bookings/:id` | 404 if outside your tenant | | GET | `/admin/bookings/:id/track` | **the tracking screen** — see below | | POST | `/admin/bookings/:id/assign-miler` | | | POST | `/admin/bookings/:id/assign-vehicle` | | | PUT | `/admin/bookings/:id/status` | | | POST | `/admin/bookings/:id/cancel` | | | POST | `/admin/bookings/bulk-cancel` | | ```jsonc POST /admin/expressbooking { "tenantid": 13, // required; forced to your own tenant on client logins "pickuplocationid": 15, // a stored kitchen/branch — fills address, pincode and // coords for you, and is what makes per-site reporting work "customer_phone": "9876543210", // creates a Guest customer if unknown "customer_name": "Ramesh", "deliveryaddress": "12 Cross Cut Road, Gandhipuram", "deliverypincode": "641012", "deliverycity": "Coimbatore", "deliverylatitude": 11.0168, "deliverylongitude": 76.9558, "service_option": "Fast", // Normal | Fast | Superfast "finalprice": 120, // the order amount the tenant pays — passed through "notes": "Ring the bell", "parcels": [ { "itemcategory": "Food", "itemdescription": "2 meal boxes", "declaredvalue": 350 } ] } ``` Rules worth knowing: - `parcels` must be non-empty and `tenantid` must exist. - Pickup address + pincode are required **unless** `pickuplocationid` supplies them. - A `pickuplocationid` belonging to another tenant is rejected. - **CityGate**: the pickup pincode prefix must be an open city — `641` Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil. Any other prefix is refused at the middleware, before the handler runs. - Matching 3-digit pickup and delivery prefixes = hyperlocal, and the parcel goes straight to `Out_for_Delivery` at pickup instead of routing via a hub. - Auto-assignment fires after commit as a background retry loop (5 attempts, 2 min apart). The response returns before a rider is attached. ### Tracking one booking end to end **`GET /admin/bookings/:id/track`** — one call for the whole lifecycle, so the tracking screen doesn't have to stitch five requests together: ```jsonc { "booking": { …with parcels, serviceoptions, payments… }, "assignments": [ { mileruserid, assignmentstatus, assignedat, acceptedat, completedat, riderkms, ridercharges } ], // every attempt "riders": [ { userid, displayname, phone, … } ], // one per attempt "livelocation": { "latitude": …, "longitude": … }, // null if no ping in 30 min "lastpingat": "…", "consignment": { … }, // present once picked up "history": [ { eventstatus, remarks, createdat } ], "telemetry": [ ConsignmentLog… ], "deliveryproof": { deliveredtoname, photourl, geolatitude, … } } ``` `assignments` holds **every** attempt, not just the current one — a rejected first assignment is exactly what ops needs when asking why a pickup was slow. ## Consignments | Method | Path | Notes | |---|---|---| | GET | `/admin/consignments` | `?tenantid=` for staff | | GET | `/admin/consignments/:id` | | | GET | `/admin/consignments/:id/logs` | event history + telemetry + proof | | GET | `/admin/consignments/track/:trackingno` | | | PUT | `/admin/consignments/:id/status` | | `/:id/logs` returns `{ consignment, history[], telemetry[], deliveryproof? }` — the durable Postgres event log and the rider's Redis trail in one response, rather than making the console reconcile two stores. ## Tripsheets (hub-to-hub transport) | Method | Path | Body | |---|---|---| | GET | `/admin/tripsheets` | | | POST | `/admin/tripsheets` | `{ sourcehubid, destinationhubid, vehicleid, driveruserid }` | | GET | `/admin/tripsheets/:id` | | | POST | `/admin/tripsheets/:id/items` | `{ consignmentid }` | | DELETE | `/admin/tripsheets/:id/items/:itemid` | | | PUT | `/admin/tripsheets/:id/dispatch` | | | PUT | `/admin/tripsheets/:id/arrive` | | ## Pricing | Method | Path | Notes | |---|---|---| | GET | `/admin/pricing` | tenant pricing rules | | POST | `/admin/pricing` | `{ tenantid, applocationid, vehicletype, baseprice, baseweight, priceperkg, basedistance, priceperkm, handlingcharges, effectivefrom, effectiveto, currency, priority, status }` | | PUT | `/admin/pricing/:id` | | | DELETE | `/admin/pricing/:id` | | | POST | `/admin/pricing/simulate` | quote without creating anything | | POST | `/admin/pricing/quote` | same handler as simulate | | GET | `/admin/doormile-pricing` | Doormile's own bands | | POST | `/admin/doormile-pricing` | | | PUT | `/admin/doormile-pricing/:id` | | | DELETE | `/admin/doormile-pricing/:id` | soft delete | ## Exceptions | Method | Path | Body | |---|---|---| | GET | `/admin/exceptions` | | | POST | `/admin/exceptions` | `{ consignmentid, tripsheetid, hubid, exceptiontype, severity, description }` | | GET | `/admin/exceptions/:id` | | | PUT | `/admin/exceptions/:id/status` | `{ resolution, status }` — `Resolved` \| `Closed` | `exceptiontype`: `Lost`, `Damaged`, `Misrouted`, `Receiver_Refused`, `Missing_Contents`, `Undeliverable`. `severity`: `Low`, `Medium`, `High`, `Critical`. ## Competitive intel | Method | Path | |---|---| | GET/POST | `/admin/competitor-branches` | | PUT/DELETE | `/admin/competitor-branches/:id` | | GET/POST | `/admin/carrier-pricing` | | PUT/DELETE | `/admin/carrier-pricing/:id` | --- ## Conventions across every endpoint - **Envelope**: `{ "success": true, "data": ... }` on success, `{ "success": false, "message": "..." }` on failure. Lists add `total`, paginated lists add `page`. - **Pagination**: `?pageno=1&pagesize=100`. Default 500, cap 1000. - **Rate limits**: 300/min per IP globally, 10/min shared across all credential endpoints. Behind the ingress this keys on the proxy IP unless `TRUSTED_PROXIES` is set. - **Timestamps** are IST (`Asia/Kolkata`) wall-clock in `timestamp without time zone` columns. Send dates as `YYYY-MM-DD`, not epochs. - **Soft delete** exists on Hub, Vehicle, Consignment, Tripsheet, TripsheetItem, ConsignmentException, DoormilePricing, CarrierPricing. Partner and Tenant are hard-deleted. ## Verified against production, 2026-08-06 Run as both a client login (`info@dailygrubs.com`, tenant 13) and Doormile staff (`developer@doormile.com`, tenant 0): - dashboard, reports, milers, milers/summary, milers/:id, milers/:id/logs, milers/:id/activity, customers, bookings, bookings/:id, bookings/:id/track, consignments, consignments/:id/logs, tenants, tenants/:id/locations - `?tenantid=` narrowing on all of the above, for staff - 403 on a client requesting another tenant; 404 on cross-tenant reads *and* on `assign-miler` / `status` / `cancel` writes, with the target row confirmed unmodified afterwards Still unproven: tripsheets, exceptions, vehicles, competitor-branches, carrier-pricing, doormile-pricing, app-users CRUD, partner CRUD, bulk booking create/cancel, `assign-vehicle`, `block`. Written, compiled, never called with a real request.