Conservative States

Conservative States

Partner API guide

How to connect your organization’s own systems — a CRM, a data warehouse, a dashboard — to Conservative States: what to call, in what order, and what the data means.

This is the guide. The interactive reference — every endpoint, field and example, with a downloadable OpenAPI document — is at /developers. Building with an AI coding assistant? Install the agent skill: npx skills add conservative-states/skills.

Overview

The Partner API is a read API over one organization’s data, chosen by the API key. There is no cross-organization access of any kind. It answers:

  • Who your supporters are — identity, address, districts, elected officials, consents, engagement, where they first came from.
  • What they did — every submission (a letter to one or many officials, a signup, a pledge), whether each letter was delivered, and the issues and bills it concerned.
  • What your organization stands for — tracked bills and your position on each, shared Issue Portfolios, campaigns and action pages.
  • The public record behind it — the bills and legislators your data refers to.

Base URL: https://my.conservativestates.app/api/partner/v1. Every request is HTTPS with a bearer key. Webhooks push changes to you as they happen; the pull endpoints are always the source of truth. The API never exposes the text of the letters supporters write — the subject line is the only message content it carries.

Quickstart

An organization owner creates a key under Settings → Partner API & webhooks. Then, in a terminal:

export CS_API_KEY=cs_org_...
BASE=https://my.conservativestates.app/api/partner/v1

# 1. Prove the key works: organization, scopes, live limits, is_test
curl -s -H "Authorization: Bearer $CS_API_KEY" $BASE/whoami

# 2. Page through supporters (repeat with &cursor=<meta.next_cursor> until it is null)
curl -s -H "Authorization: Bearer $CS_API_KEY" "$BASE/supporters?limit=500"

# 3. Register a webhook receiver (needs a key with webhooks:manage)
curl -s -X POST -H "Authorization: Bearer $CS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"url":"https://crm.example.com/hooks/conservative-states"}' \
  $BASE/webhook-endpoints
# → data.secret is shown once: store it as CS_WEBHOOK_SECRET

4. Verify the ping. Registration immediately sends a signed ping to your URL. Check its X-CS-Signature header with the secret (see Webhooks) and answer 2xx:

POST /hooks/conservative-states
X-CS-Event: ping
X-CS-Signature: t=1792000000,v1=…

{ "id": "ping-1792000000000", "type": "ping", "api_version": "v1",
  "created_at": "2026-10-12T18:22:41.000Z",
  "organization": { "id": 12, "slug": "your-org" },
  "data": { "message": "Webhook endpoint verified. Real events will look like this envelope." } }

If the ping verifies, every real event will too. Next: read Building a CRM sync.

Authentication and scopes

Send the key on every request as Authorization: Bearer cs_org_…. An owner creates keys in Settings, after confirming that supporter data will be shared with the system the key goes into. A key is shown once and stored only as a hash: a lost key cannot be recovered — revoke it and create another.

