Election 2027
← All documents

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

ConcernRule
AuthPublic endpoints: none. B2B: Authorization: Bearer <JWT> (15 min access token + rotating refresh) or X-Api-Key for server-to-server analytics
Geo codesgeo_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
PaginationCursor-based: ?limit=20&cursor=<opaque> → { data, page: { nextCursor } }
MoneyInteger kobo, e.g. "cpmKobo": 150000. Display strings are produced client-side
ErrorsRFC 9457 application/problem+json { type, title, status, detail, errors? }
Rate limitsRedis sliding window per IP+route. Headers RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
VersioningURL 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

ParamTypeReqNotes
statestring—State code. Required when office ∈ {GOVERNOR, SENATOR, HOUSE_OF_REPS, HOUSE_OF_ASSEMBLY} unless lga, pu or constituency_id is given
lgastring—Resolves to its senatorial district, federal constituency and state constituencies
pustring—INEC PU code SS-LL-WW-PPP. This is the most precise option: it returns exactly the user's ballot
officeenum—PRESIDENT, GOVERNOR, SENATOR, HOUSE_OF_REPS, HOUSE_OF_ASSEMBLY. Comma-separated multi-select is allowed
electionslug—Defaults to the next upcoming election that covers the geo
partystring—Party acronym(s), comma-separated
qstring—Name search (trigram, min 2 chars)
statusenum—Default NOMINATED,CLEARED
includecsv—manifesto, qualifications, party_history, running_mate
sortenum—ballot (default), name, party
limitint—1–50, default 20
cursorstring—

Resolution logic

  1. If pu is 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.
  2. If only lga is given, resolve the federal constituency and senatorial district. The state constituency is ambiguous when the LGA has several, so return meta.ambiguous: ["HOUSE_OF_ASSEMBLY"] with the options listed.
  3. 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 in promoted[] and kept separate from organic data[], 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 with include_promoted=false on the cached call.
  • 400 for an invalid office/geo combination (e.g. office=GOVERNOR with state=FC, since the FCT has no governor). 404 for an unknown geo code. 422 when the lga does not belong to the given state.

2. GET /api/v1/ads/serve

Returns one ad decision for a slot. See 03-ad-engine.md for the algorithm.

Query parameters

ParamTypeReqNotes
slot_typestring✔AdSlot.key, e.g. home_leaderboard, candidate_feed_native, search_boost, kyr_sticky, tool_pvc_checker
geo_statestring—Falls back to the edge cf-region-code, then national
geo_lgastring—Ignored if it does not belong to geo_state
geo_wardstring—Optional; enables ward and state-constituency targeting
geo_confidenceenum—declared · device · ip_state · none
ctx_candidatestring—Candidate ID of the current page (for exclusions and profile takeover)
page_urlstring—For brand-safety logging only
nint—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 Content or 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 with s-maxage=60. It contains sponsorships only, with no budgets involved.
  • 400 for an unknown slot_type. 429 when 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 202 immediately. The event is verified (HMAC, nonce SET NX, token age under 30 min), then enqueued.
  • Duplicate or invalid tokens also get 202, because returning 400 would 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:read and an active AnalyticsSubscription.
  • Geographies requested must be within subscription.scopeIds (and their descendants). Otherwise the response is 403 with type: /problems/out-of-subscription-scope.

Query parameters

ParamTypeReqNotes
levelenum✔STATE, SENATORIAL_DISTRICT, FEDERAL_CONSTITUENCY, STATE_CONSTITUENCY, LGA (the finest level; there is no ward/PU-level analytics)
geo_idcsv✔One or more IDs at level, max 50
from, todate✔ISO dates, max range 180 days, to ≤ yesterday
granularityenum—day, week (default), month
topicscsv—PolicyTopic values; default all
compare_toenum—previous_period, state_avg, national_avg
breakdownenum—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 < 50 returns null and is counted in suppressedCells. To block differencing attacks, if exactly one child cell would be suppressed, a second cell is suppressed as well (complementary suppression).
  • What toneIndex means: 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.
  • trendingSearches only 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; with granularity=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). 429 with Retry-After when the quota is exceeded.
  • Export: Accept: text/csv is supported on the same endpoint.

4. Supporting endpoints (summary)

Method & pathPurposeAuth
GET /geo/states, /geo/states/{code}/lgas, /geo/lgas/{code}/wards, /geo/wards/{id}/polling-unitsCascading locator selects (static, cached 24 h)public
GET /geo/polling-units/nearest?lat=&lng=&limit=5PostGIS KNN (ORDER BY location <-> point)public
GET /geo/polling-units/search?q=Trigram PU name/landmark searchpublic
GET /candidates/{slug} · GET /candidacies/{id}/compare?with=Profile and manifesto comparisonpublic
GET /elections/{slug}/tracker?level=&geo_id=Live tallies snapshot (s-maxage=15)public
POST /elections/{slug}/reportsParallel report (Turnstile + rate limit)citizen / anon
POST /elections/{slug}/ec8aEC8A upload (presigned URL flow)citizen
GET /news?state=&candidate=Fact-checked feedpublic
POST /claims · GET /claims/{id}Candidate profile claim / KYCuser
POST /studio/campaigns · PATCH /studio/campaigns/{id} · POST /studio/campaigns/{id}/submitAd bookingadvertiser
GET /studio/campaigns/{id}/stats?from=&to=&by=lgaDelivery reporting (hourly rollups)advertiser
GET /studio/rate-card?slot=&level=&geo_id=&from=&to=Published floor prices and available sponsorship windowspublic
POST /admin/campaigns/{id}/arcon-approveRecords the ARCON approval number and certificatecompliance
GET /ad-library?advertiser=&state=Public political ad transparency archivepublic