JavaScript SDK
@cg/sdk β a typed, zero-dependency client for the same API documented at
/api. Credentials and rate limits: API guide.
Install, and a first call
The SDK is framework-agnostic and has no runtime dependencies. It runs in a
browser and in Node 18+ (anywhere fetch exists, or you can inject one).
Not on npm yet. @cg/sdk is currently a private workspace package
inside the CivicGate monorepo, so npm install @cg/sdk will not resolve. Until it
is published, the two supported paths are the browser bundle below and
vendoring the source. Everything else on this page describes the surface you get either way.
In a browser, with no build step
<script src="https://www.civicgate.org/widget/civicgate-widget.js" async></script>
<script>
CivicGate.configure({ apiKey: "cg_live_β¦" }); // only for account-scoped widgets
</script>
<!-- one bill's public wall β no credential needed -->
<div data-cg-widget="wall" data-type="bill" data-id="hr-22-119"></div> Every widget and every attribute is documented on widgets.
In an app with a bundler
import { createClient } from "@cg/sdk";
const cg = createClient({ apiKey: "cg_live_β¦" });
// A bill's public wall needs no credential at all:
const page = await cg.subjectFeed("bill", "hr-22-119", 10);
for (const e of page.events) {
console.log(e.occurredAt, e.title, e.url);
} There are two things called CivicGate, and they are not the same object.
The browser bundle defines window.CivicGate = the widget runtime
(configure, mount, refresh, takeLoginVerifier).
The SDK exports a CivicGate of its own = the kernel namespace
(configure, client, cache, people,
resource, refresh, reset). Only configure and
refresh exist on both, and they take different options. Reaching for
CivicGate.people after loading the bundle gets you undefined.
createClient
One client holds the endpoint and the credential, so a host page configures once and every call shares it. A client with neither credential is still useful β most of CivicGate is public.
import { createClient } from "@cg/sdk";
// Third-party page: a read-only key, safe to ship in the markup.
const cg = createClient({ apiKey: "cg_live_β¦" });
// First-party page: a GETTER for the session bearer, read fresh on every call.
const cg = createClient({ token: () => localStorage.getItem("cg-token") });
// Public data only. No sign-up, no key.
const cg = createClient(); Which credential
| Option | Reads | Writes | Safe in a public page |
|---|---|---|---|
| none | Public data β bills, people, votes, money, courts, any subject wall. | No | n/a |
apiKey | Public data plus that account's own feed and subscriptions. | No β rejected by the gateway | Yes, and that is what it is for |
token | Everything that reader may see. | Yes | No β it is that reader's session |
A key being read-only is a property of the server, not of this client: the gateway refuses every mutation on a key-authenticated request. So a key pasted into a public page cannot change the account it belongs to, and cannot widen what that page can see. Create and scope keys at Settings β API keys; details on API keys.
ClientConfig (4 options)
| Option | Type | Default | What it does |
|---|---|---|---|
apiKey | string | β | Sent as X-API-Key. READ-ONLY: the gateway rejects every mutation on a key-authenticated request, and treats the caller as anonymous for visibility purposes, so a key can never widen what comes back. Safe to put in a public page. |
token | () => string | null | Promise<string | null> | β | A GETTER, not a string. It is called fresh on every request, so a refreshed session token is picked up without rebuilding the client. Sent as Authorization: Bearer. This is the only credential that can write. |
gatewayUrl | string | https://www.civicgate.org/graphql | Point this at your own deployment, or at api.civicgate.org if you prefer the dedicated API host. |
fetch | typeof fetch | globalThis.fetch, bound | Inject one for SSR, tests, or a polyfill. The default is BOUND to globalThis β an unbound window.fetch called as a method of another object throws βIllegal invocationβ in browsers. |
Every method
All 23 methods on the client, grouped. Each is a thin, typed wrapper over
query(), so anything the typed surface does not cover is still reachable through
that β and every query, mutation and type is enumerated in the generated
schema reference.
Every read takes an optional trailing AbortSignal. Pass one from your component's
teardown and a cancelled request surfaces as an ABORTED error you can ignore,
rather than a state update into an unmounted view.
Feed reads (5)
Three different sources, deliberately not one filtered query β see the note under the table.
| Method | Returns | Needs | What it does |
|---|---|---|---|
feed(filter?: FeedFilter, signal?): Promise<FeedPage> | FeedPage | Key or session | The authenticated identity's own wall: every update from every subject it subscribes to, newest first. Returns events only from subjects that identity already follows. |
subjectFeed(subjectType, subjectId, limit = 10, cursor?, signal?): Promise<FeedPage> | FeedPage | None | ONE subject's public wall β a bill, a member, a committee, a user. No credential. |
placeFeed(place?: PlaceFeedQuery, signal?): Promise<PlaceFeedPage> | PlaceFeedPage | None | A place's wall: its representatives' activity plus events indexed to the place itself. limit defaults to 20. level is clamped server-side and the clamped value comes back on the page. |
subscriptions(opts?: { subjectType?, group? }, signal?): Promise<Subscription[]> | Subscription[] | Key or session | Everything the identity subscribes to, with labels, URLs and last-update times. |
catalog(signal?): Promise<FeedCatalog> | FeedCatalog | None | The filter vocabulary β subject types, subject groups, event types. Build filter UIs from this rather than hard-coding strings. |
Subscriptions (2)
Writes. A session bearer only β an API key is rejected by the gateway on every mutation.
| Method | Returns | Needs | What it does |
|---|---|---|---|
setSubscribed(subjectType, subjectId, on): Promise<boolean> | boolean | Session | Follow / unfollow a subject of any type. |
subscribeToUser(handle, on = true): Promise<string | null> | string | null | Session | Follow / unfollow another member by handle. Resolves to the member's id, or null when the handle does not resolve. |
Posts (9)
A post is a feed subject like any other, and a reply is a post with a parent.
| Method | Returns | Needs | What it does |
|---|---|---|---|
post(input: { body, kind?, title?, url?, preview?, hasMedia? }): Promise<UserPost> | UserPost | Session | Post to your own wall. Set hasMedia: true when the attachments are the post and the body is deliberately empty, or an empty body is rejected. |
updatePost(id, body, title?): Promise<UserPost> | UserPost | Session | Edit your own post's text. Ownership is matched inside the server's WHERE clause, not checked beforehand. |
deletePost(id): Promise<boolean> | boolean | Session | Delete your own post. |
getPost(id, signal?): Promise<UserPost | null> | UserPost | null | None | One post with its media. Public, so a post page works signed out. |
getPosts(ids: string[], signal?): Promise<UserPost[]> | UserPost[] | None | Several posts in ONE request, in the order asked for. Missing or deleted ids are simply absent β do not index the result by position. |
postReplies(id, limit = 50, signal?): Promise<UserPost[]> | UserPost[] | None | A post's replies. |
replyToPost(postId, body): Promise<UserPost> | UserPost | Session | Reply to a post. The reply inherits the parent's like and subscription machinery. |
setPostPreview(postId, preview: PostPreviewInput | null): Promise<boolean> | boolean | Session | Set or clear the preview card under a post. Pass null to remove it. A media preview is applied here rather than in post(), because attachment ids do not exist until the grants come back. |
linkPreview(url, signal?): Promise<LinkPreview> | LinkPreview | Key or session | Unfurl a URL server-side β a browser cannot read another origin's <head>. Never rejects on an unreadable page: it resolves with ok: false so a composer can keep the plain link. |
Uploads (6)
The three-step contract. Prefer the wrappers in @cg/sdk/uploads over calling these by hand.
| Method | Returns | Needs | What it does |
|---|---|---|---|
uploadLimits(signal?): Promise<UploadLimits> | UploadLimits | None | Server-enforced maxFilesPerPost / maxFileBytes / maxNoteChars. Validate against these before spending a byte; the server re-checks regardless. |
requestPostUploads(postId, files): Promise<UploadGrant[]> | UploadGrant[] | Session | Reserve attachment slots and get one presigned PUT per file. This is where media ids are minted. |
finalizeUpload(mediaId): Promise<{ ok, message }> | { ok, message } | Session | Confirm the object landed. Load-bearing: a presigned PUT signs the key and content-type but CANNOT bind a size, so an oversize object is measured here and deleted. |
myUploads(opts?: { kind?, search?, limit?, offset? }, signal?): Promise<PostMedia[]> | PostMedia[] | Session | The signed-in member's media library, filterable by kind and searchable. |
setMediaNote(mediaId, note): Promise<boolean> | boolean | Session | Set or clear one attachment's caption. |
deletePostMedia(mediaId): Promise<boolean> | boolean | Session | Remove an attachment from the post, from object storage, and from the CDN edge. |
Escape hatch (1)
Every typed method above is a thin wrapper over this one.
| Method | Returns | Needs | What it does |
|---|---|---|---|
query<T>(query: string, variables?, signal?): Promise<T> | T | None | Send any GraphQL document. Returns data (the SDK unwraps it) and throws a GqlError on a non-2xx or on the first entry in errors. Use it for everything the typed surface does not cover yet β see the schema reference for what exists. |
Three feed sources, not one filtered query. feed() can only ever
return events from subjects the caller already follows, so filtering it by someone
else's id shows a visitor an empty page. Use subjectFeed() for a subject's public
wall and placeFeed() for a place β a place is not a subject, it is a set of them.
Filtering a feed
FeedFilter takes subjectGroups (families),
subjectTypes (concrete types, unioned with the groups),
eventTypes, a limit of 1β50 (default 10), a
cursor, and sort of newest (default) or
oldest.
const page = await cg.feed({
subjectGroups: ["bills"],
eventTypes: ["bill_status", "bill_vote"],
limit: 20,
});
// Paging: pass nextCursor back VERBATIM. It is opaque β do not parse it.
if (page.hasMore) {
const older = await cg.feed({ subjectGroups: ["bills"], limit: 20, cursor: page.nextCursor });
}
The four subject groups, generated below from the same
@cg/core/feed catalog the gateway and the ingestion signals import β so this table
cannot go stale when a type is added:
| Group | Label | Types in it |
|---|---|---|
people | People | person, official, governor, president, judge, state_legislator, appointee, candidate |
bills | Bills | bill, state_bill |
users | Users | user, post |
civic | Civic | issue, committee, hearing, election, jurisdiction, policy_area |
That is 18 subject types and 38 event types in total.
Rather than copying either list, fetch it at runtime with catalog() β or, if you
build against the monorepo, import SUBJECT_TYPES / EVENT_TYPES from
@cg/core/feed directly. Every value is enumerated on
widgets β reference.
createFeedStore β the SWR layer
A raw feed() call gives you a page. createFeedStore() gives you a
live list: it paints from cache on the first frame, revalidates behind that, and keeps
polling politely for as long as anyone is looking at it. It is a plain
subscribe / getSnapshot store, so Preact, React, Vue or hand-written DOM can all drive from it.
import { createClient, createFeedStore } from "@cg/sdk";
const cg = createClient({ apiKey: "cg_live_β¦" });
const store = createFeedStore(cg, { filter: { subjectGroups: ["bills"] }, limit: 20 });
const off = store.subscribe((s) => render(s)); // called on every meaningful change
render(store.getSnapshot()); // paint NOW, synchronously
store.start(); // cached snapshot, then fetch, then poll
// later
await store.loadMore(); // append the next (older) page
await store.refresh(); // force a check now, bypassing the interval
store.stop(); // stop polling and abort anything in flight
off(); What it guarantees
- Synchronous first paint.
start()reads the last page out oflocalStorageand publishes it before anything is awaited, so a returning visitor sees real content on frame one instead of a spinner. Only the first page is persisted β deeper pages are cheap to refetch and would bloat storage. - Background refresh every ~12s (
refreshMs, default12000;0disables polling). The refresh replaces page one and keeps the older pages already loaded, so aloadMore()result does not vanish under a poll. - Polling pauses on a hidden tab and fires one immediate tick when the tab becomes visible again, so a returning reader never waits out the interval they just missed. A backgrounded tab hammering the API all day is the easiest way to look like abuse.
- A failed refresh never blanks a working feed. On error the events already on
screen are kept,
erroris populated andloadinggoes false. Render the list and the error; never treat a non-nullerroras "show nothing". -
start()is idempotent. Calling it twice β a re-mount in dev β does not double the interval.
For someone else's wall, pass subject, not a filter. createFeedStore(cg, { subject: { type: "user", id: "handle" } })
calls subjectFeed(). Writing { filter: { subjectId: "β¦" } }
instead filters the reader's own feed, which shows a visitor an empty profile. Everything
else β SWR, paging, persistence β is identical, which is why both live in one store.
FeedStoreOptions (7)
| Option | Type | Default | What it does |
|---|---|---|---|
filter | FeedFilter | {} | Applied to the personal feed. Swap it later with setFilter(), which clears the list, re-keys the cache and refetches. |
subject | { type, id } | β | Read ONE subject's public wall instead of the personal feed. A different API call, not a filter β see the warning below. |
place | PlaceFeedQuery | β | Read a place's wall. Mutually exclusive with subject; subject wins if both are set. |
refreshMs | number | 12000 | Background refresh interval. Set 0 to disable polling entirely. |
limit | number | 10 | Page size, for the first page and for every loadMore(). |
storageKey | string | null | "cg-feed" | localStorage key prefix. Set null to disable persistence β a shared kiosk, or an SSR pass. The subject is part of the key, so two profiles cannot paint each other's wall on first frame. |
storage | Storage-like | window.localStorage | Injected storage for tests and SSR. |
FeedState β what a subscriber receives (9 fields)
| Field | Type | Meaning |
|---|---|---|
events | FeedEvent[] | The list to render, newest first. |
loading | boolean | True ONLY for the very first load with nothing cached. A returning visitor never sees it, which is the whole point. |
refreshing | boolean | A background revalidation is in flight. Drive a subtle indicator from this, never a spinner over data you already have. |
error | string | null | Populated on failure INCLUDING one that left a working list on screen. Never assume error means empty. |
hasMore | boolean | There is an older page. Gate the Load-more control on this. |
cursor | string | null | Opaque. Pass it back verbatim; do not parse it. |
updatedAt | string | null | ISO time of the last successful fetch β powers βupdated 12s agoβ. |
subscriptionCount | number | How many subscriptions the fan-in covered. 0 explains an empty feed honestly: nothing is followed, as against nothing has happened. |
source | string | null | cache | db-fill | db, straight from the API. Useful when debugging staleness. |
The kernel β one cache for a page of widgets
A page can carry a dozen CivicGate widgets, and three cards for the same member is the ordinary case rather than the edge case. The kernel is what makes that cost one request: it owns the endpoint and credential, one cache, one in-flight request per key, and error normalisation. Split any of those across widgets and the sharing evaporates.
import { CivicGate } from "@cg/sdk";
CivicGate.configure({ apiKey: "cg_live_β¦" });
// Three widgets calling this in the same tick = ONE network request.
const card = await CivicGate.people.get("ted_cruz");
// β { id, name, party, state, role, roles[], imageUrl, internalUrl, updatedAt }
CivicGate.people.peek("ted_cruz"); // synchronous cached read, for a first paint
await CivicGate.refresh(); // revalidate every loaded widget; never rejects -
configure()is safe to call twice. Re-configuring with the same endpoint and credential returns the existing kernel untouched, so a second widget's defensive call cannot throw away the first one's warm cache. The cache is wiped only when the credential itself changes β a cache that outlives a sign-out is exactly how a signed-in payload lands on an anonymous screen. -
createKernel()builds a standalone one, outside the singleton. Use it only when a page hosts two genuinely independent embeds that must not share a cache or a credential. - Link to
card.internalUrl, not to a URL you rebuilt. A person owns several resolving slugs; the gateway returns the canonical one, and every other form 301-redirects onto it. - The cache picks IndexedDB β localStorage β memory by itself, bounds itself at 500 entries, evicts on write (a TTL checked only on read frees nothing), and never throws β a widget must not die because Safari is in private mode.
- Everything on the kernel today is public, viewer-independent data, which is what makes one shared cache safe. A service that ever returns viewer-dependent data has to scope its own key first.
CivicGate.resource({ key, fetcher }) is the extension point: it builds an SWR
resource on the kernel's cache and deduper, with the same
loading | stale | fresh | error status vocabulary. Reuse it rather than writing a
second SWR layer.
Import from the leaf, not the index
This one is measured, and it is expensive. Import the feed catalog from
@cg/core/feed β the leaf module. Importing the package index
(@cg/core) drags zod into the bundle and takes a widget from
29 kB to 355 kB. Nothing errors; the widget simply gets twelve times
heavier, which is why it survived unnoticed.
import { SUBJECT_TYPES, EVENT_TYPES } from "@cg/core/feed"; // β
29kb
import { SUBJECT_TYPES } from "@cg/core"; // β 355kb β pulls in zod @cg/sdk follows the same discipline: it is sideEffects: false and every
module is also a leaf entry point, so a bundle that only renders a person card does not carry the
feed store, the upload helpers or the catalog.
Leaf entry points (12)
| Import path | What it carries |
|---|---|
@cg/sdk | Everything. The client, the feed store, the kernel, uploads, and the catalog. |
@cg/sdk/client | createClient / CivicGateClient only. |
@cg/sdk/feed-store | createFeedStore / FeedStore. |
@cg/sdk/kernel | createKernel / configure / CivicGate. |
@cg/sdk/people | The people service in isolation. |
@cg/sdk/errors | CivicGateError / normalizeError / isAborted. |
@cg/sdk/cache | The cache drivers and cacheKey(). |
@cg/sdk/resource | createResource β the generic SWR primitive. |
@cg/sdk/dedupe | createDeduper. |
@cg/sdk/observable | createEmitter / createPoller. |
@cg/sdk/uploads | createPostWithMedia and the validation helpers. |
@cg/sdk/types | Wire types only. Erased at build time. |
Error handling
Three completely different failure shapes reach a caller through the same method: a
DOMException when a component unmounts mid-request, a bare
TypeError when the network or an ad-blocker kills it, and a GraphQL error whose
extensions.code is the only one that carries meaning. A UI cannot render three
shapes, so the SDK normalises them into one.
The raw client throws a GqlError, which has code and
traceId but no retryable. That flag lives on
CivicGateError, which you get by passing the thrown value through
normalizeError() (the kernel and its services do this for you). Reading
e.retryable off a raw client rejection gets undefined, which is falsy β
so every failure would look permanent and a rate limit would never be retried.
import { createClient, normalizeError, isAborted } from "@cg/sdk";
const cg = createClient({ apiKey: "cg_live_β¦" });
async function feedWithRetry(attempt = 0) {
try {
return await cg.feed({ limit: 20 });
} catch (raw) {
const e = normalizeError(raw); // β without this there is no .retryable
if (isAborted(e)) return null; // we cancelled on purpose; say nothing
if (e.code === "RATE_LIMITED" && e.retryable && attempt < 4) {
// The window is one minute. Back off exponentially with jitter β a fixed
// retry from many clients re-synchronises them into the next burst.
const waitMs = Math.min(60_000, 2 ** attempt * 1_000) + Math.random() * 500;
await new Promise((r) => setTimeout(r, waitMs));
return feedWithRetry(attempt + 1);
}
// Not retryable, or out of attempts. Quote the trace id in a bug report:
console.error(`CivicGate ${e.code} (trace ${e.traceId ?? "none"}): ${e.message}`);
throw e;
}
}
A CivicGateError carries code, status (the HTTP status, or
null for a transport failure), traceId (the only handle support has on
a live failure β quote it) and retryable. normalizeError() is
idempotent, so normalising twice is a no-op as an error passes through layers.
Error codes (10)
| Code | Retryable | Meaning |
|---|---|---|
RATE_LIMITED | Yes | The identity's per-minute budget is spent. The window is one minute β back off and retry. |
NETWORK | Yes | DNS, CORS, offline, or an ad-blocker. fetch rejects with a bare TypeError for all of these and there is no way to tell them apart from script. |
TIMEOUT | Yes | The request timed out. |
SERVER | Yes | HTTP 5xx. |
ABORTED | No | The caller cancelled β an unmount, or a newer keystroke. Deliberately NOT retryable: auto-retrying resurrects work the UI already decided it did not want. Use isAborted(e) to stay silent. |
UNAUTHORIZED | No | HTTP 401. No credential, or an expired one. |
FORBIDDEN | No | HTTP 403. Often a write attempted with an API key, which is read-only. |
NOT_FOUND | No | HTTP 404. |
BAD_REQUEST | No | HTTP 400 or 422. Retrying an identical request cannot help. |
UNKNOWN | No | Nothing recognisable. A gateway-specific extensions.code passes through VERBATIM rather than being flattened to this β the server knows more about its own failure than the SDK does. |
A code the gateway sends that is not in this list passes through verbatim
rather than being flattened to UNKNOWN β so switch on the ones you handle and let
the rest fall to a default.
Rate limits
Every request is charged to an identity β the key, the authorising user, or the IP β at that identity's tier. The numbers are tabulated once, on the API guide, generated from the same catalog the gateway enforces from. A limit documented in two places is a limit that will eventually disagree with itself, so it is not repeated here.
A worked example
A live "what's happening with this bill" panel, in plain JavaScript, that would actually run in a browser: no credential, no framework, no build step beyond a bundler resolving the import.
import { createClient, createFeedStore, normalizeError, isAborted } from "@cg/sdk";
import { eventIcon } from "@cg/core/feed"; // the LEAF module β see #imports
const el = document.querySelector("#bill-activity");
// A public wall needs no credential at all.
const cg = createClient();
const store = createFeedStore(cg, {
subject: { type: "bill", id: "hr-22-119" },
limit: 10,
refreshMs: 30_000, // this panel does not need 12s
});
store.subscribe((s) => {
// A failed refresh keeps the events it already had, so render the list FIRST
// and treat the error as an annotation β never as "show nothing".
if (s.loading) { el.textContent = "Loadingβ¦"; return; }
el.innerHTML = "";
if (s.events.length === 0 && !s.error) {
el.textContent = "No recorded activity for this bill yet.";
return;
}
const ul = document.createElement("ul");
for (const e of s.events) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = new URL(e.url ?? e.subjectUrl ?? "/", "https://www.civicgate.org").href;
a.textContent = e.title;
li.append(`${e.eventIcon ?? eventIcon(e.eventType)} `, a);
li.append(` β ${new Date(e.occurredAt).toLocaleDateString()}`);
ul.append(li);
}
el.append(ul);
if (s.error) {
const p = document.createElement("p");
p.className = "muted";
p.textContent = `Could not check for updates: ${s.error}`;
el.append(p);
}
if (s.hasMore) {
const btn = document.createElement("button");
btn.textContent = "Older";
btn.disabled = s.refreshing;
btn.onclick = () => { void store.loadMore(); };
el.append(btn);
}
});
store.start();
// Stop polling when the panel goes away.
window.addEventListener("pagehide", () => store.stop()); The same thing signed in, on the server
import { createClient, normalizeError } from "@cg/sdk";
// Node 18+ has fetch; pass one explicitly if yours does not.
const cg = createClient({ apiKey: process.env.CIVICGATE_API_KEY });
try {
const subs = await cg.subscriptions({ group: "bills" });
console.log(`Following ${subs.length} bills`);
const page = await cg.feed({ subjectGroups: ["bills"], limit: 50 });
console.log(`${page.events.length} updates, served from ${page.source}`);
} catch (raw) {
const e = normalizeError(raw);
// FORBIDDEN here almost always means a mutation was attempted with a key.
console.error(e.code, e.traceId, e.message);
} Beyond the feed
The SDK ships a few more pieces, each usable on its own:
| Module | What it is for |
|---|---|
@cg/sdk/uploads | createPostWithMedia() wraps the three-step upload contract β create the post,
PUT the bytes straight to object storage, finalize each one β with byte-weighted
aggregate progress and bounded concurrency (3 lanes). Bytes never pass through CivicGate's
servers, so a large video is limited by the uplink rather than a request timeout.
validateAttachments() checks a batch against uploadLimits()
before you spend anything.
|
@cg/sdk/resource | createResource() β the generic SWR primitive behind the kernel. Synchronous
paint from cache, background revalidation, and it notifies subscribers only on a
real change, so an unchanged poll costs zero renders.
|
@cg/sdk/dedupe | createDeduper() β concurrent callers asking for the same key join one request.
The cache cannot solve this alone: nothing is cached until the first response lands, and
all the callers happen before it does.
|
@cg/sdk/observable | createEmitter() and createPoller() β the listener set and the
visibility-aware interval that the feed store and every resource share.
|
createIdentity()(package index) |
Cross-site identity for an embedded widget, walking a five-rung ladder: same-origin
session β FedCM β identity iframe β OIDC popup β anonymous. Read the honest
limit before building on it: silent cross-site identity is what browsers are
deliberately removing, the iframe rung is storage-partitioned by default, and its
status of "unknown" is not "anonymous" β
a UI must offer sign-in on unknown and must never assert that the reader is signed out.
|
Reference
- Public API β endpoint, terms, and what the data covers.
- Schema reference β every query, mutation and type, generated from the live schema. The answer to βwhat can I ask forβ.
- API guide β credentials, rate limits, errors on the wire.
- API keys β creating, scoping and revoking.
- Widgets β every embeddable widget and its parameters.
The API is public-domain civic data and stays free to read. If you build something with it, we would like to hear about it β get in touch.