ScopeGrantsEndpoints
supporters:readSupporter records, profiles, email lookup and match; supporter events in the replay. On by default.GET /supporters · GET /supporters/lookup · POST /supporters/match · GET /supporters/{supporterId}
actions:readThe actions feed; with supporters:read, action events in the replay. On by default.GET /actions
org_data:readYour organization’s tracked bills, shared portfolios, campaigns and action pages. On by default.GET /tracked-bills · GET /portfolios · GET /portfolios/{portfolioId} · GET /campaigns · GET /campaigns/{campaignId} · GET /action-pages · GET /action-pages/{actionPageId}
webhooks:manageRegister, list and delete webhook endpoints from software. Never on a key by default.GET /webhook-endpoints · POST /webhook-endpoints · DELETE /webhook-endpoints/{endpointId}
any valid keyPublic lookups and reference data.GET /whoami · GET /bills/{billId} · GET /legislators/{legislatorId} · GET /issues
per event typeThe replay shows each event only to a key that could read its body: supporter.created → supporters:read; supporter.updated → supporters:read; supporter.deleted → supporters:read; supporter.action.completed → supporters:read + actions:read; supporter.unsubscribed → supporters:read; action.updated → supporters:read + actions:read.GET /events
  • Scopes are fixed at creation. A scope added to the platform later is never granted to existing keys — org_data:read is the current example. To use it, an owner creates a new key with it and you swap keys.
  • Expiry. An owner may give a key an expiry date. After it, requests answer 401 key_expired. GET /whoami shows api_key.expires_at — alert someone before it arrives.
  • Revocation takes effect on the next request (401 key_revoked). It also switches off every webhook endpoint that key registered, so a leaked key cannot leave a data feed behind.
  • The organization switch. An owner can turn the Partner API off for the whole organization; every key then answers 403 partner_api_disabled from the next request, and webhooks stop. Keys are not revoked and work again when it is switched back on. Events that happen meanwhile are not pushed later — read them from GET /events.
  • Suspended organizations. An organization that is not active is treated exactly like a switched-off one: 403 partner_api_disabled, no webhooks, keys resume with the organization.

Requests, responses and errors

Every success is an envelope; lists add paging fields to meta:

{ "data": { … } | [ … ],
  "meta": { "request_id": "7c9e6679-…", "count": 200, "has_more": true, "next_cursor": "eyJ0Ijoi…" } }

Every error has the same shape. Branch on error.code, never on the message:

{ "error": { "code": "rate_limited", "message": "Too many requests. Retry in 23s.",
             "details": { "bucket": "key lookup" } },
  "request_id": "7c9e6679-…" }
CodeHTTPWhat to do
unauthorized401Missing, malformed or unknown key. Do not retry; check the key.
key_expired401The key expired. A human must issue a new one.
key_revoked401The key was revoked. A human must issue a new one.
forbidden_scope403The key lacks the scope this endpoint (or event type) needs. A scope cannot be added to an existing key: an owner issues a new one.
partner_api_disabled403An owner switched the partner API off, or the organization is not active. Pause and retry later (hourly).
invalid_request400A malformed parameter, body or cursor; see error.details. Fix, do not retry as is (a bad cursor: restart the listing).
not_found404No such record in this organization (or outside its jurisdiction).
supporter_deleted410The person deleted their account; details.deleted_at. Delete or anonymize your copy.
idempotency_key_reused409Same Idempotency-Key with a different body or endpoint. Use a new key.
idempotency_key_in_progress409The first request with this key is still running. Retry shortly with the same key.
rate_limited429Wait Retry-After seconds; details.bucket names the ceiling hit.
internal500Unexpected error. Retry with backoff; quote request_id if it persists.
service_unavailable503Paused platform-wide, a dependency is down, or an Idempotency-Key could not be honoured. Retry with backoff and the same Idempotency-Key.

Headers

HeaderWhereMeaning
X-Request-Idevery responseSame as request_id. Quote it when you report a problem.
X-RateLimit-Limit / -Remaining / -Resetevery response past the key checkYour budget for this window; Reset is Unix seconds. See Rate limits.
Retry-After429Seconds to wait before retrying.
Idempotency-Keyrequest, POSTSee Idempotency.
Idempotent-Replayed: truePOST responseA stored response replayed for a repeated key.

A response refused while the key itself is checked (401, a 403 for scope or a switched-off organization, a 503 for a platform-wide pause) carries no rate-limit headers. New fields and new meta keys can appear at any time: ignore what you do not recognize. Every GET is safe to retry.

Retrying

429           wait Retry-After seconds, then retry
503, 500      back off with jitter (1 s, 2 s, 4 s … up to 60 s), at most ~5 tries; same Idempotency-Key
409 …in_progress  retry shortly with the same Idempotency-Key
other 4xx     do not retry — fix the request (a 400 on a cursor: restart that listing)
401 / 403     stop the sync and tell a person: the key or the organization needs attention

