PoliData API · Developer guide
Build on PoliData's
legislative data.
Pull bills, votes, people, parties and committees from the US Congress, UK Parliament, European Parliament, Polish Sejm, Dutch Tweede Kamer and German Bundestag into your own systems, for the parliaments in your plan. API keys, cursor pagination, signed webhooks and machine-readable errors.
- Base URL
- https://api.getpolidata.com/api/public/v1
- Authentication
- Authorization: Bearer <key>
- Format
- JSON (UTF-8), cursor-paginated
- Rate limits
- Per workspace, per minute and per hour
Authentication
Create a key in PoliData, then send it with every request.
- Check that your plan includes the API. API access is included on Pro, Business and Enterprise, and with Research Access. Explorer and Team don't include it. Compare plans or apply for Research Access.
- Create a key. Go to Settings → API & webhooks → API keys (you'll be asked to sign in) and choose Mint new key. Give it a label such as
production-appand an expiry of 30, 60, 90 or 180 days, or one year. PoliData may ask you to confirm it's you. The key is shown once: copy it then. - Send it with every request in the
Authorizationheader.
Authorization: Bearer pk_J8eR0aKh_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxA key reads the parliaments your workspace has API access to, or the ones you limit it to when you create it, with the external:read scope. Members see the keys they created; workspace admins see every key. Regenerate replaces a key: the old one stops working at once. Keys stop working if the workspace moves to a plan without API access.
Your first call
Walk the legislative items your key can read.
# Legislative items, least recently updated first.
curl -H "Authorization: Bearer $POLIDATA_KEY" \
"https://api.getpolidata.com/api/public/v1/legislative-items?limit=100"
# One parliament: US, UK, EU, PL, NL or DE.
curl -H "Authorization: Bearer $POLIDATA_KEY" \
"https://api.getpolidata.com/api/public/v1/legislative-items?jurisdiction=US&limit=100"
# Only items updated since a point in time.
curl -H "Authorization: Bearer $POLIDATA_KEY" \
"https://api.getpolidata.com/api/public/v1/legislative-items?updated_since=2026-10-01T00:00:00Z"
# The next page: the same filters plus the cursor from the last response.
curl -H "Authorization: Bearer $POLIDATA_KEY" \
"https://api.getpolidata.com/api/public/v1/legislative-items?jurisdiction=US&limit=100&cursor=$NEXT_CURSOR"The other collections work the same way, and each data collection has a detail endpoint for one record, such as /legislative-items/{id}. Paths are relative to the base URL above.
/legislative-itemsBills and other legislative items/peopleMembers and other people/partiesParties and groups/committeesCommittees/votesVotes/vote-recordsHow each member voted/eventsChange events, for catching up on webhooks
Pagination
Opaque cursors, ordered by last update. No offsets.
Collections return records ordered by updated_at, then id, oldest first (/events by created_at). Every response has items, next_cursor, has_more and as_of. Send next_cursor back as cursor, with the same filters, until has_more is false. limit sets the page size: 1 to 500, 100 by default. Treat cursors as opaque; an altered cursor answers 400.
{
"items": [
{ "id": "88dcb366-…", "title": "…", "updated_at": "2026-10-12T17:30:50Z" },
…
],
"next_cursor": "eyJ1IjoiMjAyNi0xMC0xMlQxNzozMDo1MCIsImkiOiI4OGRjYjM2Ni0uLi4ifQ",
"has_more": true,
"as_of": "2026-10-13T09:00:00Z"
}as_of is the server time when the request started. Keep the as_of of a full walk's first page and pass it as updated_since next time: records that changed during or after the walk are included.
Keeping data in sync
Import once, follow changes, reconcile now and then.
- Import once. Walk each collection you need with cursors and
limit=500, and store records byid.GET /api/public/v1/legislative-items?limit=500 → store the items, follow next_cursor until has_more is false - Follow changes with webhooks. PoliData sends a small signed event when a bill is introduced or updated (
bill.introduced,bill.updated), a vote is recorded (vote.recorded), a committee meeting is scheduled (committee.scheduled) and whenever a record is created or updated (for examplelegislative_item.updated). The event carries the record's id; fetch the full record from the REST API.GET /api/public/v1/webhooks/event-typeslists every type. See Webhooks. - Reconcile regularly, weekly for example: query each collection with
updated_sinceset to theas_ofof your last complete run and upsert byid.GET /api/public/v1/legislative-items?updated_since=2026-10-06T00:00:00Z&limit=500 → upsert by id (ids are stable, so this is idempotent)
Reconciling catches what webhooks can't: deliveries your endpoint missed and corrections from historical re-imports, which update records without sending webhook events. If your endpoint was down, GET /api/public/v1/events replays the same event envelopes for the last 30 days; an older cursor answers 410 cursor_expired, and you resync with updated_since instead.
Webhooks
Register an endpoint, verify every delivery, answer quickly.
- Register an endpoint in Settings → API & webhooks → Webhooks. Choose the events and parliaments, or receive all of them, and copy the signing secret (
whsec_…): it is shown once. Workspace admins can use Send test to deliver awebhook.testevent and check the receiver. - Verify the signature of every delivery. Deliveries follow the Standard Webhooks specification, so an off-the-shelf library does the work. Verify the raw body bytes, not re-serialized JSON, and keep your server clock in sync: libraries reject a timestamp more than five minutes away from it.
Delivery headers Header Meaning webhook-idThe delivery's id. It stays the same when a delivery is retried, so use it to drop duplicates. webhook-timestampWhen this attempt was signed, in Unix seconds. webhook-signaturev1,<base64 signature>. During a secret rotation, two signatures separated by a space. X-PoliData-Event-TypeThe event type, for routing without parsing the body. # pip install standardwebhooks import os from standardwebhooks import Webhook webhook = Webhook(os.environ["POLIDATA_WEBHOOK_SECRET"]) # "whsec_…" def handle_delivery(raw_body: bytes, headers: dict[str, str]): # Raises when the signature or timestamp doesn't verify: answer 400. event = webhook.verify(raw_body, headers) enqueue(event) # do the work later; answer 2xx within 10 seconds - Fetch the full record. The body is a thin envelope:
dataholds the record'sid,jurisdiction_codeandupdated_at. Fetch the record from/api/public/v1/<collection>/<id>and upsert it.POST https://your-app.example/polidata-webhook Content-Type: application/json webhook-id: 0f6d1c52-9a3e-4b8e-a1d4-5d0e6c2b7a91 webhook-timestamp: 1791892801 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= X-PoliData-Event-Type: legislative_item.updated { "event_type": "legislative_item.updated", "aggregate_type": "legislative_item", "aggregate_id": "8c78f50f-2265-4b13-9ef8-a2ff8c3ba6a0", "jurisdiction_code": "US", "occurred_at": "2026-10-13T12:00:00.157558+00:00", "idempotency_key": "legislative_item.updated:8c78f50f-…", "data": { "id": "8c78f50f-2265-4b13-9ef8-a2ff8c3ba6a0", "jurisdiction_code": "US", "updated_at": "2026-10-13T12:00:00.157558+00:00" }, "created_at": "2026-10-13T12:00:00.204411+00:00" } - Answer with a 2xx within 10 seconds. Any 2xx counts as delivered. A 4xx other than 408 or 429 fails the delivery at once. Timeouts, connection errors, 408, 429 and 5xx answers are retried:After a fifth failure the delivery is marked Failed. Recent deliveries are listed under each endpoint in Settings, and
Delivery attempts Attempt When 1st When the event is published 2nd 1 minute after the first failure 3rd 5 minutes after the second failure 4th 30 minutes after the third failure 5th 2 hours after the fourth failure GET /api/public/v1/eventslets you catch up. - Rotate the secret if it leaks. Rotate secret shows a new secret once. For 24 hours, deliveries carry signatures made with both the old and the new secret, so you can update your receiver without missing events.
Rate limits
Shared by your workspace, per minute and per hour.
| Plan | Per minute | Per hour | Per month |
|---|---|---|---|
| Explorer | No API access | ||
| Team | No API access | ||
| Research Access | 20 | 100 | 10,000 |
| Pro | 60 | 1,000 | No limit |
| Business | 120 | 5,000 | No limit |
| Enterprise | 240 | 10,000 | No limit |
Limits apply to the workspace, not to each key. All of a workspace's API keys share one budget, so creating more keys doesn't add capacity. The windows are fixed and follow the clock in UTC: the minute window resets at the start of every minute, the hour window at the start of every hour, and the Research Access monthly allowance on the 1st of each month.
Every successful response tells you where you stand:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per hour |
X-RateLimit-Remaining | Requests left this hour |
X-RateLimit-Reset | Seconds until the hour window resets |
X-RateLimit-Minute-Limit | Requests allowed per minute |
X-RateLimit-Minute-Remaining | Requests left this minute |
The two Remaining headers are left out on the rare occasions the counters can't be read.
When a limit is reached, the API answers 429 Too Many Requests with a Retry-After header: the seconds until you can try again.
HTTP/1.1 429 Too Many Requests
Retry-After: 43
X-RateLimit-Minute-Limit: 60
X-RateLimit-Minute-Remaining: 0
{
"detail": {
"reason": "rate_limited",
"window": "minute",
"message": "Rate limit exceeded: 60 requests per minute for tier 'pro'. Retry in 43s.",
"tier": "pro",
"limit": 60
}
}- The hourly limit answers the same way, with
"window": "hour"and the hourly limit inlimit. - When Research Access uses up its monthly allowance, the reason is
monthly_quota_exceeded, withlimit_per_month, andRetry-Aftercounts down to the 1st of next month (UTC). - If the rate limiter is briefly unavailable, Research Access requests get
503with the reasonrate_limiter_unavailableand aRetry-After. Requests on paid plans are not blocked.
Staying within the limits
- Honour
Retry-Afterand back off on 429 instead of retrying straight away. - Ask for up to 500 records per request with
limit=500(the default is 100). - Follow changes with webhooks and
updated_sinceinstead of re-reading whole collections. - Watch
X-RateLimit-Minute-Remainingand spread bursts across minutes.
Errors
JSON bodies with machine-readable reasons.
Errors carry a detail. For 403, 410, 429 and 503 it is an object with a reason to branch on and a human-readable message. For 400, 401 and 404 it is a string, and for 422 a list of the invalid parameters.
HTTP/1.1 403 Forbidden
{ "detail": { "reason": "key_expired", "message": "API key expired on 2026-09-30T08:12:44+00:00" } }| HTTP | detail or reason | What it means |
|---|---|---|
| 400 | "Invalid cursor: …" | The cursor was altered or cut short. Start again, or resume with updated_since. |
| 401 | "Invalid API key" | The Authorization header is missing, or the key is malformed, unknown or revoked. |
| 403 | key_expired | The key has passed its expiry date. Create a new key in Settings → API. |
| 403 | tier_no_api | The workspace's plan doesn't include API access. |
| 403 | subscription_required | The workspace's subscription isn't active. |
| 403 | no_api_entitlements | The workspace, or this key, has no parliament with API access. |
| 403 | organization_suspended | The workspace is suspended. |
| 403 | organization_deleted | The workspace is scheduled for deletion. |
| 403 | insufficient_scope | The endpoint needs a scope the key doesn't have, such as external:write for managing webhooks through the API. Keys created in Settings have external:read. |
| 403 | jurisdiction_not_entitled | ?jurisdiction= names a parliament the key can't read. |
| 404 | "Not found" | No such record, or it belongs to a parliament the key can't read. |
| 410 | cursor_expired | An /events cursor older than 30 days. Resync with updated_since. |
| 422 | [{ "loc": […], "msg": "…" }] | A parameter is invalid, for example limit above 500 or an unknown jurisdiction code. |
| 429 | rate_limited | The per-minute or hourly limit was reached. Wait Retry-After seconds. |
| 429 | monthly_quota_exceeded | The Research Access monthly allowance is used up until the 1st (UTC). |
| 503 | rate_limiter_unavailable | Research Access only: try again after Retry-After seconds. |
Next steps
- API reference Every endpoint with its parameters and response schemas.
- OpenAPI schema The same contract as machine-readable JSON, for code generators.
- Manage API keysCreate, regenerate and revoke keys.
- Set up webhooksAdd endpoints, send test events and see recent deliveries.
- Compare plansAPI access starts on Pro; Research Access includes it too.