API

The data API

Tamil Nadu's top three issues of the day as JSON: the counted split, grouped voices, checked facts, the research with every source, and the AI's tracked stance. Searchable by meaning, and readable as it stood on any day.

Only published issues appear, the same ones as on this site. It is free, with fair limits.

Quickstart

1. Create an account and make a key on the Keys page. 2. Call the API with it:

shell
curl http://politic.in/v1/issues?limit=1 \
  -H "authorization: Bearer ptn_your_key"
javascript
const res = await fetch("http://politic.in/v1/issues?limit=1", {
  headers: { authorization: `Bearer ${process.env.POLITIC_KEY}` },
});
const { issues } = await res.json();
const voices = await fetch(`http://politic.in/v1/issues/${issues[0].id}/voices`, {
  headers: { authorization: `Bearer ${process.env.POLITIC_KEY}` },
}).then((r) => r.json());
python
import os, requests
h = {"authorization": f"Bearer {os.environ['POLITIC_KEY']}"}
issue = requests.get("http://politic.in/v1/issues", params={"limit": 1}, headers=h).json()["issues"][0]
take = requests.get(f"http://politic.in/v1/issues/{issue['id']}/take", headers=h).json()

Keys

Send your key as a bearer token: authorization: Bearer ptn_…. A key is shown once when you make it; we keep only its hash. Make one key per app so you can revoke one without touching the others. An account can have up to 10 keys.

Limits

Everything is free. To keep it that way for everyone, requests are limited:

Per key60 requests a minute
Per account10,000 requests a day across all its keys (India time; a key made without an account has its own)
Search20 searches a minute per key, on top of the above
The website120 pages and 20 searches a minute per address; no key needed

Every response carries x-ratelimit-limit, x-ratelimit-remaining (both for the day) and x-ratelimit-reset (when the day's allowance starts again, in Unix seconds). Past a limit you get HTTP 429 with retry-after. Need more? Ask for a higher daily limit from the Limits page of your dashboard.

Going back in time

Nothing published is ever overwritten. A correction or an update to an issue is a new version, and every fact, voice, finding, take and stance is kept with the moment it became current and the moment it was replaced. So every read endpoint takes as_of, and answers with what was published and current then: as_of=2026-10-20 is the end of 20 October in India, and an ISO timestamp works too.

A snapshot is a day: /v1/snapshots lists them, /v1/snapshots/:date gives that day's issues, every position current then and the day's trends, and its as_of searches that snapshot in /v1/search.

shell
# What did it know about the tariff on 20 October, and what does it know now?
curl "http://politic.in/v1/search?q=electricity+tariff&as_of=2026-10-20" -H "authorization: Bearer ptn_your_key"
curl "http://politic.in/v1/search?q=electricity+tariff" -H "authorization: Bearer ptn_your_key"

Errors

401No key, or the key is wrong or revoked. { "error": "invalid API key" }
404No published issue with that id, or that part of it is not ready.
400A parameter is wrong; the body says which. { "error": "as_of must be a date (YYYY-MM-DD, India time) or an ISO timestamp" }
429Too many requests in a minute, or the day's limit is used up. retry-after says when to try again.
500Our fault. Try again; the body carries a request_id to quote if it keeps happening.
GET/v1/issues

Published issues, newest day first and by rank within a day (up to three a day).

limit1 to 100, default 20
dateYYYY-MM-DD: one day's issues
beforeYYYY-MM-DD: only issues before this date, for paging back
as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "as_of": "2026-10-25T10:00:00.000Z",
  "issues": [ { "id": "iss_…", "date": "2026-10-25", "rank": 1, "day": 12, "version": 1,
    "published_at": "…", "current_until": null, "title": { "en": "…", "ta": "…" },
    "question": "…", "topic": "Electricity tariff", "consistency": "refined" } ],
  "more": false }
GET/v1/issues/:id

One issue as it stood at a moment (or ?version=), with its reel once posted.

as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
versiona version number from /versions, instead of as_of
response
{ "id": "iss_…", "date": "2026-10-25", "rank": 1, "version": 2, "title": { "en": "…", "ta": "…" },
  "summary": { "en": "…" }, "trend": "…", "keywords": ["…"],
  "reel": { "instagram": "https://www.instagram.com/reel/…", "duration": 58.2 } }
