Data · API
Data API
Statements, officials, bills, votes, surveys and violence as JSON
How the data API works
JSON over HTTPS, free with an account
Ask for statements, officials, bills, votes, survey results or violence events and get JSON back. Every response with data carries the license (CC BY 4.0) and the citation.
What it’s for
The lab’s data, one request at a time: classified statements, officials and their profiles, bills, roll-call votes, survey aggregates and political violence events.
Use the API to pull a slice (one official’s votes, a phrase in Democrats’ press releases since January, the bills of one state) or to keep a project up to date. For a whole dataset the bulk files are faster: one download each, CSV and Parquet in a zip. Every address starts with https://api.americaspoliticalpulse.com/v1, and the API only reads: every request is a GET.
| Path | What |
|---|---|
| /v1/statements | Classified statements: tweets, press releases, newsletters, floor speeches (members of Congress, state legislators, governors). Tweets carry tweet_id and an empty text. |
| /v1/officials | Officials: members of Congress, state legislators, governors. |
| /v1/bills | Federal and state bills with sponsors, stage and policy area. |
| /v1/votes | Roll-call votes with party splits. |
| /v1/statements/{id} | One statement |
| /v1/officials/{id} | An official's full profile (rhetoric, policy, ideology, money, votes, legislation) |
| /v1/officials/{id}/votes | An official's roll-call votes, newest first |
| /v1/bills/{bill_id}/similar | The same proposal in other states (similarity 0.87 or more) |
| /v1/surveys/state-estimates | State opinion estimates (MRP) |
| /v1/surveys/toplines | Survey topline reports |
| /v1/violence/events | Political violence events |
| /v1/bulk | Bulk files (CSV and Parquet zips) |
| /v1/bulk/{name} | Download a bulk file (302 to a five-minute link; counts toward the daily downloads) |
| /v1/usage | Your limits and use |
Notes
Account and key
A free account gets you one key. Create an account to get a key.
Sign up with your email address, your name and institution, your sector and a line on what you plan to do with the data. There is no password: each time you sign in, we email you a link.
On your account page, make a key. It starts with pulse_ and is shown once, in full, so copy it then; we keep only a fingerprint of it. Making a new key replaces the old one, which stops working within a minute, and so does a key you revoke. Limits and counts belong to the account, not the key, so a new key starts with what you have already used today.
Authentication
Send your key in a header with every request, in either of two forms.
A key in the address (?api_key= or ?key=) is refused with 400. Only https://api.americaspoliticalpulse.com/v1 itself and the OpenAPI file answer without a key.
Authorization: Bearer pulse_…
X-API-Key: pulse_…Your first request
One page of statements: Democrats’ statements with the phrase “school safety”, 100 at a time.
Keep the key in an environment variable (PULSE_KEY here), not in your code. The examples use curl, Python with requests, and R with httr2.
curl -H "Authorization: Bearer $PULSE_KEY" \
"https://api.americaspoliticalpulse.com/v1/statements?q=%22school%20safety%22&party=D&limit=100"import os
import requests
API = "https://api.americaspoliticalpulse.com/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['PULSE_KEY']}"
r = s.get(f"{API}/statements", params={"q": '"school safety"', "party": "D", "limit": 100})
r.raise_for_status()
page = r.json()
print(page["total"], len(page["data"]))library(httr2)
page <- request("https://api.americaspoliticalpulse.com/v1/statements") |>
req_auth_bearer_token(Sys.getenv("PULSE_KEY")) |>
req_url_query(q = '"school safety"', party = "D", limit = 100) |>
req_perform() |>
resp_body_json()
page$totalEvery response has the same four parts: data (a list, or one object), next_cursor for the next page, total (the number of matches, where a resource counts them) and meta with the license and the citation.
Posts on X come with their tweet_id and an empty text: X’s terms let us share post ids, not the posts. Press releases, newsletters and floor speeches come with their whole text.
{
"data": [ … up to 100 statements … ],
"next_cursor": "…",
"total": …,
"meta": {
"licence": "CC BY 4.0",
"licence_url": "https://creativecommons.org/licenses/by/4.0/",
"citation": "Westwood, S. J., & Lelkes, Y. (2024). America's Political Pulse [Data set]. https://americaspoliticalpulse.com/data",
"sources": "https://americaspoliticalpulse.com/data#sources",
"docs": "https://americaspoliticalpulse.com/api"
}
}Filters and pages
Narrow a list with filters, then follow next_cursor to the end.
The four lists (statements, officials, bills, votes) take the same paging parameters and filters of their own; the reference lists every one.
q takes words, “a phrase” in quotes, -word to leave a word out, a | b for either and prefix*; upper-case OR, AND and NOT work too. A filter that takes several values takes them comma-separated (party=D,R) or repeated (party=D&party=R), except policy_area, which must be repeated because area names contain commas. Party and state codes can be lower case, and dates are YYYY-MM-DD. A parameter the list doesn’t take is refused with 400, so a typo can’t quietly return everything.
limit sets the page size, 1 to 100 (25 if you leave it out). Send each page’s next_cursor back as cursor, with the other parameters unchanged, until it comes back null. A cursor belongs to its query: change a filter and it is refused with 400. A full page can be followed by one empty page. You can page as deep as the results go, but the index is rebuilt nightly, and a walk that spans the rebuild can skip or repeat items. An official’s votes (/v1/officials/{id}/votes) page the same way.
The example below collects every statement by Pennsylvania officials since January 2025 that matches “tariff”, waiting out the per-minute limit. At the default limits that is up to 6,000 statements a minute.
import os
import time
import requests
API = "https://api.americaspoliticalpulse.com/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['PULSE_KEY']}"
params = {"q": "tariff", "state": "PA", "date_from": "2025-01-01", "limit": 100}
rows = []
while True:
r = s.get(f"{API}/statements", params=params)
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", "60"))
if wait > 60: # the day's limit: stop here, carry on tomorrow
break
time.sleep(wait)
continue
r.raise_for_status()
page = r.json()
rows += page["data"]
if not page["next_cursor"]:
break
params["cursor"] = page["next_cursor"]
print(len(rows), "of", page["total"])library(httr2)
req <- request("https://api.americaspoliticalpulse.com/v1/statements") |>
req_auth_bearer_token(Sys.getenv("PULSE_KEY")) |>
req_retry(max_tries = 5) # waits out a 429 or 503, reading Retry-After
params <- list(q = "tariff", state = "PA", date_from = "2025-01-01", limit = 100)
rows <- list()
repeat {
page <- req |> req_url_query(!!!params) |> req_perform() |> resp_body_json()
rows <- c(rows, page$data)
if (is.null(page$next_cursor)) break
params$cursor <- page$next_cursor
}
length(rows)Limits and quotas
60 requests a minute and 10,000 a day for each account, and 100 file downloads a day, unless the lab has raised yours.
Days run on UTC. A request turned away for being over a limit doesn’t count.
Each successful response says where you stand, in X-RateLimit-Limit-Minute, X-RateLimit-Remaining-Minute, X-RateLimit-Limit-Day and X-RateLimit-Remaining-Day. Over a limit you get 429 with Retry-After: the seconds to the next minute, or to midnight UTC. /v1/usage gives your limits, today’s requests by resource and the last 30 days.
Downloads count the site’s file downloads and /v1/bulk/{name} together; each answers with a link that works for five minutes. Separately, one address can send at most 1,000 requests to the API in five minutes, whatever keys it uses, and then gets 429 for a few minutes. Need more? Write to the lab.
Errors
An error is JSON with one field, a sentence saying what went wrong: {"error": "limit must be 1 to 100"}.
| Status | When |
|---|---|
| 400 | A parameter is wrong: a name the resource doesn’t take, a value it can’t read, a limit outside 1 to 100, a cursor from another query. Also a key sent in the address. |
| 401 | No key, or a key that isn’t valid (revoked or mistyped). |
| 403 | The account is suspended, or the API isn’t open to it yet. |
| 404 | No such resource, statement, official, bill or bulk file, or no votes for that official and window. |
| 405 | A method other than GET: the API is read-only. |
| 429 | Over a limit. Retry-After gives the seconds to wait, except for the daily downloads (they reset at midnight UTC) and the per-address limit. |
| 500 | Something failed on our side. Try again. |
| 502 | The search service failed. Try again. |
| 503 | A resource is being rebuilt, or the search service is busy (then with Retry-After: 5). Try again shortly. |
Versions and changes
This is version 1: every address starts with /v1.
The reference is built from the code that answers your requests, and the API serves the same description at https://api.americaspoliticalpulse.com/v1/openapi.json (no key needed). The terms let us change the limits or the API, with notice where we can.
Terms and citation
The data are CC BY 4.0: use them for anything, and cite them.
Using the API means accepting the terms of use: one account per person, keep your key to yourself, and stay within the limits. Every response carries the citation in meta.citation:
Westwood, S. J., & Lelkes, Y. (2024). America's Political Pulse [Data set]. https://americaspoliticalpulse.com/data
When you use a measure, cite its methods paper too. meta.sources links to the outside sources behind the data, their terms and the credit each asks for. One exception: caption text from broadcasters and video platforms in the state floor speeches file, and campaign-website text in the websites file, remain under their sources’ terms, not CC BY 4.0.

