openapi: 3.1.0 info: title: Kuramap public API version: "1.0.0" summary: Read-only civic geography and representatives for Kenya. description: | The **Kuramap public API** serves read-only JSON over Kenya's administrative regions (county → constituency → ward), their boundary geometry, and the elected representatives who currently hold each seat. The schema is country-parameterised - every path begins with a `{country}` slug (`ke` today). Treat it as a variable; never hard-code Kenya. ## Authentication Most clients can call the API **anonymously** - anonymous traffic is rate-limited per IP, cached at the edge, and needs no key. Pass an API key to get a higher, attributable quota and `X-RateLimit-*` accounting. Send the key as a **bearer token**: ``` Authorization: Bearer km_live_3f9a1c20<…> ``` (The legacy header `X-API-Key: km_live_…` is also accepted.) A key looks like `km_live_`: `` is a short, **non-secret** 8-character identifier - it forms the stored, displayable prefix `km_live_` so you can match a key to its dashboard row - and `` is the high-entropy remainder. **Only the prefix and a SHA-256 hash are ever stored.** The full key is shown exactly once, at creation; Kuramap cannot display it again, so copy it immediately. If you lose it, revoke it and create a new one. Create and manage keys in the developer dashboard at **`/developers`** (sign in with a magic link). An unknown or revoked key returns `401` with a `WWW-Authenticate: Bearer` header; the bad-key response is never cached. ## Rate limits & quotas | Tier | Daily quota | Burst (per minute) | | ------ | ------------------- | ------------------------- | | free | 10,000 requests/day | 600 requests/minute | Tiers are server config, so the numbers above are the launch defaults (`API_FREE_DAILY` / `API_FREE_RATE_PER_MIN`); a future paid tier raises them without any change to this contract. Every **keyed** response carries the daily-quota accounting headers: | Header | Meaning | | ----------------------- | ---------------------------------------------------- | | `X-RateLimit-Limit` | Your tier's daily request cap. | | `X-RateLimit-Remaining` | Requests left in the current UTC day. | | `X-RateLimit-Reset` | Unix epoch seconds when the daily window resets (00:00 UTC). | Exceeding the **daily quota** or the **per-minute burst** returns `429 Too Many Requests` with a `Retry-After` header (seconds to wait). Anonymous requests are governed only by the per-IP limit and do not carry the `X-RateLimit-*` headers. ## Caching Anonymous `2xx` responses are public and cacheable (`Cache-Control: public, max-age=300, s-maxage=86400`). The exception is `/{country}/stats`, which sends `s-maxage=300`: it is an aggregate consumed at site-build time, where a 24-hour shared cache could publish day-old figures. **Keyed** responses are `private, no-store` - supplying a key opts you out of the shared cache so every request counts against your quota. Error responses are never cached. ## Versioning & deprecation The API is versioned in the path (`/api/v1`). Within `v1` we only make **backward-compatible** changes: new endpoints, new optional query parameters, and new fields on existing objects. Clients **must** ignore unknown JSON fields and unknown enum values so these additions never break them. A **breaking** change - removing or renaming a field, changing a type, tightening validation, or altering response semantics - would ship under a new major path (`/api/v2`), and `v1` and `v2` would run side by side. When an endpoint or field is slated for removal it is first marked deprecated in this document (and, where practical, with a `Deprecation`/`Sunset` response header) with a published sunset window before it is withdrawn. contact: name: Kuramap url: https://kuramap.euxven.com servers: - url: https://kuramap.euxven.com/api/v1 description: Production - url: http://localhost:8080/api/v1 description: Local development (Go API on the host; port may differ via API_HOST_PORT) # Anonymous OR keyed - the empty requirement object marks auth optional. security: - {} - ApiKeyAuth: [] tags: - name: meta description: Supported countries and service metadata. - name: regions description: County → constituency → ward hierarchy and detail. - name: geometry description: Boundary geometry as GeoJSON (sub-issue #76). - name: representatives description: Current elected representatives and coverage. - name: map description: Reverse lookup and per-region map annotations. - name: search description: Unified search over regions, parties, and representatives. paths: /countries: get: tags: [meta] operationId: listCountries summary: List supported countries description: Every country Kuramap serves, with the default map view. responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: type: array items: { $ref: "#/components/schemas/CountrySummary" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/stats: get: tags: [meta] operationId: getCountryStats summary: Region counts and seat coverage for a country's current cycle description: > One aggregate over the country's current election cycle: how many regions exist per level, and per political role how many seats exist, are filled, and are vacant. A seat counts as **filled** when an open term (no end date, or an end date today or later) is held by a named representative; anything else is **vacant**. This is the same definition `/{country}/representatives/coverage` uses, so the two endpoints always agree. Cached `public, max-age=300, s-maxage=300` - a shorter shared-cache TTL than the rest of the API, because these numbers are prerendered into the Kuramap website at build time and must not be a day stale. parameters: - $ref: "#/components/parameters/Country" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/CountryStats" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/counties: get: tags: [regions] operationId: listCounties summary: List a country's counties parameters: - $ref: "#/components/parameters/Country" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: type: array items: { $ref: "#/components/schemas/RegionSummary" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/counties/{slug}: get: tags: [regions] operationId: getCounty summary: County detail with current representatives parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/CountyDetailResponse" } example: county: id: 47 code: "047" name: Nairobi slug: nairobi centroid: { lat: -1.3028, lng: 36.8261 } bbox: [36.6645, -1.4445, 37.1037, -1.1607] current_representatives: - role: { code: governor, name: Governor } party: { code: uda, name: United Democratic Alliance, color: "#facc15", } person: full_name: Johnson Sakaja slug: johnson-sakaja photo_url: https://cdn.kuramap.com/reps/12/main-512.webp "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/counties/{slug}/constituencies: get: tags: [regions] operationId: listConstituenciesByCounty summary: List constituencies in a county parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: type: array items: { $ref: "#/components/schemas/RegionSummary" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/counties/{slug}/geojson: get: tags: [geometry] operationId: getCountyGeoJSON summary: County boundary as a GeoJSON Feature description: | Returns the county boundary as a single GeoJSON `Feature` (`Polygon` or `MultiPolygon`). Introduced in sub-issue #76. parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" - $ref: "#/components/parameters/Simplify" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/geo+json: schema: { $ref: "#/components/schemas/RegionFeature" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/GeometryTooLarge" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/constituencies/{slug}: get: tags: [regions] operationId: getConstituency summary: Constituency detail with current representatives parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/ConstituencyDetailResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/constituencies/{slug}/wards: get: tags: [regions] operationId: listWardsByConstituency summary: List wards in a constituency parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: type: array items: { $ref: "#/components/schemas/RegionSummary" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/constituencies/{slug}/geojson: get: tags: [geometry] operationId: getConstituencyGeoJSON summary: Constituency boundary as a GeoJSON Feature description: | Returns the constituency boundary as a single GeoJSON `Feature`. Introduced in sub-issue #76. parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" - $ref: "#/components/parameters/Simplify" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/geo+json: schema: { $ref: "#/components/schemas/RegionFeature" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/GeometryTooLarge" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/wards/{slug}: get: tags: [regions] operationId: getWard summary: Ward detail with current representatives parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/WardDetailResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/wards/{slug}/geojson: get: tags: [geometry] operationId: getWardGeoJSON summary: Ward boundary as a GeoJSON Feature description: | Returns the ward boundary as a single GeoJSON `Feature`. Introduced in sub-issue #76. parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" - $ref: "#/components/parameters/Simplify" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/geo+json: schema: { $ref: "#/components/schemas/RegionFeature" } example: type: Feature geometry: type: Polygon coordinates: - [ [36.78, -1.28], [36.83, -1.28], [36.83, -1.31], [36.78, -1.31], [36.78, -1.28], ] properties: name: Kileleshwa slug: kileleshwa code: "0290" level: ward country: ke current_representative: full_name: Robert Alai slug: robert-alai role_code: mca role_name: MCA "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/GeometryTooLarge" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/representatives: get: tags: [representatives] operationId: listRepresentatives summary: List, search, and filter representatives description: | Two response shapes, selected by the query string: - **No** `q`, `page`, `role`, `level`, `region`, or `party`: a flat array of `{ full_name, slug }` for every representative (used to enumerate pages for the static site). - **Any** of those parameters present: a paged object (`RepresentativePage`, fixed page size 24) with `current_terms` on each item. parameters: - $ref: "#/components/parameters/Country" - name: q in: query description: Case-insensitive substring match on the representative's name. schema: { type: string } example: sakaja - name: page in: query description: 1-based page index (paged shape only). Out-of-range values clamp to 1. schema: { type: integer, minimum: 1, default: 1 } - name: role in: query description: Filter to a single political role. schema: { $ref: "#/components/schemas/RoleCode" } - name: level in: query description: Filter to representatives whose term is at this region level. schema: { $ref: "#/components/schemas/RegionLevel" } - name: region in: query description: Region slug to filter by (e.g. `nairobi`). schema: { type: string } example: nairobi - name: party in: query description: > Filter to representatives whose current term is with this party (party code, matched case-insensitively). schema: { type: string } example: uda responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: description: A flat summary array (no filters) or a paged object (any filter). oneOf: - type: array items: { $ref: "#/components/schemas/RepresentativeSummary" } - $ref: "#/components/schemas/RepresentativePage" "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/representatives/by-region: get: tags: [map] operationId: listRepsByRegion summary: Current representatives per region, for map annotations parameters: - $ref: "#/components/parameters/Country" - name: level in: query required: true description: Region level to annotate. schema: { $ref: "#/components/schemas/RegionLevel" } responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: type: array items: { $ref: "#/components/schemas/RepByRegionEntry" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/representatives/coverage: get: tags: [representatives] operationId: listRepresentativesCoverage summary: Regions with vacant seats at a level parameters: - $ref: "#/components/parameters/Country" - name: level in: query required: true description: Region level to report coverage for. schema: { $ref: "#/components/schemas/RegionLevel" } - name: page in: query schema: { type: integer, minimum: 1, default: 1 } responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/CoveragePage" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/representatives/{slug}: get: tags: [representatives] operationId: getRepresentative summary: Representative detail parameters: - $ref: "#/components/parameters/Country" - $ref: "#/components/parameters/Slug" responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/RepresentativeDetail" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/lookup: get: tags: [map] operationId: lookupPoint summary: Reverse-geocode a point to ward / constituency / county description: Returns the (ward, constituency, county) tuple that contains a lat/lng point. parameters: - $ref: "#/components/parameters/Country" - name: lat in: query required: true description: Latitude in EPSG:4326. schema: { type: number, format: double, minimum: -90, maximum: 90 } example: -1.2921 - name: lng in: query required: true description: Longitude in EPSG:4326. schema: { type: number, format: double, minimum: -180, maximum: 180 } example: 36.8219 responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/LookupResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: No region contains the point (or unknown country). content: application/json: schema: { $ref: "#/components/schemas/ApiError" } "429": { $ref: "#/components/responses/TooManyRequests" } /{country}/search: get: tags: [search] operationId: search summary: Unified search over regions, parties, and representatives parameters: - $ref: "#/components/parameters/Country" - name: q in: query required: true description: Search query. schema: { type: string, minLength: 1, maxLength: 120 } example: nakuru - name: types in: query description: > Comma-separated subset of `county,constituency,ward,party,representative`. Unknown values are dropped; an all-unknown list behaves as no filter. schema: { type: string } example: county,representative - name: limit in: query schema: { type: integer, minimum: 1, maximum: 50, default: 10 } responses: "200": description: OK headers: X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/SearchResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/TooManyRequests" } components: securitySchemes: ApiKeyAuth: type: http scheme: bearer bearerFormat: km_live_ description: | Pass your API key as a bearer token: `Authorization: Bearer km_live_…`. The header `X-API-Key: km_live_…` is also accepted. Keys are optional - anonymous access works without one. parameters: Country: name: country in: path required: true description: Country slug (e.g. `ke`). schema: { type: string } example: ke Slug: name: slug in: path required: true description: Region or representative slug (kebab-case). schema: { type: string } example: nairobi Simplify: name: simplify in: query required: false description: > Optional Douglas–Peucker tolerance (in degrees) to simplify the returned geometry; larger values return fewer vertices. Omit for full resolution. (Sub-issue #76.) schema: { type: number, format: double, minimum: 0 } example: 0.001 headers: RateLimitLimit: description: Your tier's daily request cap. Present on keyed responses only. schema: { type: integer, format: int64 } example: 10000 RateLimitRemaining: description: Requests left in the current UTC day. Present on keyed responses only. schema: { type: integer, format: int64 } example: 9987 RateLimitReset: description: Unix epoch seconds when the daily quota resets (00:00 UTC). schema: { type: integer, format: int64 } example: 1750982400 RetryAfter: description: Seconds to wait before retrying after a 429. schema: { type: integer, format: int32 } example: 42 responses: NotFound: description: The country, region, or representative does not exist. content: application/json: schema: { $ref: "#/components/schemas/ApiError" } example: { error: "unknown county: atlantis", status: 404 } BadRequest: description: A query parameter failed validation. content: application/json: schema: { $ref: "#/components/schemas/ApiError" } example: { error: "level must be one of: county, constituency, ward", status: 400, } Unauthorized: description: The supplied API key is unknown or revoked. Anonymous requests never receive this. headers: WWW-Authenticate: description: Always `Bearer` (RFC 6750). schema: { type: string } example: Bearer content: application/json: schema: { $ref: "#/components/schemas/ApiError" } example: { error: "invalid or revoked API key", status: 401 } TooManyRequests: description: The per-minute burst or the daily quota for this key was exceeded. headers: Retry-After: { $ref: "#/components/headers/RetryAfter" } X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" } content: application/json: schema: { $ref: "#/components/schemas/ApiError" } example: { error: "daily API quota exceeded for this key", status: 429 } GeometryTooLarge: description: > The requested geometry exceeds the response size cap. Pass a `simplify` tolerance (or request a smaller region) and retry. (Sub-issue #76.) content: application/json: schema: { $ref: "#/components/schemas/ApiError" } example: { error: "geometry too large; pass a simplify tolerance", status: 422, } schemas: ApiError: type: object description: The uniform error envelope for every non-2xx response. required: [error, status] properties: error: { type: string, example: "unknown county: atlantis" } status: { type: integer, example: 404 } RoleCode: type: string description: A political role code. enum: [governor, senator, woman_rep, mp, mca] RegionLevel: type: string description: A region level in the administrative hierarchy. enum: [county, constituency, ward] LatLng: type: object description: A geographic point. required: [lat, lng] properties: lat: { type: number, format: double, example: -1.3028 } lng: { type: number, format: double, example: 36.8261 } Bbox: type: array description: Bounding box as `[west, south, east, north]` (equivalently `[minLng, minLat, maxLng, maxLat]`). items: { type: number, format: double } minItems: 4 maxItems: 4 example: [36.6645, -1.4445, 37.1037, -1.1607] CountrySummary: type: object required: [iso_code, slug, name, default_lat, default_lng, default_zoom] properties: iso_code: { type: string, example: KE } slug: { type: string, example: ke } name: { type: string, example: Kenya } default_lat: { type: number, format: double, example: 0.0236 } default_lng: { type: number, format: double, example: 37.9062 } default_zoom: { type: number, format: float, example: 6 } RegionSummary: type: object description: The list-row shape for a region (county / constituency / ward). required: [id, code, name, slug] properties: id: { type: integer, format: int32, example: 47 } code: { type: string, example: "047" } name: { type: string, example: Nairobi } slug: { type: string, example: nairobi } ParentRef: type: object description: A minimal reference to a parent region. required: [slug, name] properties: slug: { type: string, example: westlands } name: { type: string, example: Westlands } RegionRef: type: object description: A region reference including id and code (used by reverse lookup). required: [id, code, name, slug] properties: id: { type: integer, format: int32, example: 290 } code: { type: string, example: "0290" } name: { type: string, example: Kileleshwa } slug: { type: string, example: kileleshwa } RefCodeName: type: object required: [code, name] properties: code: { type: string, example: governor } name: { type: string, example: Governor } PartyRef: type: object required: [code, name] properties: code: { type: string, example: uda } name: { type: string, example: United Democratic Alliance } color: type: [string, "null"] description: Hex brand colour, or null. example: "#facc15" PersonRef: type: object required: [full_name, slug] properties: full_name: { type: string, example: Johnson Sakaja } slug: { type: string, example: johnson-sakaja } photo_url: type: [string, "null"] description: 512px WebP portrait URL, or null when none/unconfigured. example: https://cdn.kuramap.com/reps/12/main-512.webp CurrentRep: type: object description: One current representative of a region, with role and party. required: [role, person] properties: role: { $ref: "#/components/schemas/RefCodeName" } party: oneOf: - { $ref: "#/components/schemas/PartyRef" } - { type: "null" } person: { $ref: "#/components/schemas/PersonRef" } RepRegion: type: object description: The single region a term ties to (county / constituency / ward). required: [type, slug, name] properties: type: { $ref: "#/components/schemas/RegionLevel" } slug: { type: string, example: nairobi } name: { type: string, example: Nairobi } RepRegionLink: type: object description: > The region a representative represents, on a **representative detail** response: the rep's primary current-term region plus links to its boundary geometry and public map page, so a client can draw the rep's region without a second round-trip. The whole object is null when the rep holds no current term tied to a region. required: [type, slug, name, geojson_url] properties: type: { $ref: "#/components/schemas/RegionLevel" } slug: { type: string, example: nairobi } name: { type: string, example: Nairobi } geojson_url: type: string description: URL of the region's GeoJSON boundary endpoint. example: /api/v1/ke/counties/nairobi/geojson map_url: type: [string, "null"] description: URL/path of the region's interactive map page. example: /ke/counties/nairobi RepTerm: type: object required: [role, region, start_date] properties: role: { $ref: "#/components/schemas/RefCodeName" } party: oneOf: - { $ref: "#/components/schemas/PartyRef" } - { type: "null" } region: { $ref: "#/components/schemas/RepRegion" } start_date: type: string description: Term start as `YYYY-MM-DD` (empty string when open-ended). example: "2022-08-25" RepTermSummary: type: object description: The list-row subset of a term (role + region only). required: [role, region] properties: role: { $ref: "#/components/schemas/RefCodeName" } region: { $ref: "#/components/schemas/RepRegion" } RepresentativeSummary: type: object description: The flat list-row when no filter is applied. required: [full_name, slug] properties: full_name: { type: string, example: Johnson Sakaja } slug: { type: string, example: johnson-sakaja } RepListItem: type: object required: [full_name, slug, current_terms] properties: full_name: { type: string, example: Johnson Sakaja } slug: { type: string, example: johnson-sakaja } current_terms: type: array items: { $ref: "#/components/schemas/RepTermSummary" } RepresentativePage: type: object description: A page of representatives (fixed page size 24). required: [items, total, page, page_size] properties: items: type: array items: { $ref: "#/components/schemas/RepListItem" } total: { type: integer, format: int64, example: 349 } page: { type: integer, example: 1 } page_size: { type: integer, example: 24 } RepresentativeDetail: type: object required: [full_name, slug, source_urls, current_terms] properties: full_name: { type: string, example: Johnson Sakaja } slug: { type: string, example: johnson-sakaja } photo_url: { type: [string, "null"], example: https://cdn.kuramap.com/reps/12/main-512.webp, } bio: { type: [string, "null"] } phone: { type: [string, "null"] } email: { type: [string, "null"] } website: { type: [string, "null"] } twitter: { type: [string, "null"] } facebook: { type: [string, "null"] } source_urls: type: array items: { type: string } example: ["https://www.parliament.go.ke/"] current_terms: type: array items: { $ref: "#/components/schemas/RepTerm" } region: description: > The region this representative represents (the primary current term's region), with links to its boundary geometry and public map page. Null when no current term ties to a region. oneOf: - { $ref: "#/components/schemas/RepRegionLink" } - { type: "null" } CountyDetail: type: object required: [id, code, name, slug, centroid, bbox] properties: id: { type: integer, format: int32, example: 47 } code: { type: string, example: "047" } name: { type: string, example: Nairobi } slug: { type: string, example: nairobi } centroid: { $ref: "#/components/schemas/LatLng" } bbox: { $ref: "#/components/schemas/Bbox" } CountyDetailResponse: type: object required: [county, current_representatives] properties: county: { $ref: "#/components/schemas/CountyDetail" } current_representatives: type: array items: { $ref: "#/components/schemas/CurrentRep" } ConstituencyDetail: type: object required: [id, code, name, slug, county, centroid, bbox] properties: id: { type: integer, format: int32, example: 280 } code: { type: string, example: "280" } name: { type: string, example: Westlands } slug: { type: string, example: westlands } county: { $ref: "#/components/schemas/ParentRef" } centroid: { $ref: "#/components/schemas/LatLng" } bbox: { $ref: "#/components/schemas/Bbox" } ConstituencyDetailResponse: type: object required: [constituency, current_representatives] properties: constituency: { $ref: "#/components/schemas/ConstituencyDetail" } current_representatives: type: array items: { $ref: "#/components/schemas/CurrentRep" } WardDetail: type: object required: [id, code, name, slug, constituency, county, centroid, bbox] properties: id: { type: integer, format: int32, example: 1390 } code: { type: string, example: "0290" } name: { type: string, example: Kileleshwa } slug: { type: string, example: kileleshwa } constituency: { $ref: "#/components/schemas/ParentRef" } county: { $ref: "#/components/schemas/ParentRef" } centroid: { $ref: "#/components/schemas/LatLng" } bbox: { $ref: "#/components/schemas/Bbox" } WardDetailResponse: type: object required: [ward, current_representatives] properties: ward: { $ref: "#/components/schemas/WardDetail" } current_representatives: type: array items: { $ref: "#/components/schemas/CurrentRep" } LookupResponse: type: object description: The region tuple containing a point, plus the echoed coordinates. required: [country, lookup, county, constituency, ward] properties: country: { type: string, example: ke } lookup: { $ref: "#/components/schemas/LatLng" } county: { $ref: "#/components/schemas/RegionRef" } constituency: { $ref: "#/components/schemas/RegionRef" } ward: { $ref: "#/components/schemas/RegionRef" } RepByRegionPerson: type: object required: [full_name, slug, role_code] properties: full_name: { type: string, example: Johnson Sakaja } slug: { type: string, example: johnson-sakaja } role_code: { type: string, example: governor } RepByRegionEntry: type: object required: [level, region_slug, region_name, centroid, reps] properties: level: { $ref: "#/components/schemas/RegionLevel" } region_slug: { type: string, example: nairobi } region_name: { type: string, example: Nairobi } centroid: { $ref: "#/components/schemas/LatLng" } reps: type: array items: { $ref: "#/components/schemas/RepByRegionPerson" } SearchResult: type: object required: [type, id, code, name, slug] properties: type: type: string enum: [county, constituency, ward, party, representative] example: county id: { type: integer, format: int32, example: 47 } code: { type: string, example: "047" } name: { type: string, example: Nairobi } slug: { type: string, example: nairobi } parent_slug: { type: [string, "null"], example: null } parent_name: { type: [string, "null"], example: null } SearchResponse: type: object required: [country, query, results] properties: country: { type: string, example: ke } query: { type: string, example: nakuru } results: type: array items: { $ref: "#/components/schemas/SearchResult" } VacantRole: type: object required: [code, name] properties: code: { type: string, example: mca } name: { type: string, example: MCA } CoverageGap: type: object required: [region_slug, region_name, vacant_roles] properties: region_slug: { type: string, example: kileleshwa } region_name: { type: string, example: Kileleshwa } vacant_roles: type: array items: { $ref: "#/components/schemas/VacantRole" } CoveragePage: type: object required: [level, items, total, vacant_seats, page, page_size] properties: level: { $ref: "#/components/schemas/RegionLevel" } items: type: array items: { $ref: "#/components/schemas/CoverageGap" } total: { type: integer, format: int64, example: 1450 } vacant_seats: { type: integer, format: int64, example: 12 } page: { type: integer, example: 1 } page_size: { type: integer, example: 24 } # ----- Country stats ------------------------------------------------------ StatsCycle: type: object required: [year, label] properties: year: { type: integer, example: 2022 } label: { type: string, example: 2022 General Election } StatsRegionCount: type: object required: [level, total] properties: level: { $ref: "#/components/schemas/RegionLevel" } total: { type: integer, format: int64, example: 1450 } StatsSeatStat: type: object required: [role, level, total, filled, vacant] properties: role: { $ref: "#/components/schemas/RefCodeName" } level: allOf: - $ref: "#/components/schemas/RegionLevel" description: The region level this role is elected at. total: type: integer format: int64 description: Seats for this role - one per region at the role's level. example: 290 filled: type: integer format: int64 description: Seats held by a named representative on an open term. example: 282 vacant: { type: integer, format: int64, example: 8 } StatsTotals: type: object description: Sums over `seats`; each field equals the sum of the matching column. required: [seats, filled, vacant] properties: seats: { type: integer, format: int64, example: 1881 } filled: { type: integer, format: int64, example: 423 } vacant: { type: integer, format: int64, example: 1458 } CountryStats: type: object required: [country, cycle, regions, seats, totals, generated_at] properties: country: type: string description: The country slug these stats are for. example: ke cycle: { $ref: "#/components/schemas/StatsCycle" } regions: type: array description: Region counts per level, ordered county -> constituency -> ward. items: { $ref: "#/components/schemas/StatsRegionCount" } seats: type: array description: > Every political role in the country, ordered by level (county -> constituency -> ward) then role code. A role whose level has no regions still appears, with a total of 0. items: { $ref: "#/components/schemas/StatsSeatStat" } totals: { $ref: "#/components/schemas/StatsTotals" } generated_at: type: string format: date-time description: > When the origin computed these numbers. With the 5-minute shared-cache ceiling, a cached response is at most ~5 minutes older than this. example: "2026-07-17T12:00:00Z" # ----- GeoJSON (sub-issue #76) ------------------------------------------- GeoJSONPosition: type: array description: A `[longitude, latitude]` position (GeoJSON axis order). items: { type: number, format: double } minItems: 2 maxItems: 3 example: [36.8219, -1.2921] GeoJSONPolygon: type: object required: [type, coordinates] properties: type: { const: Polygon } coordinates: type: array description: An array of linear rings (each a closed array of positions). items: type: array items: { $ref: "#/components/schemas/GeoJSONPosition" } GeoJSONMultiPolygon: type: object required: [type, coordinates] properties: type: { const: MultiPolygon } coordinates: type: array items: type: array items: type: array items: { $ref: "#/components/schemas/GeoJSONPosition" } RegionFeatureRepresentative: type: object description: > The region's current representative (or null when vacant). Shape is finalised in sub-issue #76. required: [full_name, slug] properties: full_name: { type: string, example: Robert Alai } slug: { type: string, example: robert-alai } role_code: { type: string, example: mca } role_name: { type: string, example: MCA } RegionFeatureProperties: type: object required: [name, slug, code, level, country] properties: name: { type: string, example: Kileleshwa } slug: { type: string, example: kileleshwa } code: { type: string, example: "0290" } level: { $ref: "#/components/schemas/RegionLevel" } country: { type: string, example: ke } current_representative: oneOf: - { $ref: "#/components/schemas/RegionFeatureRepresentative" } - { type: "null" } RegionFeature: type: object description: A single GeoJSON Feature for a region boundary (sub-issue #76). required: [type, geometry, properties] properties: type: { const: Feature } geometry: oneOf: - { $ref: "#/components/schemas/GeoJSONPolygon" } - { $ref: "#/components/schemas/GeoJSONMultiPolygon" } properties: { $ref: "#/components/schemas/RegionFeatureProperties" }