GET/v1/issues/:id/versions

Every published version of an issue: a correction or an update is a new version, and the old one stays readable.

response
{ "issue_id": "iss_…", "versions": [ { "version": 2, "published_at": "…", "until": null, "items": 512 },
  { "version": 1, "published_at": "…", "until": "…", "items": 498 } ] }
GET/v1/issues/:id/voices

How different kinds of people are talking: the counted answer to the core question, grouped voices with mood and share, counts by speaker, camp and emotion, and the public figures who spoke, with links.

as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "opinion": { "question_en": "…", "n": 412, "for": 31, "against": 52, "mixed": 9, "unclear": 8 },
  "summary": { "mood": { "en": "…", "ta": "…" }, "undercurrent": "…", "quiet": "…",
    "clusters": [ { "label_en": "…", "label_ta": "…", "share": 38, "emotion": "angry",
      "position": "against", "says_en": "…", "says_ta": "…" } ] },
  "counts": [ { "speaker": "ordinary_public", "camp": "DMK", "emotion": "angry", "position": "against", "n": 57 } ],
  "public_figures": [ { "handle": "…", "speaker": "politician", "argument_en": "…", "post": "https://x.com/…" } ] }
GET/v1/issues/:id/take

The AI's take, its reasons, what would change it, and the objections each side would raise; with how it stands against the AI's earlier position.

as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "take": { "take_en": "…", "take_ta": "…", "reasons_en": ["…"], "would_change_en": "…",
    "confidence": 0.7, "objections": [ { "camp": "…", "objection_en": "…" } ] },
  "position": { "id": "pos_…", "topic": "…", "consistency": "refined", "change_reason": "…", "previous_position_id": "pos_…" },
  "note": "An AI model's take, reviewed for fairness; not an endorsement." }
GET/v1/issues/:id/context

What happened in plain sentences, the claims checked against sources, and the unproven ones going round.

as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "what_happened_en": "…", "what_happened_ta": "…", "question_en": "…",
  "verified": [ { "claim_en": "…", "source_url": "https://…" } ],
  "disputed": [ { "claim_en": "…", "why_en": "…" } ] }
GET/v1/issues/:id/research

The deep research behind the issue: a summary, the timeline, findings with their confidence, past positions, the numbers and a verdict on each claim. Every item carries the source it was read in; sources that stopped answering are dropped.

as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "summary_en": "…", "timeline": [ { "date": "2026-10-20", "event_en": "…", "source_url": "https://…" } ],
  "findings": [ { "angle": "history", "claim_en": "…", "evidence_en": "…", "source_url": "…",
    "source_name": "…", "published": "2026-10-21", "confidence": "confirmed" } ],
  "claims_checked": [ { "claim_en": "…", "verdict": "misleading", "why_en": "…", "source_url": "…" } ] }
GET/v1/snapshots

The days you can go back to, newest first, each with how many issues it had and its study day.

limit1 to 365, default 30
beforeYYYY-MM-DD, for paging back
as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "snapshots": [ { "date": "2026-10-25", "issues": 3, "day": 12, "as_of": "2026-10-25T18:29:59.999Z", "url": "/v1/snapshots/2026-10-25" } ] }
GET/v1/snapshots/:date

Everything as it stood at the end of a day in India: that day's issues, the AI's latest position on every topic then, the day's X trends, and the size of the record. Its as_of works in every other endpoint.

response
{ "date": "2026-10-20", "as_of": "2026-10-20T18:29:59.999Z",
  "issues": [ { "id": "iss_…", "rank": 1, "title": { "en": "…" }, … } ],
  "stances": [ { "topic": "NEET exemption", "issue_id": "iss_…", "date": "2026-10-18", "stance": { "en": "…" }, "consistency": "consistent" } ],
  "trends": [ { "name": "#…", "query": "…", "best_rank": 2, "max_posts": 18400, "sightings": 12 } ],
  "totals": { "issues": 33, "days": 11, "voices": 5120, "stances": 33, "items": 6812 } }

Semantic search across everything published (one of: issue, fact, disputed, opinion, mood, voice_cluster, voice, summary, event, finding, past_position, number, claim_check, open_question, take, stance), in Tamil or English, as of a moment. Results carry a cosine score, the issue and version they belong to, and when they were valid. Searches have their own limit of 20 a minute.