Rate limits

BudgetPer minuteApplies to
Reads, per key300Everything except email lookup and match
Email lookup and match, per key60GET /supporters/lookup, POST /supporters/match — tighter, because an email lookup reveals whether someone is a supporter
Organization total1200All keys of the organization together

Budgets reset every 60 seconds on fixed windows. More keys do not raise the organization total. X-RateLimit-Limit is the key’s budget for that kind of request; X-RateLimit-Remaining is the smaller of what the key and the organization have left; X-RateLimit-Reset is when the window ends. Pace on Remaining and you will rarely see a 429; when you do, error.details.bucket says which budget ran out. The live numbers are also in GET /whoami.

Pagination and filters

  • Lists return meta.next_cursor while more rows exist; pass it back as ?cursor= until it is null. limit defaults to 200 and is capped at 500 (/events: at most 100).
  • /events pages in journal order and releases an event a few minutes after it occurs; a page stops at the first event not yet released, so following its cursor never skips one. since sets where the first page starts.
  • Cursors do not expire, but a cursor is only meaningful with the same filters as the request that produced it — keep since, updated_since, status, supporter_id unchanged while you page. A malformed cursor is a 400: restart the listing, never retry the cursor.
  • Watermarks are inclusive: since and updated_since return rows at or after the instant you pass. Lists are ascending, so the last row of the last page is your next watermark.
  • Overlap every incremental run. Rows are stamped when written but become visible when their transaction commits, so a row can appear slightly behind a watermark you already passed. Start each run from your last watermark minus a few minutes and upsert on id — seeing a row twice is harmless, missing one is not.
  • GET /supporters orders by updated_at, which moves on advocacy activity, staff edits and the person’s own name, email, address or district changes. A row edited during a long backfill moves to the end and is seen again, never skipped.
  • GET /actions has two modes that cannot be combined: ?since= lists submissions by when they began; ?updated_since= is the change feed, ordered by updated_at, and returns a submission again whenever a recipient’s delivery settles. A submission is held back until it has been complete for 60 seconds, so you never see half of a multi-recipient send.
  • The organization lists (/tracked-bills, /portfolios, /campaigns, /action-pages) are ascending on updated_at with the same updated_since and cursor.

Building a CRM sync

A dependable sync has five layers; each covers a failure the one before cannot.

LayerCallCovers
1. BackfillGET /supporters, GET /actions?updated_since=<epoch>Everything that exists today
2. Incremental pulls…?updated_since=<watermark − overlap>Every change since the last run
3. Webhooksyour HTTPS receiverSeconds-level freshness
4. Event replayGET /events?since=Webhook downtime, lost deliveries, deletions for pull-only syncs
5. Periodic full reconcilefull re-pageAnything missed; removals from lists that have no tombstones

The loop, every few minutes:

OVERLAP = 5 minutes

