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.
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_SECRET4. 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.
| Scope | Grants | Endpoints |
|---|---|---|
supporters:read | Supporter 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:read | The actions feed; with supporters:read, action events in the replay. On by default. | GET /actions |
org_data:read | Your 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:manage | Register, 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 key | Public lookups and reference data. | GET /whoami · GET /bills/{billId} · GET /legislators/{legislatorId} · GET /issues |
| per event type | The 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:readis 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 /whoamishowsapi_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_disabledfrom 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 fromGET /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-…" }| Code | HTTP | What to do |
|---|---|---|
unauthorized | 401 | Missing, malformed or unknown key. Do not retry; check the key. |
key_expired | 401 | The key expired. A human must issue a new one. |
key_revoked | 401 | The key was revoked. A human must issue a new one. |
forbidden_scope | 403 | The 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_disabled | 403 | An owner switched the partner API off, or the organization is not active. Pause and retry later (hourly). |
invalid_request | 400 | A malformed parameter, body or cursor; see error.details. Fix, do not retry as is (a bad cursor: restart the listing). |
not_found | 404 | No such record in this organization (or outside its jurisdiction). |
supporter_deleted | 410 | The person deleted their account; details.deleted_at. Delete or anonymize your copy. |
idempotency_key_reused | 409 | Same Idempotency-Key with a different body or endpoint. Use a new key. |
idempotency_key_in_progress | 409 | The first request with this key is still running. Retry shortly with the same key. |
rate_limited | 429 | Wait Retry-After seconds; details.bucket names the ceiling hit. |
internal | 500 | Unexpected error. Retry with backoff; quote request_id if it persists. |
service_unavailable | 503 | Paused platform-wide, a dependency is down, or an Idempotency-Key could not be honoured. Retry with backoff and the same Idempotency-Key. |
Headers
| Header | Where | Meaning |
|---|---|---|
X-Request-Id | every response | Same as request_id. Quote it when you report a problem. |
X-RateLimit-Limit / -Remaining / -Reset | every response past the key check | Your budget for this window; Reset is Unix seconds. See Rate limits. |
Retry-After | 429 | Seconds to wait before retrying. |
Idempotency-Key | request, POST | See Idempotency. |
Idempotent-Replayed: true | POST response | A 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 attentionRate limits
| Budget | Per minute | Applies to |
|---|---|---|
| Reads, per key | 300 | Everything except email lookup and match |
| Email lookup and match, per key | 60 | GET /supporters/lookup, POST /supporters/match — tighter, because an email lookup reveals whether someone is a supporter |
| Organization total | 1200 | All 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_cursorwhile more rows exist; pass it back as?cursor=until it isnull.limitdefaults to 200 and is capped at 500 (/events: at most 100). /eventspages 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.sincesets 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_idunchanged while you page. A malformed cursor is a 400: restart the listing, never retry the cursor. - Watermarks are inclusive:
sinceandupdated_sincereturn 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 /supportersorders byupdated_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 /actionshas two modes that cannot be combined:?since=lists submissions by when they began;?updated_since=is the change feed, ordered byupdated_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 onupdated_atwith the sameupdated_sinceand cursor.
Building a CRM sync
A dependable sync has five layers; each covers a failure the one before cannot.
| Layer | Call | Covers |
|---|---|---|
| 1. Backfill | GET /supporters, GET /actions?updated_since=<epoch> | Everything that exists today |
| 2. Incremental pulls | …?updated_since=<watermark − overlap> | Every change since the last run |
| 3. Webhooks | your HTTPS receiver | Seconds-level freshness |
| 4. Event replay | GET /events?since= | Webhook downtime, lost deliveries, deletions for pull-only syncs |
| 5. Periodic full reconcile | full re-page | Anything 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
/supporterswith 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
deliveredorfailedafter the submission. Theupdated_sincefeed (or theaction.updatedwebhook) 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
/supportersand 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 /whoamiand refuse to run ifis_testis 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/matchtakes{ "emails": [ … ] }, up to 200 per request. Addresses are trimmed and lowercased, and duplicates — case variants included — collapse to one entry. The response listsmatched({ email, supporters: [ … ] }) andunmatched, both in the lowercased form, so map them back to your records case-insensitively.meta.requestedcounts 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, returnsmatched: falsewith 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.
Consent, unsubscribes and deletion
statusis the supporter’s standing with your organization:active,inactiveorunsubscribed. Treatunsubscribedas “stop emailing” in your system too.consentsrecords what the person agreed to:email_updates,action_alerts,data_sharing.trueis granted;falseis declined or withdrawn;nullmeans never asked — not a refusal.data_sharingis reported, not enforced. Your organization controls this data and the CRM is its own system, so every supporter is returned whatever the flag says. Your integration decides what its system may do with someone whosedata_sharingisfalse.supporter.unsubscribedis sent when someone opts out by the unsubscribe link, marks an alert as spam, or is marked unsubscribed by your staff. The supporter’supdated_atmoves too, so a pull-only sync sees it.- Deletion. When a person deletes their account, a
supporter.deletedevent is sent whosedata.supporteris only{ id, deleted_at }. From then on they are absent from every list, lookup and match;GET /supporters/{id}answers 410supporter_deletedwithdetails.deleted_at; their submissions leave/actionsand the replay; and any webhook about them not yet delivered is dropped. Delete or anonymize your copy. A pull-only integration learns of deletions fromGET /events(or a 410 during a reconcile).
Data model
Field-by-field definitions and examples are in the interactive reference. What the fields mean:
| Object | What it is | Watch out for |
|---|---|---|
| Supporter | One 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 profile | GET /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”. |
| Action | One 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. |
| Recipient | Each 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. |
| Positions | imputed_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. |
| Issues | GET /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 bill | A 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. |
| Portfolio | A 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. |
| Campaign | Every 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 page | Every 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, legislator | GET /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).
| Event | When | Replay needs |
|---|---|---|
supporter.created | A 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.updated | A supporter’s record changed: staff edits, or the person’s own name, email, address or districts. | supporters:read |
supporter.deleted | The 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.completed | A 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.unsubscribed | A supporter opted out — via the unsubscribe link, a spam complaint, or staff marking them unsubscribed. Stop mailing them. | supporters:read |
action.updated | A 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 |
ping | The 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.updatedcan arrive before the same submission’ssupporter.action.completed. Dedupe on the event id; upsert records by their own id and keep the newerupdated_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 409idempotency_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 /whoamireportsis_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:readwith/tracked-bills,/portfolios,/campaignsand/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 andupdated_at; per recipientdeliveryandletter_stance;attributionon actions and supporters;bill.state. New eventaction.updated. A recipient’sstatuscan now change tofailedafter the submission. - Deletion:
supporter.deleted, 410supporter_deleted, and deleted people’s submissions leave/actions. Every existing webhook endpoint was subscribed tosupporter.deletedautomatically;action.updatedis opt-in for endpoints registered before it. GET /eventsreplay;Idempotency-Keyon POST;X-RateLimit-*headers;is_testin whoami; webhook retries now span about 24 hours.- Shape changes to check: profile
representativesnow lists every sitting member of each district (several per type in multi-member districts) and addspeople_id;chamber,full_name,partyandocd_idmay be null.districtskeeps one value per type (the first the address lookup listed); the newdistricts_alllists 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
amendandmonitor(reported asneutral/unknowninimputed_position); issue pages, whose actions havebill: 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.
Links
- Interactive reference · OpenAPI document (always current — generate clients from it)
- MCP connection guide · MCP tool reference
- Terms · Privacy · Support: support@conservativestates.app