qthe question, up to 500 characters
kindcomma separated kinds, e.g. finding,claim_check
issuekeep one issue
limit1 to 50, default 10
as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "q": "who opposed the tariff in 2022", "as_of": "…",
  "results": [ { "id": "kn_…", "kind": "past_position", "text": "DMK, 2022: …", "data": { "who": "DMK", "when": "2022" },
    "source_url": "https://…", "issue": { "id": "iss_…", "version": 1, "date": "2026-10-20", "title": { "en": "…" } },
    "valid_from": "…", "valid_to": null, "score": 0.7123 } ] }
GET/v1/knowledge

The same store as rows, filtered and paged, newest first: every fact, voice, finding, take and stance valid at a moment.

kindcomma separated kinds
issueone issue's rows
datethe issues of one day
topicstances on one topic
limit1 to 200, default 50
cursornext from the page before
as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "items": [ { "id": "kn_…", "kind": "claim_check", "text": "…", "data": { "verdict": "false", … }, … } ], "next": "kn_…" }
GET/v1/stances

The AI's position on every published issue, as of a moment, and whether each one held, sharpened, moved or reversed an earlier one.

topicfollow one underlying question, e.g. NEET exemption
limit1 to 200, default 50
as_ofread it as it stood at a moment: a date (YYYY-MM-DD, the end of that day in India) or an ISO timestamp; now by default
response
{ "topics": [ { "topic": "…", "n": 3 } ],
  "stances": [ { "id": "pos_…", "issue_id": "iss_…", "date": "2026-10-25", "topic": "…",
    "stance": { "en": "…", "ta": "…" }, "confidence": 0.7, "consistency": "consistent",
    "previous_position_id": "pos_…", "change_reason": null } ] }

What X showed as trending for Tamil Nadu on a day, most persistent first: how often and how high each trend showed, and its post count.

dateYYYY-MM-DD, today by default
limit1 to 100, default 30
response
{ "date": "2026-10-25", "trends": [ { "name": "#…", "query": "…", "context": "Trending in India", "best_rank": 1, "max_posts": 18400, "sightings": 14, "first_seen": "…", "last_seen": "…" } ] }
GET/v1/trends/history

One trend across days.

namethe trend's name, any case
fromYYYY-MM-DD, 30 days before to by default
toYYYY-MM-DD, today by default
response
{ "name": "#…", "from": "2026-09-25", "to": "2026-10-25", "days": [ { "date": "2026-10-24", "sightings": 9, "best_rank": 3, … } ] }
GET/v1/usage

Your key's requests by day and endpoint, and your limits.

response
{ "usage": [ { "day": "2026-10-25", "route": "/v1/issues", "count": 12 } ], "daily_limit": 10000, "per_minute": 60, "search_per_minute": 20 }

Webhooks

Add an endpoint on the Webhooks page and we POST to it the moment an issue is published (issue.published) and when a later version of it is (issue.updated), so you don't have to poll. Each request carries politic-event and a signature you should check before trusting the body.

payload
{
  "event": "issue.published",
  "at": "2026-10-25T13:05:11.000Z",
  "issue": {
    "id": "iss_…", "date": "2026-10-25", "rank": 1, "version": 1, "published_at": "…",
    "title": { "en": "…", "ta": "…" }, "summary": { "en": "…" }, "question": "…", "trend": "…",
    "url": "http://politic.in/issues/iss_…", "api": "http://politic.in/v1/issues/iss_…",
    "reel": { "instagram": "https://www.instagram.com/reel/…", "video": "http://politic.in/media/video/…/video.mp4" }
  }
}

Checking the signature

The header is politic-signature: t=1761397511,v1=5f1c…: the HMAC-SHA256 of <t>.<raw body> with your endpoint's secret, in hex. Compare in constant time and refuse a t more than five minutes old.

node
import crypto from "node:crypto";

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const want = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1 ?? ""));
}

Answer with any 2xx within ten seconds. A delivery that fails is logged on your Webhooks page and not retried; the issue is always in the API.

Using the data

The take is an AI model's reading, reviewed for fairness: label it as that, not as fact or endorsement. Post text from X is never served: voices are paraphrased, ordinary users appear only as counts, and only public figures are named, with a link to their post. Credit politic.tamilnadu and link the issue when you republish.