# supporters
since = state.supporters_watermark - OVERLAP
for page in GET /supporters?updated_since=<since>&limit=500:
    for s in page.data: upsert supporter by s.id
    newest = max(newest, last row's updated_at)
commit; THEN state.supporters_watermark = newest       # never before the writes commit

# actions — the change feed, so delivery outcomes arrive
since = state.actions_watermark - OVERLAP
for page in GET /actions?updated_since=<since>&limit=500:
    for a in page.data: upsert action by a.id, replacing its recipients wholesale
    newest = max(newest, last row's updated_at)
commit; THEN state.actions_watermark = newest

# events — catches what webhooks missed (and deletions)
since = state.events_watermark - OVERLAP
for page in GET /events?since=<since>&limit=100:
    for e in page.data:
        if e.id in processed_event_ids: continue
        handle(e)                       # exactly as if it arrived by webhook
        processed_event_ids.add(e.id)
    newest = max(newest, last event's created_at)
state.events_watermark = newest
  • Backfill: page /supporters with no watermark, then /actions?updated_since=1970-01-01T00:00:00Z; set the events watermark to the moment the backfill started.
  • Delivery changes: a letter the platform delivers to an office settles delivered or failed after the submission. The updated_since feed (or the action.updated webhook) brings the whole submission back; if you only use ?since=, you never see the outcome. Actions are not append-only.
  • Webhooks carry the same bodies as the pulls: upsert by id, dedupe on the event id, and never let an older body overwrite a newer one (compare updated_at).
  • Full reconcile (weekly, say): re-page /supporters and every organization list. Tracked bills, portfolios, campaigns and action pages have no tombstones — an untracked bill, a portfolio made private or deleted, a deleted draft simply stop appearing — so anything you hold that the full listing no longer returns is gone. For a supporter you hold that is no longer listed, GET /supporters/{id} answers 410 if the person deleted their account.
  • Start each deployment with GET /whoami and refuse to run if is_test is not what that environment expects.

Matching supporters

To link an existing donor file, match it once by email, store the supporter id on each record, and key everything on id from then on. Email addresses change; the id does not.

  • POST /supporters/match takes { "emails": [ … ] }, up to 200 per request. Addresses are trimmed and lowercased, and duplicates — case variants included — collapse to one entry. The response lists matched ({ email, supporters: [ … ] }) and unmatched, both in the lowercased form, so map them back to your records case-insensitively. meta.requested counts the distinct addresses.
  • GET /supporters/lookup?email= is the one-record version, for when a donor record is opened. It echoes the address as you sent it, returns matched: false with an empty list when there is no match (a 200, never a 404).
  • Both return an array of supporters per address: two spellings that differ only by case can be two different people. Pick deliberately (most recent last_action_at, say) or flag the record for a person to decide.
  • Both count against the lookup budget (60/minute per key). People who deleted their account never match.

Data model

Field-by-field definitions and examples are in the interactive reference. What the fields mean:

ObjectWhat it isWatch out for
SupporterOne person as your organization knows them: identity, address, districts, consents, engagement, first-touch attribution.Key on id, not email. District labels are text (“12A”, “A”). districts holds one per type; an address in several districts of one type (a New Hampshire floterial) has every one in districts_all. engagement is recomputed on read and is not a change signal.
Supporter profileGET /supporters/{id}: the supporter plus representatives, issues, policy issues, per-bill history, alert email engagement and the five latest submissions.representatives lists every sitting member for each district (a multi-member district gives several), each with people_id when matched; most fields can be null. policy_issues coverage is partial: absence means “not tagged”.
ActionOne submission: a letter to a 12-member committee is one action with 12 recipients. Signups and pledges are actions too.Upsert on id (an opaque grp_… string — do not parse it). bill is null for an issue page; it carries state. Display short_summary, not the statutory title.
RecipientEach official on a submission: legislator_id (null for a governor, statewide officer or a recipient your organization added), delivery, letter_stance.delivery.status: queued → delivered / failed, or not_applicable when the supporter sent it from their own mail app or an official’s web form. failure_reason is a coarse class, never the provider’s text.
Positionsimputed_position is your organization’s ask, resolved from the campaign, action page or tracked bill, with a basis and confidence, computed when you read it. letter_stance is the stance the letter carried when it was sent, and never changes.Neither is a position the supporter stated. Render “acted with your campaign to support HB0123”, never “Jane supports HB0123”. A tracked amend reads neutral; monitor reads unknown with confidence none.
IssuesGET /issues: the flat issue vocabulary (broad coverage) and the hierarchical policy taxonomy (sparser).Cache for an hour; map by id or slug, not display name.
Tracked billA bill on your tracked list with your stance, priority, public statement, website flag and the shared portfolios holding it.Internal notes are never exposed. updated_at moves on position changes only — not portfolio membership or the bill’s progress.
PortfolioA shared Issue Portfolio: a named set of bills. It holds no stance; the tracked bill does.Only shared portfolios are ever exposed. A private one is absent from every list and a 404 by id.
CampaignEvery campaign, archived ones included, with its bills and its stance on each (watch = no ask yet), action pages and current letter round.kind is issue when no bill is named.
Action pageEvery page — drafts, live, unpublished and archived — with bills, stance, live URL and submission count.A page supporters used is archived, never deleted, so every action_page.id in /actions resolves. url is set only while published.
Bill, legislatorGET /bills/{id}, GET /legislators/{id}: public lookups of what your data refers to — status, sponsors, documents, latest action; party, chamber, district, contact email, committees.Limited to your organization’s jurisdiction (and its tracked list); a state organization also sees its state’s members of Congress, so every legislator_id resolves. Anything else is a 404. Bill numbers keep each legislature’s own form: HB0123 in Tennessee, HB45 in Alabama.

Webhooks

Webhooks push events to an HTTPS endpoint as they happen — the two action events after about 60 seconds, so a multi-recipient submission arrives complete. They are for speed, not completeness: deliveries can be late, repeated or out of order, so keep the pull sync running. Set endpoints up in Settings, or from software with a key that has webhooks:manage (up to 10 endpoints per organization).

EventWhenReplay needs
supporter.createdA person became a supporter of your organization — through an action page, a signup, or a subscriber entering your org’s public flow.supporters:read
supporter.updatedA supporter’s record changed: staff edits, or the person’s own name, email, address or districts.supporters:read
supporter.deletedThe person deleted their account. data.supporter is a tombstone — { id, deleted_at }, nothing else. Delete or anonymize your copy. From now on the supporter is absent from every list, lookup and match, GET /supporters/{id} answers 410 supporter_deleted, their submissions leave /actions, and any undelivered webhook about them is dropped rather than sent.supporters:read
supporter.action.completedA supporter completed a submission. Sent about a minute after the action so multi-recipient sends arrive as one complete event; data.action carries the full recipient list.supporters:read + actions:read
supporter.unsubscribedA supporter opted out — via the unsubscribe link, a spam complaint, or staff marking them unsubscribed. Stop mailing them.supporters:read
action.updatedA submission changed after it was reported: a recipient’s letter was delivered or failed (recipients[].delivery). data.action is the whole submission as it is now. Held about a minute, like supporter.action.completed, so one delivery run is one event; it can arrive before that submission’s supporter.action.completed — upsert on data.action.id.supporters:read + actions:read
pingThe test delivery the Send Test button produces. Same envelope and signature as real events.never replayed

Every body is { id, type, api_version, created_at, organization, data }, built from the data as it is when sent. data.supporter is on every event (only { id, deleted_at } on supporter.deleted); data.action is added on the two action events. Each event type’s exact body is in the reference.

POST https://crm.example.com/hooks/conservative-states
Content-Type: application/json
User-Agent: ConservativeStates-Webhooks/1
X-CS-Event: supporter.action.completed
X-CS-Event-Id: 3c7d2b18-…        ← dedupe on this (the same as body.id)
X-CS-Delivery-Id: 98211          ← changes per delivery, not per event
X-CS-Timestamp: 1792000000
X-CS-Signature: t=1792000000,v1=9a3f…

Verifying the signature

v1 is the hex HMAC-SHA256 of "<t>.<raw body>" keyed with the endpoint’s signing secret (cswhsec_…). Verify before you trust anything in the body: use the raw bytes as received (re-serialized JSON will not match), compare in constant time after checking the length, and reject a timestamp more than five minutes from now. These are the agent skill’s reference verifiers — copy them as they are:

/**
 * Reference verifier for Conservative States webhooks (X-CS-Signature).
 *
 *   X-CS-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
 *
 * Pass the RAW request body (string or Buffer) exactly as received — never
 * re-serialized JSON. `secrets` holds the current signing secret first and, during
 * a rotation, the previous one.
 */
import { createHmac, timingSafeEqual } from 'node:crypto';

export const SIGNATURE_TOLERANCE_SECONDS = 300;

export function verifyConservativeStatesSignature(
  rawBody: string | Buffer,
  header: string | null | undefined,
  secrets: readonly string[],
  nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean {
  if (!header || secrets.length === 0) return false;

  let timestamp: string | undefined;
  const signatures: string[] = [];
  for (const part of header.split(',')) {
    const eq = part.indexOf('=');
    if (eq <= 0) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value);
  }
  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
  if (Math.abs(nowSeconds - Number(timestamp)) > SIGNATURE_TOLERANCE_SECONDS) return false;

  const body = typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : rawBody;
  for (const secret of secrets) {
    const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest();
    for (const signature of signatures) {
      // timingSafeEqual throws on unequal lengths: check the shape first.
      if (!/^[0-9a-fA-F]{64}$/.test(signature)) continue;
      if (timingSafeEqual(Buffer.from(signature, 'hex'), expected)) return true;
    }
  }
  return false;
}

/** Sign a body the way Conservative States does — for your own tests. */
export function signForTest(secret: string, rawBody: string, timestamp: number): string {
  const v1 = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  return `t=${timestamp},v1=${v1}`;
}
"""Reference verifier for Conservative States webhooks (X-CS-Signature).

    X-CS-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>

Pass the RAW request body bytes exactly as received, never re-serialized JSON.
`secrets` holds the current signing secret first and, during a rotation, the
previous one.
"""
from __future__ import annotations

import hashlib
import hmac
import re
import time
from typing import Iterable, Optional

SIGNATURE_TOLERANCE_SECONDS = 300
_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")


def verify_conservative_states_signature(
    raw_body: bytes,
    header: Optional[str],
    secrets: Iterable[str],
    now: Optional[float] = None,
) -> bool:
    if not header:
        return False
    timestamp: Optional[str] = None
    signatures: list[str] = []
    for part in header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if not timestamp or not timestamp.isdigit() or not signatures:
        return False
    current = time.time() if now is None else now
    if abs(current - int(timestamp)) > SIGNATURE_TOLERANCE_SECONDS:
        return False
    for secret in secrets:
        expected = hmac.new(
            secret.encode("utf-8"), timestamp.encode("ascii") + b"." + raw_body, hashlib.sha256
        ).hexdigest()
        for signature in signatures:
            if _HEX64.match(signature) and hmac.compare_digest(signature.lower(), expected):
                return True
    return False


def sign_for_test(secret: str, raw_body: bytes, timestamp: int) -> str:
    """Sign a body the way Conservative States does, for your own tests."""
    v1 = hmac.new(secret.encode("utf-8"), str(timestamp).encode("ascii") + b"." + raw_body,
                  hashlib.sha256).hexdigest()
    return f"t={timestamp},v1={v1}"

Responding, retries and disabling

  • Answer any 2xx within 10 seconds, then process — queue the body and acknowledge first.
  • Anything else, or no answer in time, is retried: 31 attempts over about 24 hours (30 seconds after the first failure, doubling each time, at most an hour apart). Each attempt is signed afresh.
  • Answering 410 permanently disables the endpoint. Never use it for “I don’t know this supporter” — answer 200 and ignore.
  • 20 failed deliveries in a row — each one after its whole retry window — disable the endpoint until an owner turns it back on in Settings. Replay what you missed from GET /events.
  • Order is not guaranteed, and an event may arrive twice. action.updated can arrive before the same submission’s supporter.action.completed. Dedupe on the event id; upsert records by their own id and keep the newer updated_at.
  • An endpoint receives the event types it subscribed to at registration (all of them by default). Event types added later are not subscribed automatically — the one exception is supporter.deleted, added to every existing endpoint so deletions reach your CRM.

Registering from software

Make reconnecting idempotent: call GET /webhook-endpoints first and look for your URL with registered_by_this_key: true. If it is there and active, keep it. Otherwise POST /webhook-endpoints — the response holds the signing secret, returned once, and a signed ping is already on its way. The same URL twice is a 400 with details.reason duplicate_url; the endpoint cap is endpoint_limit. A key can delete only the endpoints it registered; owners can manage all of them in Settings. Lost the secret? Delete your endpoint and register again, then replay the gap from GET /events.

Rotating the secret

An owner rotates an endpoint’s secret in Settings. The new secret signs every delivery attempt from that moment — retries of earlier events included — and there is no period where both are used. To rotate without losing anything, have your receiver accept a list of secrets (the verifiers above do), rotate, add the new secret to the list, then remove the old one. Deliveries that fail verification in between are simply retried on the schedule above.

Idempotency

The POST endpoints (POST /supporters/match, POST /webhook-endpoints) accept an Idempotency-Key header: any printable string up to 255 characters — a fresh UUID per logical request is ideal.

  • The first successful response is stored for 24 hours and replayed, with Idempotent-Replayed: true, to any retry with the same key. A failed request is not stored, so the corrected retry can reuse its key.
  • The same key with a different body or on another endpoint is 409 idempotency_key_reused; a retry while the first request is still running is 409 idempotency_key_in_progress — wait and retry.
  • If the key cannot be honoured right now the answer is 503, never a silent second execution: retry with the same key.

Testing

  • Test organizations. Conservative States can mark an organization as a test organization; its supporters and actions are synthetic, and GET /whoami reports is_test: true. Point a staging CRM at one (ask support@conservativestates.app) and guard production against it.
  • Ping. Registering an endpoint sends a signed ping; the Send test button in Settings sends another whenever you like. Recent deliveries, their responses and a redeliver button are in the same place.
  • Unit-test the verifier with bodies you sign yourself: a changed byte, a stale timestamp, a wrong-length signature and a missing header must all fail.

Versioning and changes

The path carries v1 and payloads carry api_version. New fields, endpoints, event types, enum values and meta keys are added without a version bump — ignore what you do not recognize and do not fail on an unknown enum value. A breaking change gets a new path.

October 2026

  • New scope org_data:read with /tracked-bills, /portfolios, /campaigns and /action-pages; any key can read /bills/{id} and /legislators/{id}. Existing keys do not get the new scope: create a new key.
  • Actions: the ?updated_since= change feed and updated_at; per recipient delivery and letter_stance; attribution on actions and supporters; bill.state. New event action.updated. A recipient’s status can now change to failed after the submission.
  • Deletion: supporter.deleted, 410 supporter_deleted, and deleted people’s submissions leave /actions. Every existing webhook endpoint was subscribed to supporter.deleted automatically; action.updated is opt-in for endpoints registered before it.
  • GET /events replay; Idempotency-Key on POST; X-RateLimit-* headers; is_test in whoami; webhook retries now span about 24 hours.
  • Shape changes to check: profile representatives now lists every sitting member of each district (several per type in multi-member districts) and adds people_id; chamber, full_name, party and ocd_id may be null. districts keeps one value per type (the first the address lookup listed); the new districts_all lists every district of each type as an array. Revoking a key disables the webhook endpoints it registered; a suspended organization’s keys answer 403.
  • Earlier in October: tracked stances amend and monitor (reported as neutral / unknown in imputed_position); issue pages, whose actions have bill: null; letters to governors, statewide officials and organization-added recipients (legislator_id: null, delivery_method: webform_manual).

For AI agents

The conservative-states-api agent skill teaches a coding assistant (Claude Code, Cursor, Codex, Copilot and others) to build this integration correctly — the sync pattern, signature checks, error handling and the meaning of each field. It is generated from the same code as this page.

npx skills add conservative-states/skills
  • Also served from this site: /.well-known/skills/index.json (npx skills add https://my.conservativestates.app).
  • /llms.txt maps the developer surface for language models.
  • Questions about legislation rather than your own data? The MCP server gives AI assistants read-only tools over bills, text, votes and legislators.