Skip to the guide

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.

  1. 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.
  2. 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-app and 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.
  3. Send it with every request in the Authorization header.
Authorization: Bearer pk_J8eR0aKh_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A 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.

  1. Import once. Walk each collection you need with cursors and limit=500, and store records by id.
    GET /api/public/v1/legislative-items?limit=500
      → store the items, follow next_cursor until has_more is false
  2. 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 example legislative_item.updated). The event carries the record's id; fetch the full record from the REST API. GET /api/public/v1/webhooks/event-types lists every type. See Webhooks.
  3. Reconcile regularly, weekly for example: query each collection with updated_since set to the as_of of your last complete run and upsert by id.
    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.

  1. 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 a webhook.test event and check the receiver.
  2. 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
    HeaderMeaning
    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
  3. Fetch the full record. The body is a thin envelope: data holds the record's id, jurisdiction_code and updated_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"
    }
  4. 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:
    Delivery attempts
    AttemptWhen
    1stWhen the event is published
    2nd1 minute after the first failure
    3rd5 minutes after the second failure
    4th30 minutes after the third failure
    5th2 hours after the fourth failure
    After a fifth failure the delivery is marked Failed. Recent deliveries are listed under each endpoint in Settings, and GET /api/public/v1/events lets you catch up.
  5. 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.

API requests by plan
PlanPer minutePer hourPer month
ExplorerNo API access
TeamNo API access
Research Access2010010,000
Pro601,000No limit
Business1205,000No limit
Enterprise24010,000No 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:

Rate-limit headers
HeaderMeaning
X-RateLimit-LimitRequests allowed per hour
X-RateLimit-RemainingRequests left this hour
X-RateLimit-ResetSeconds until the hour window resets
X-RateLimit-Minute-LimitRequests allowed per minute
X-RateLimit-Minute-RemainingRequests 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 in limit.
  • When Research Access uses up its monthly allowance, the reason is monthly_quota_exceeded, with limit_per_month, and Retry-After counts down to the 1st of next month (UTC).
  • If the rate limiter is briefly unavailable, Research Access requests get 503 with the reason rate_limiter_unavailable and a Retry-After. Requests on paid plans are not blocked.

Staying within the limits

  • Honour Retry-After and 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_since instead of re-reading whole collections.
  • Watch X-RateLimit-Minute-Remaining and 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" } }
Error responses
HTTPdetail or reasonWhat 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.
403key_expiredThe key has passed its expiry date. Create a new key in Settings → API.
403tier_no_apiThe workspace's plan doesn't include API access.
403subscription_requiredThe workspace's subscription isn't active.
403no_api_entitlementsThe workspace, or this key, has no parliament with API access.
403organization_suspendedThe workspace is suspended.
403organization_deletedThe workspace is scheduled for deletion.
403insufficient_scopeThe 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.
403jurisdiction_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.
410cursor_expiredAn /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.
429rate_limitedThe per-minute or hourly limit was reached. Wait Retry-After seconds.
429monthly_quota_exceededThe Research Access monthly allowance is used up until the 1st (UTC).
503rate_limiter_unavailableResearch Access only: try again after Retry-After seconds.

Next steps