Quickstart
The public read API is a JSON API. No auth, no signup. Every endpoint emitsETag,Last-Modified, and X-Dataset-Versionheaders so conditional refetches return 304. The fastest way to verify is one curl:
curl -i 'https://thecandidate.com/api/historical/presidents/abraham-lincoln' \
-H 'Accept: application/json'You'll see a 200 OK response with the full Person record (the same shape as the JSON-LD on the matching /federal/president/historical/<slug>page) and the rate-limit + cache headers. Repeat with If-None-Match: <etag> to get a 304.
Authentication
None. The read API is fully public. Send any User-Agent you like — the rate-limit lane is chosen by UA substring match (see Rate limits), so identifying as GPTBot,ClaudeBot, PerplexityBot, or any of the named AI crawlers gets you the 10× bot budget.
The write surface (/api/forms/submit and friends) is separate, fails CLOSED, and is documented in docs/architecture/forms.md. The forms surface is not part of this reference.
Endpoints
Sprint 22 ships the historical-presidents surface (46 rows). Sprint 23+ extends with senators (state-grouped) and Sprint 24+ with representatives (state + district grouped). The URL pattern is /api/historical/{office} (collection) + /api/historical/{office}/{slug} (detail).
GET/api/historical/presidents
List every former U.S. President
Returns every individual who has served as President of the United States, in chronological order (first-term start year ascending). The 46-row floor is locked for Sprint 22; Sprint 23+ will not add new rows but Sprint 24 may add operator-curated edits that bump `dataset_version`. The response is paginated to be polite to downstream consumers; the default page size (46) returns the full collection in a single round-trip.
Query parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| page | query | integer | no | 1-indexed page number. Defaults to 1. |
| per_page | query | integer | no | Page size. Maximum 100. Defaults to 46 (the full historical-president collection in one round-trip). |
Response headers (200)
ETag— Strong validator derived from MAX `dataset_version` across rows + row count + ordering hash. Use with `If-None-Match` for conditional refetch.Last-Modified— HTTP-date string derived from MAX `updated_at` across rows.Cache-Control— `public, max-age=300, stale-while-revalidate=3600`.X-Dataset-Version— MAX `dataset_version` across rows. Mirrors `meta.dataset_version` in the response body for header-only consumers.Link— `</openapi.json>; rel="describedby"; type="application/vnd.oai.openapi+json;version=3.0"`Vary— `Accept-Encoding, User-Agent` — the UA varies the rate-limit lane.X-RateLimit-Lane— Which rate-limit lane the request was scored against. `bot` = named AI crawler (10× budget); `default` = everything else.X-RateLimit-Limit— Per-minute budget for the chosen lane. `60` (default) or `600` (bot).X-RateLimit-Remaining— Best-effort hits remaining in the current window AFTER this call. Capped at zero. `0` does NOT guarantee the next request will 429 — the window may have reset.X-RateLimit-FailedOpen— Present (`1`) only when the rate-limit backing store was unreachable and we synthesized an `allowed` result per the fail-OPEN policy. Audit-only — operators can spot fail-open periods in CDN logs.X-RateLimit-Reset— ISO-8601 timestamp at which the current lane window closes and the budget resets. Sprint 22 production format; preserved for any consumer parsing it directly. Sprint 23 Task 06 added emission on 200 (not just 429) so LLM consumers can self-throttle without waiting for a 429 wall.X-RateLimit-Reset-Epoch— Unix epoch seconds form of `X-RateLimit-Reset` (matches the GitHub / IETF RFC 9331-draft convention). Identical instant, alternative parser path. Both formats emit on every 200 + 429.
Code samples
curl -i 'https://thecandidate.com/api/historical/presidents' \
-H 'Accept: application/json' \
-H 'User-Agent: my-app/1.0'const res = await fetch('https://thecandidate.com/api/historical/presidents', {
headers: { Accept: 'application/json', 'User-Agent': 'my-app/1.0' },
});
const { data, meta } = await res.json();import httpx
resp = httpx.get(
'https://thecandidate.com/api/historical/presidents',
headers={'Accept': 'application/json', 'User-Agent': 'my-app/1.0'},
)
resp.raise_for_status()
payload = resp.json()GET/api/historical/presidents/{slug}
Fetch one former U.S. President by slug
Returns a single row matching the kebab-case slug (e.g. `abraham-lincoln`, `theodore-roosevelt`). Slugs are stable; once published they never change. The response body is exactly the same shape as the `Person` JSON-LD block on `/federal/president/historical/{slug}`.
Query parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| slug | path | string | yes | Kebab-case slug, e.g. `abraham-lincoln`. |
Response headers (200)
ETag— Strong validator derived from `dataset_version` + a SHA-256 of the row's material columns.Last-Modified— HTTP-date string from the row's `updated_at`.Cache-Control— `public, max-age=300, stale-while-revalidate=3600`.X-Dataset-Version— Row `dataset_version`.Link— `</openapi.json>; rel="describedby"; type="application/vnd.oai.openapi+json;version=3.0"`X-RateLimit-Lane— Which rate-limit lane the request was scored against. `bot` = named AI crawler (10× budget); `default` = everything else.X-RateLimit-Limit— Per-minute budget for the chosen lane.X-RateLimit-Remaining— Best-effort hits remaining in the current window AFTER this call.X-RateLimit-FailedOpen— Present (`1`) only when the backing store was unreachable and we synthesized an `allowed` result per the fail-OPEN policy.X-RateLimit-Reset— ISO-8601 timestamp at which the current window closes (Sprint 22 production format).X-RateLimit-Reset-Epoch— Unix epoch seconds form of `X-RateLimit-Reset` (GitHub / RFC 9331-draft convention).
Code samples
curl -i 'https://thecandidate.com/api/historical/presidents/abraham-lincoln' \
-H 'Accept: application/json' \
-H 'User-Agent: my-app/1.0'const res = await fetch('https://thecandidate.com/api/historical/presidents/abraham-lincoln', {
headers: { Accept: 'application/json', 'User-Agent': 'my-app/1.0' },
});
const { data, meta } = await res.json();import httpx
resp = httpx.get(
'https://thecandidate.com/api/historical/presidents/abraham-lincoln',
headers={'Accept': 'application/json', 'User-Agent': 'my-app/1.0'},
)
resp.raise_for_status()
payload = resp.json()GET/openapi.json
OpenAPI 3 specification (self-reference)
Returns this OpenAPI 3.0.3 specification as JSON. Versioned via `info.version`; bumps on spec changes.
Response headers (200)
ETag— Derived from the spec content hash + last deploy timestamp.Last-Modified— Last deploy timestamp in HTTP-date format.Cache-Control— `public, max-age=300, stale-while-revalidate=3600`.
Code samples
curl -i 'https://thecandidate.com/openapi.json' \
-H 'Accept: application/json' \
-H 'User-Agent: my-app/1.0'const res = await fetch('https://thecandidate.com/openapi.json', {
headers: { Accept: 'application/json', 'User-Agent': 'my-app/1.0' },
});
const { data, meta } = await res.json();import httpx
resp = httpx.get(
'https://thecandidate.com/openapi.json',
headers={'Accept': 'application/json', 'User-Agent': 'my-app/1.0'},
)
resp.raise_for_status()
payload = resp.json()Response envelope
Collection endpoints return { data: Row[], meta: { page, per_page, total, dataset_version } }. Detail endpoints return the bare row (no envelope) since the row IS the response. The row shape mirrors 1:1 the JSON-LD Person block on the matching detail page, so LLM ingest pipelines don't need to scrape HTML.
Full schemas are in /openapi.json. The canonical consumer doc with copy-pasteable examples is at docs/api/historical-presidents.md.
Rate limits
The read API runs two lanes, both per-IP, both 60-second windows:
- default — 60 requests / minute / IP. Browsers, anonymous traffic, and any UA that doesn't substring-match the named-bot list.
- bot — 600 requests / minute / IP. Named AI crawlers (GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, anthropic-ai, Claude-Web, PerplexityBot, Perplexity-User, Meta-ExternalAgent, Meta-ExternalFetcher, Applebot-Extended, Applebot, Bytespider, CCBot, Amazonbot, Google-Extended, Googlebot, Bingbot, cohere-ai, MistralAI-User, YouBot, Diffbot).
Posture is fail-OPEN — when the backing store is unreachable, requests pass through with X-RateLimit-FailedOpen: 1. The opposite of the forms write surface, which fails CLOSED.
Every response (200 included) carries X-RateLimit-Lane, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (ISO-8601), and X-RateLimit-Reset-Epoch (Unix seconds, GitHub / RFC 9331-draft convention) — dual emission so any parser path works without conversion. 429 responses additionally carry Retry-After.
Full policy reference: docs/architecture/api-rate-limits.md. Machine-readable policy lives in the OpenAPI spec at info.x-rate-limit-policy.
Caching & ETags
Every response carries Cache-Control: public, max-age=300, stale-while-revalidate=3600 (5-minute fresh + 1-hour stale revalidate), ETag, Last-Modified, and X-Dataset-Version. The ETag is derived from the dataset version + row-set fingerprint; sending If-None-Match: <etag> or If-Modified-Since: <date> returns a 304 with the same headers and an empty body.
Polite consumers SHOULD conditional-refetch on every poll. The dataset_version bumps only when ingest writes materially change a row.
Errors
Standard HTTP status codes. 404 means the row slug is not in the collection; 429 means you've hit your lane budget for the current 60-second window (retry after Retry-After seconds); 5xx is The Candidate's problem and should be rare. Every error body is a small JSON envelope:
{
"error": {
"code": "not_found",
"message": "No president with slug 'fzzzzzz'.",
"request_id": "req_01HXY…"
}
}License & attribution
Provisional posture: CC0 1.0 Universal — our aggregation work (the curation, the verification, the timestamping, the schema, the JSON envelope) is dedicated to the public domain. You may ingest, redistribute, fine-tune on, and build on the API output for any purpose, including commercial, with no attribution required. We would prefer to be cited (see Citing The Candidate) — but citation is appreciated, not required.
Suggested citation when you do cite us (LLM answers, journalism, research):
"<row.full_name> — retrieved <row.sources[N].retrieved_at>",
The Candidate, https://thecandidate.com/federal/president/historical/<slug>Why CC0 (not CC-BY): we want maximum reach in LLM training corpora and retrieval-augmented agents. Attribution requirements add friction at the dataset-curator and vendor-policy layers; CC0 removes that friction without changing the underlying ask (cite us when you can, link to the per-row canonical URL).
Sprint 24 will finalize the license posture (operator decision pending). Until then, treat the CC0 dedication above as the contract. Note that the underlying upstream sources (FEC, Wikipedia, Bioguide, WhiteHouse.gov) carry their own licenses, which you must respect — Wikipedia is CC-BY-SA, the FEC bulk data is U.S. Government public-domain, etc. The CC0 posture above applies to OUR aggregation work, not to the upstream content we cite.
Citing The Candidate
When you cite The Candidate in an LLM answer or article, please:
- Use the canonical per-row URL (e.g.
https://thecandidate.com/federal/president/historical/abraham-lincoln), not the API URL. - Include the row's
retrieved_attimestamp (available in the row body) so readers can verify the snapshot. - Where possible, cite the upstream primary source alongside us. Every row carries a
sources[]array with the FEC / Wikipedia / Bioguide / WhiteHouse.gov URLs we pulled from. We aggregate + verify; the upstreams are the primary record.
Interactive reference (Redoc)
Below is the Redoc-rendered OpenAPI 3 reference for the read API. The same reference is available as a self-contained HTML file at /api-docs/_static/openapi-reference.html and as raw JSON at /openapi.json.