Votewise API v1 — Endpoint Specifications
Style: REST/JSON under /api/v1. Public reads are designed to be cache-friendly. A GraphQL BFF can be added later for the Studio dashboard, but public endpoints stay REST because CDN caching of GraphQL POSTs is impractical.
Common conventions
| Concern | Rule |
|---|---|
| Auth | Public endpoints: none. B2B: Authorization: Bearer <JWT> (15 min access token + rotating refresh) or X-Api-Key for server-to-server analytics |
| Geo codes | geo_state / state = ISO-style state code (LA, FC, KN, …). geo_lga / lga = Votewise LGA code (LA-IKJ) or INEC code (24-10); both are accepted |
| Pagination | Cursor-based: ?limit=20&cursor=<opaque> → { data, page: { nextCursor } } |
| Money | Integer kobo, e.g. "cpmKobo": 150000. Display strings are produced client-side |
| Errors | RFC 9457 application/problem+json { type, title, status, detail, errors? } |
| Rate limits | Redis sliding window per IP+route. Headers RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset |
| Versioning | URL major version, plus additive-only changes within v1 |
1. GET /api/v1/candidates/search
Searches candidacies (a person running for an office in an election) with geo- and office-aware filters. This endpoint backs the directory, "Who's on my ballot?" and the "Featured Candidate" boost.
Query parameters
| Param | Type | Req | Notes |
|---|---|---|---|
state | string | — | State code. Required when office ∈ {GOVERNOR, SENATOR, HOUSE_OF_REPS, HOUSE_OF_ASSEMBLY} unless lga, pu or constituency_id is given |
lga | string | — | Resolves to its senatorial district, federal constituency and state constituencies |
pu | string | — | INEC PU code SS-LL-WW-PPP. This is the most precise option: it returns exactly the user's ballot |
office | enum | — | PRESIDENT, GOVERNOR, SENATOR, HOUSE_OF_REPS, HOUSE_OF_ASSEMBLY. Comma-separated multi-select is allowed |
election | slug | — | Defaults to the next upcoming election that covers the geo |
party | string | — | Party acronym(s), comma-separated |
q | string | — | Name search (trigram, min 2 chars) |
status | enum | — | Default NOMINATED,CLEARED |
include | csv | — | manifesto, qualifications, party_history, running_mate |
sort | enum | — | ballot (default), name, party |
limit | int | — | 1–50, default 20 |
cursor | string | — |
Resolution logic
- If
puis given, look up its ward and LGA, then its state constituency, federal constituency, senatorial district and state. Then build the ballot set for each office in the election. - If only
lgais given, resolve the federal constituency and senatorial district. The state constituency is ambiguous when the LGA has several, so returnmeta.ambiguous: ["HOUSE_OF_ASSEMBLY"]with the options listed. - Promoted results: at most 1 per office group. They are pulled from the ad server (
slot_type=search_boost) using the same geo, and they must match the query filters. A boost never shows a candidate outside the race being searched. Promoted results are returned inpromoted[]and kept separate from organicdata[], so the client can badge them.
Response 200
{
"meta": {
"election": { "slug": "2027-general", "pollDate": "2027-02-20" },
"resolvedGeo": {
"state": { "code": "LA", "name": "Lagos" },
"senatorialDistrict": { "id": "24-SD-3", "name": "Lagos West" },
"federalConstituency": { "id": "24-FC-07", "name": "Ikeja" },
"lga": { "code": "LA-IKJ", "name": "Ikeja" },
"stateConstituencies": [{ "id": "24-SC-21", "name": "Ikeja I" }, { "id": "24-SC-22", "name": "Ikeja II" }]
},
"ambiguous": ["HOUSE_OF_ASSEMBLY"]
},
"promoted": [
{
"candidacyId": "ckx…",
"office": "HOUSE_OF_REPS",
"badge": "PROMOTED",
"disclosure": "Political Ad · Paid for by Friends of Jane Doe · ARCON PA/2027/00123",
"impressionToken": "eyJ0Ijoi….sig",
"candidate": { "…": "same shape as data[]" }
}
],
"data": [
{
"candidacyId": "ckx…",
"office": "HOUSE_OF_REPS",
"status": "CLEARED",
"isIncumbent": false,
"constituency": { "level": "FEDERAL_CONSTITUENCY", "id": "24-FC-07", "name": "Ikeja" },
"candidate": {
"id": "ckc…",
"slug": "jane-doe",
"fullName": "Jane Doe",
"age": 44,
"gender": "FEMALE",
"photoUrl": "https://cdn.votewise.ng/c/jane-doe_160.webp",
"verified": true,
"highestQualification": "MASTERS"
},
"party": { "acronym": "XYZ", "name": "Example Party", "logoUrl": "…" },
"manifesto": [{ "topic": "SECURITY", "summary": "…", "authoredBy": "CANDIDATE" }]
}
],
"page": { "nextCursor": null }
}
Caching and errors
Cache-Control: public, s-maxage=300, stale-while-revalidate=3600. The cache key covers all query params, normalised and sorted at the edge.promoted[]is not cached: the edge Worker stitches it in from a separate uncached call. Alternatively, the client fetches it lazily withinclude_promoted=falseon the cached call.400for an invalid office/geo combination (e.g.office=GOVERNORwithstate=FC, since the FCT has no governor).404for an unknown geo code.422when thelgadoes not belong to the givenstate.
2. GET /api/v1/ads/serve
Returns one ad decision for a slot. See 03-ad-engine.md for the algorithm.
Query parameters
| Param | Type | Req | Notes |
|---|---|---|---|
slot_type | string | ✔ | AdSlot.key, e.g. home_leaderboard, candidate_feed_native, search_boost, kyr_sticky, tool_pvc_checker |
geo_state | string | — | Falls back to the edge cf-region-code, then national |
geo_lga | string | — | Ignored if it does not belong to geo_state |
geo_ward | string | — | Optional; enables ward and state-constituency targeting |
geo_confidence | enum | — | declared · device · ip_state · none |
ctx_candidate | string | — | Candidate ID of the current page (for exclusions and profile takeover) |
page_url | string | — | For brand-safety logging only |
n | int | — | 1–3 for multi-slot feeds; there is no duplicate advertiser within one response |
Viewer identity comes from the first-party cookie vw_vid, a random ID used for frequency capping only. If there is no cookie, the server uses a daily-rotating salted hash of IP+UA. It is never persisted beyond 26 h.
Response 200
{
"decisionId": "d_01J…",
"slot": "candidate_feed_native",
"ads": [
{
"kind": "campaign",
"format": "NATIVE_FEED_CARD",
"campaignId": "cmp_…",
"creative": {
"id": "cr_…",
"headline": "Clean water for every Ikeja ward",
"body": "See Jane's 5-point plan.",
"image": { "url": "https://cdn.votewise.ng/a/cr_…_480.webp", "width": 480, "height": 270 },
"cta": { "label": "Read plan", "href": "https://votewise.ng/r/eyJ0Ijoi…sig" }
},
"badge": "PROMOTED",
"disclosure": "Political Ad · Paid for by Friends of Jane Doe · ARCON PA/2027/00123",
"beacons": {
"viewable": "/api/v1/ads/events?t=eyJ0Ijoi…sig&e=v",
"click": "/r/eyJ0Ijoi…sig"
}
}
]
}
Sponsorship variant:
{ "ads": [{ "kind": "sponsorship", "label": "PVC Checker sponsored by", "logo": { "url": "…" },
"cta": { "href": "/r/…" }, "badge": "SPONSORED" }] }
- When nothing is eligible, the response is
204 No Contentor a{ "kind": "house" }variant (?house=1). Cache-Control: private, no-store(because of frequency caps and budgets).- Election-day exception: Workers serve
ad-bundle:{slot}:{state}from KV withs-maxage=60. It contains sponsorships only, with no budgets involved.
- Election-day exception: Workers serve
400for an unknownslot_type.429when a viewer exceeds 120 requests per minute.- Latency SLO: p99 under 25 ms at the service and under 150 ms end-to-end. The client times out at 800 ms and renders the house fallback.
Companion: POST /api/v1/ads/events
Body: { "token": "…", "event": "viewable" | "click" }. The sendBeacon GET form is also accepted.
- Returns
202immediately. The event is verified (HMAC, nonceSET NX, token age under 30 min), then enqueued. - Duplicate or invalid tokens also get
202, because returning400would expose information to fraudsters. They are counted in fraud metrics.
3. GET /api/v1/analytics/sentiment-summary
This is the paid B2B endpoint. It returns aggregate topic interest and engagement for geographies the subscriber holds.
Auth and scopes
- Bearer JWT with scope
analytics:readand an activeAnalyticsSubscription. - Geographies requested must be within
subscription.scopeIds(and their descendants). Otherwise the response is403withtype: /problems/out-of-subscription-scope.
Query parameters
| Param | Type | Req | Notes |
|---|---|---|---|
level | enum | ✔ | STATE, SENATORIAL_DISTRICT, FEDERAL_CONSTITUENCY, STATE_CONSTITUENCY, LGA (the finest level; there is no ward/PU-level analytics) |
geo_id | csv | ✔ | One or more IDs at level, max 50 |
from, to | date | ✔ | ISO dates, max range 180 days, to ≤ yesterday |
granularity | enum | — | day, week (default), month |
topics | csv | — | PolicyTopic values; default all |
compare_to | enum | — | previous_period, state_avg, national_avg |
breakdown | enum | — | none (default), child_geo (e.g. a state broken down into LGAs) |
Response 200
{
"meta": {
"level": "FEDERAL_CONSTITUENCY",
"geo": [{ "id": "24-FC-07", "name": "Ikeja" }],
"period": { "from": "2026-08-01", "to": "2026-10-14", "granularity": "week" },
"privacy": {
"kAnonymityThreshold": 50,
"suppressedCells": 3,
"method": "Aggregated session counts; cells below threshold suppressed; no individual-level data.",
"basis": "NDPA 2023 — aggregated statistical data"
},
"generatedAt": "2026-10-15T06:00:00+01:00"
},
"summary": {
"sessions": 48210,
"topConcerns": [
{ "topic": "SECURITY", "share": 0.27, "changeVsPrev": 0.04, "toneIndex": -38 },
{ "topic": "FUEL_AND_ENERGY", "share": 0.21, "changeVsPrev": -0.02, "toneIndex": -51 },
{ "topic": "YOUTH_EMPLOYMENT", "share": 0.16, "changeVsPrev": 0.03, "toneIndex": -12 }
],
"trendingSearches": [
{ "term": "pvc collection centre", "growth": 3.4 },
{ "term": "ikeja rep candidates", "growth": 2.1 }
],
"candidateInterest": [
{ "candidacyId": "ckx…", "name": "Jane Doe", "office": "HOUSE_OF_REPS", "profileViewsShare": 0.41 }
]
},
"series": [
{ "period": "2026-W32", "geoId": "24-FC-07", "sessions": 3920,
"topics": { "SECURITY": 1041, "FUEL_AND_ENERGY": 806, "EDUCATION": null } }
],
"heatmap": {
"level": "LGA",
"cells": [{ "geoId": "LA-IKJ", "sessionsIndex": 100 }, { "geoId": "LA-AGG", "sessionsIndex": null, "suppressed": true }]
}
}
Rules
- Suppression. A cell with
sessions < 50returnsnulland is counted insuppressedCells. To block differencing attacks, if exactly one child cell would be suppressed, a second cell is suppressed as well (complementary suppression). - What
toneIndexmeans: it is aggregated tone of the topic content people engaged with (editorially classified articles and explainers), on a scale from −100 to 100. It is not an inference about any individual's political opinion. The API documentation and UI must label it that way. trendingSearchesonly includes terms seen in at least 50 sessions, with personal names removed unless they belong to registered candidates. Free text containing phone numbers or emails is dropped at ingestion.Cache-Control: private, max-age=900. Data refreshes daily at 06:00 WAT; withgranularity=day, today's partial data is excluded.- Every call is audited (
AuditLog: analytics.query) and counted against the plan quota (e.g. 10k calls/month).429withRetry-Afterwhen the quota is exceeded. - Export:
Accept: text/csvis supported on the same endpoint.
4. Supporting endpoints (summary)
| Method & path | Purpose | Auth |
|---|---|---|
GET /geo/states, /geo/states/{code}/lgas, /geo/lgas/{code}/wards, /geo/wards/{id}/polling-units | Cascading locator selects (static, cached 24 h) | public |
GET /geo/polling-units/nearest?lat=&lng=&limit=5 | PostGIS KNN (ORDER BY location <-> point) | public |
GET /geo/polling-units/search?q= | Trigram PU name/landmark search | public |
GET /candidates/{slug} · GET /candidacies/{id}/compare?with= | Profile and manifesto comparison | public |
GET /elections/{slug}/tracker?level=&geo_id= | Live tallies snapshot (s-maxage=15) | public |
POST /elections/{slug}/reports | Parallel report (Turnstile + rate limit) | citizen / anon |
POST /elections/{slug}/ec8a | EC8A upload (presigned URL flow) | citizen |
GET /news?state=&candidate= | Fact-checked feed | public |
POST /claims · GET /claims/{id} | Candidate profile claim / KYC | user |
POST /studio/campaigns · PATCH /studio/campaigns/{id} · POST /studio/campaigns/{id}/submit | Ad booking | advertiser |
GET /studio/campaigns/{id}/stats?from=&to=&by=lga | Delivery reporting (hourly rollups) | advertiser |
GET /studio/rate-card?slot=&level=&geo_id=&from=&to= | Published floor prices and available sponsorship windows | public |
POST /admin/campaigns/{id}/arcon-approve | Records the ARCON approval number and certificate | compliance |
GET /ad-library?advertiser=&state= | Public political ad transparency archive | public |