API Reference — Public API
This page documents every endpoint served by the public API — the unauthenticated,
cookie-less surface consumed by the citizen-facing apps/web site. It excludes the
admin-* modules (admin-auth, admin-dashboard, admin-feed, admin-integrations,
admin-config, admin-geo, admin-officials, admin-reporters, admin-access,
admin-notifications, admin-analytics, admin-ops), which back the internal admin
console and require an authenticated session — those are covered on a separate page.
Note that the administrators module (documented below) is not an "admin" module in
that sense: it's the public directory of elected officials (Corporators, MLAs, MPs) and
BMC Ward Officers, distinct from the admin console's admin-* prefix.
Conventions
- All routes are mounted under the global prefix
/api(set viaapp.setGlobalPrefix('api')inapps/api/src/main.ts). - The API is a single-tenant-per-request design: nearly every list/detail endpoint takes
an optional
cityIdquery parameter that defaults to'mumbai'if omitted. As of this writing geography (cities/wards/jurisdictions) and officials/departments/police-stations are seeded for Mumbai, Thane, and Navi Mumbai; only issues and reporters are Mumbai-only (seed.tshardcodescityId: MUMBAIfor those two). - CORS is configured exactly once, app-global, in
apps/api/src/main.ts— there is no per-route override anywhere. Every route in this API, public and admin alike, shares the identical strict origin allow-list withcredentials: true. - None of the controllers in this document declare DTO validation classes (no
class-validatordecorated bodies) — query parameters are read directly with@Query()and are untyped strings unless otherwise noted. There is no globalValidationPiperegistered inmain.tsfor this API. - Every response below is the literal shape produced by the corresponding service method, read from source. Where a value is a deterministic, hash-seeded placeholder rather than real aggregated data, that's called out explicitly — this codebase's house rule is to never present fabricated numbers as if they were real without a caveat ("honest placeholder").
Health
Module: apps/api/src/health/. A single liveness endpoint, not versioned or
tenant-scoped.
GET /api/health
Returns a static liveness payload — no DB round trip:
{ "ok": true, "service": "civicpulse-api", "ts": "2026-07-09T12:00:00.000Z" }
ts is new Date().toISOString() evaluated at request time.
Geography
Module: apps/api/src/geography/. Backs the city and ward pickers.
GET /api/cities
Lists every row in the cities table, ordered by name ascending. No query parameters.
Each row is the full cities schema: id (slug, e.g. "mumbai"), name, state,
subdomain, status ("live" | "rollout" | "waitlist"), createdAt.
GET /api/wards?cityId=mumbai
Lists every row in the wards table for the given cityId (defaults to mumbai),
ordered by code ascending. Returns the full wards schema per row: id, cityId,
code, name, zone, wardNumber, officeAddress, phone, geom (nullable PostGIS
multipolygon boundary — null until boundary polygons are loaded), score (nullable
integer), resPct (nullable integer, only populated for the curated leaderboard set —
see Scores), trend (nullable integer), createdAt.
Issues
Module: apps/api/src/issues/. The core civic-issue feed.
GET /api/issues?cityId=mumbai&category=Infrastructure&q=pothole
Lists issues for a city, newest first (createdAt desc).
cityId(string, optional, default"mumbai")category(string, optional) — exact match againstissues.category; the literal value"All"is treated as "no filter"q(string, optional) — free-text search; matched withILIKE '%term%'againsttitle,area,ward,pincode,id,category, andreporterName(OR'd together)
Returns an array of Issue DTOs, each shaped:
{
id: string
title: string
category: string
area: string // '' if null in DB
ward: string // '' if null
wardNo: number // 0 if null
pincode: string // '' if null
mp: string // '' if null
status: string // 'new' | 'ack' | 'progress' | 'resolved' | 'overdue'
channel: string // e.g. 'Platform' | 'Instagram' | 'X' | 'WhatsApp'
votes: number
timeAgo: string // relative time, e.g. '5m' | '3h' | '2d'
reporter: string // reporterName, or 'Anonymous' if null
isCR: boolean // isCitizenReporter
coords: string | null // formatted 'lat° N, lng° E', derived from PostGIS coords
addr: string | null
media: { t: 'photo' | 'video'; d?: string }[]
desc: string // '' if null
}
coords is computed server-side from the PostGIS coords point column
(ST_Y/ST_X) and formatted as e.g. "19.0596° N, 72.8295° E"; it's null if the
issue has no coordinates. media entries are demo/placeholder gallery-tile descriptors,
not references to real uploaded files — the schema comment on issues.media notes real
uploads belong in a separate issue_media table once a write/upload path exists.
GET /api/issues/:id
Fetches a single issue by its id (e.g. CP-4821). Same Issue shape as above. Returns
HTTP 404 (NotFoundException) if no issue with that id exists.
Reporters
Module: apps/api/src/reporters/. The citizen-reporter directory.
GET /api/reporters?cityId=mumbai&zone=West&q=priya
Lists reporters for a city, ordered by rank ascending.
cityId(string, optional, default"mumbai")zone(string, optional) — exact match againstreporters.zone;"All"means no filterq(string, optional) — free-text search viaILIKE '%term%'againstname,beat, andarea(OR'd)
Returns an array of reporter DTOs:
{
name: string
slug: string
cr: boolean // isCitizenReporter
me: boolean // isMe — flags the "you" profile in demo/seed data
zone: string
beat: string | null
area: string | null
joined: string | null
reports: number
resolved: number
reached: string | null
impact: string | null
rank: number
bio: string | null
extra: ReporterReportRef[] // [{ id, title, area, pincode, status }], denormalized extra report refs
}
GET /api/reporters/:slug
Fetches a single reporter by slug. Same DTO shape as above. Returns HTTP 404 if no
reporter with that slug exists.
Events
Module: apps/api/src/events/. Community events (clean-ups, plantation drives, etc.)
and event nominations. Note this controller has no @Controller() path prefix of its
own — routes are declared with the full path in each @Get() decorator.
GET /api/events?cityId=mumbai&type=cleanup&city=Mumbai&q=beach
Lists events for a city, ordered by date ascending.
cityId(string, optional, default"mumbai") — filtersevents.cityIdtype(string, optional) — exact match againstevents.type; the literal value"all"means no filtercity(string, optional) — exact match againstevents.city(the display-name column, distinct fromcityId); the literal value"All cities"means no filterq(string, optional) — free-text search viaILIKE '%term%'againsttitle,location, andcity(OR'd)
Returns an array of event DTOs. For each event row, attendee rows are fetched from
event_attendees and embedded:
{
id: string
title: string
desc: string // '' if null
type: string
city: string
date: string // 'YYYY-MM-DD'
time: string // '' if null
duration: string // '' if null
location: string // '' if null
maxAttendees: number
status: string // 'upcoming' | 'completed'
attendees: { name: string; avatar: string }[]
organiser: string // '' if null
impact: string // '' if null
}
GET /api/events/:id
Fetches a single event by id (e.g. EV-001). Same DTO shape as above. Returns HTTP
404 if no event with that id exists.
GET /api/nominations?cityId=mumbai
Lists community-suggested activities seeking an organiser, filtered by cityId
(defaults to "mumbai"). Returns the raw nominations table rows, no transformation:
id, cityId, title, desc (nullable), city, supporters (integer, default 0),
type.
Scores
Module: apps/api/src/scores/. Ward/city/pincode civic scorecards. Several endpoints
here mix real DB-backed scores with deterministic, hash-seeded filler numbers for fields
that don't have real aggregation yet — each is called out below.
GET /api/scores/leaderboard?cityId=mumbai
Returns the curated ward leaderboard: only wards where wards.resPct IS NOT NULL
(a "wards with real resPct/trend data" subset — as of this writing this is a curated
set, not all wards in the city), ordered by score descending.
{
rank: number // 1-based position in the sorted list
code: string // wards.code (DB short form, e.g. 'H/W')
area: string // wards.name, falls back to code
score: number
grade: string // 'A' | 'B' | 'C' | 'D' | 'E', from gradeOf(score)
gradeC: string // hex color for the grade
rankC: string // '#FB4A2E' for rank 1, else '#CBD0D8'
res: number // resPct
barPct: string // `${resPct}%`
trendSign: string // '▲' | '▼' | '–'
trendTxt: string // absolute trend value as string, '' if 0
trendC: string // hex color keyed to trend direction
}[]
GET /api/scores/city
Returns a city-level summary. This endpoint takes no cityId parameter and is not
wired to it — the controller method calls citySummary() with no arguments, and the
service returns a hardcoded constant (CITY_SUMMARY in domain/constants.ts) regardless
of city. The source comment on that constant states real aggregation (materialized views
over the live issues table) is planned for a later milestone; today it's a placeholder
so the Scores page has something to render:
{
score: number // 64
resPct: string // '58%'
reportsWeek: string // '3,420'
avgResp: string // '19h'
overdue: string // '214'
trend: string // '3'
label: string // 'Slow, but improving'
areaSampleScore: number // 76
grade: string // derived from gradeOf(score)
gradeC: string
}
GET /api/scores/units?cityId=mumbai&kind=ward
Lists scorecard units for an index page. kind is either "ward" (default) or
"pincode"; any other value falls through to the ward branch.
- When
kind=ward: returns every row inwardsfor the city (not filtered to the curated leaderboard set), ordered byscoredescending —{ code, area, score, grade, gradeC }[]. - When
kind=pincode: returns every seeded pincode for the city. For each pincode, the score is derived from its parent ward's score plus a small deterministic offset (hash of the pincode, range -5..+5), clamped to[32, 90], then sorted by score descending —{ pincode, area, wardCode, score, grade, gradeC }[]. If a pincode has no linked ward, its base score falls back to a hardcoded64.
GET /api/scores/report?cityId=mumbai&kind=ward&code=H%2FWest&pin=400050
Builds a full civic scorecard report for one ward or one pincode.
cityId(string, optional, default"mumbai")kind(string, optional, default"ward")code(string, optional) — ward code in the frontend's full form (e.g."H/West"); defaults to"H/West"if omittedpin(string, optional) — pincode; ifkind=pincodeandpinis present, the pincode branch runs, otherwise the ward branch runs regardless ofkind
The real ward score is looked up from the wards table (falls back to 64 if the
ward isn't found); for a pincode report the score is the ward's score plus the same
deterministic ±5 offset used by /scores/units. Everything else in the report —
reports, resolved, overdue, breaches, avgResp, category breakdown, and the
drill-down issue list — is generated from a deterministic FNV-1a hash of the unit
identifier, not queried from real issue rows. This is explicitly a ported-from-frontend
placeholder (see ReportService source comments), not a real aggregation. Returned
shape (CivicReport):
{
kind: string // 'Ward' | 'Pincode'
isPin: boolean
title: string // 'Ward H/West' or 'Pincode 400050'
subtitle: string
unitLabel: string
area: string
wardCode: string
wardArea: string
subdomain: string // e.g. 'mumbai.civicpulseindia.com/ward/h-west'
fileBase: string // e.g. 'civicpulse-ward-h-west'
score: number
grade: string
gradeC: string
reports: string // formatted count, e.g. '1,234' (Indian digit grouping)
reportsN: number
resolved: string
resolvedPct: string
overdue: number
breaches: number
avgResp: string // e.g. '23h'
cats: { name: string; pct: number; pctW: string; count: number; color: string }[]
tree: RoleContact[] // Corporator, MLA, MP role-contact cards
officer: RoleContact // BMC Ward Officer role-contact card
issues: GenIssue[] // 4 generated drill-down issue rows
}
GenIssue (from report.service.ts) is a lightweight, unrelated shape from the full
Issue DTO used elsewhere on this page:
{
id: string
title: string
area: string
statusLabel: string
statusFg: string // hex color
statusBg: string // hex color
statusDot: string // hex color
votes: number
ago: string // relative time, e.g. '5m' | '3h' | '2d'
}
See RoleContact shape below for the tree/officer entries, and
note the same "real data where seeded, deterministic placeholder otherwise" caveat
applies to them.
Administrators
Module: apps/api/src/administrators/. Public directory of elected officials and ward
administrative contacts — Corporators (elected municipal ward representatives), BMC
(Brihanmumbai Municipal Corporation) Ward Officers, MLAs (Members of the state
Legislative Assembly), and MPs (Members of Parliament, Lok Sabha). This is unrelated to
the admin-* internal-console modules despite the similar name.
GET /api/administrators/roles
Returns the static list of selectable roles (no DB access):
[
{ "key": "Corporator", "label": "Corporators", "hint": "Elected · one per ward" },
{ "key": "BMC Ward Officer", "label": "Ward Officers", "hint": "BMC · administrative" },
{ "key": "MLA", "label": "MLAs", "hint": "State assembly" },
{ "key": "MP", "label": "MPs", "hint": "Lok Sabha" }
]
GET /api/administrators?role=Corporator&cityId=mumbai
Lists contact cards for a role.
role(string, optional, default"Corporator") — one of the keys abovecityId(string, optional, default"mumbai")
The result set is scoped to the same curated ward subset as the leaderboard
(wards.resPct IS NOT NULL) — per the source comment, this is because the fabricated
constituency-mapping data (WARD_META) needed to dedupe MLA/MP entries only covers that
set. For role=Corporator or role=BMC Ward Officer, every ward in that subset returns
one card. For role=MLA or role=MP, wards are deduplicated by constituency so each
real MLA/MP appears once even if their constituency spans multiple wards (preferring the
real constituency code from the jurisdictions table where seeded, falling back to the
fictional WARD_META mapping otherwise).
Each entry is a RoleContact plus a sub label:
{
role: string
jur: string // jurisdiction label, e.g. 'Bandra West Assembly'
office: string
email: string
phone: string
score: number // deterministic hash-seeded 44-92, NOT a real performance metric
grade: string
gradeC: string
gradeBg: string
owned: number // deterministic hash-seeded case count, 60-260
resolved: number // deterministic hash-seeded, <= owned
resPct: string // resolved/owned as a percentage string
tintBg: string // avatar background color
tintFg: string // avatar foreground color
sub: string // ward name (Corporator/Ward Officer) or
// 'Assembly constituency' / 'Lok Sabha constituency' (MLA/MP)
}
Important caveat: score, owned, and resolved are always computed by a
deterministic FNV-1a hash of role + code — this is true whether or not the ward has
real seeded contact data, so these three fields are placeholder numbers everywhere, not
real performance metrics. Only jur, office, email, and phone are swapped for real
data from the jurisdictions/officials tables when a matching row exists; otherwise
they fall back to generated text templates. As of this writing, real contact data is
seeded for 8 of Mumbai's 24 wards plus Thane/Navi Mumbai. Note also that officials.name
is intentionally never exposed by this endpoint even when a real official row exists —
the schema's comment states the public API surfaces office accountability, not the
individual office-holder's name.
Search
Module: apps/api/src/search/. Cross-entity keyword search.
GET /api/search?q=pothole&cityId=mumbai
q(string, optional, default"") — if empty/whitespace after trimming, returns all four result arrays empty without querying the DBcityId(string, optional, default"mumbai")
Fans the trimmed query out in parallel to the issues list, reporters list, and events
list services (each with the same free-text ILIKE matching described in their
respective sections above), plus a direct ward lookup (ILIKE on wards.code or
wards.name). Returns:
{
issues: Issue[] // same shape as GET /api/issues
reporters: ReporterDto[] // same shape as GET /api/reporters
wards: { code: string; area: string; score: number; grade: string; gradeC: string }[]
events: EventDto[] // same shape as GET /api/events
}
No result is paginated or limited — all matches from each sub-query are returned.