Skip to main content
CivicGate

← Help

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

OptionReadsWritesSafe 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)
OptionTypeDefaultWhat 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.

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

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

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

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

MethodReturnsNeedsWhat 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:

GroupLabelTypes 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 of localStorage and 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, default 12000; 0 disables polling). The refresh replaces page one and keeps the older pages already loaded, so a loadMore() 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, error is populated and loading goes false. Render the list and the error; never treat a non-null error as "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)
OptionTypeDefaultWhat 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)
FieldTypeMeaning
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 pathWhat it carries
@cg/sdkEverything. The client, the feed store, the kernel, uploads, and the catalog.
@cg/sdk/clientcreateClient / CivicGateClient only.
@cg/sdk/feed-storecreateFeedStore / FeedStore.
@cg/sdk/kernelcreateKernel / configure / CivicGate.
@cg/sdk/peopleThe people service in isolation.
@cg/sdk/errorsCivicGateError / normalizeError / isAborted.
@cg/sdk/cacheThe cache drivers and cacheKey().
@cg/sdk/resourcecreateResource β€” the generic SWR primitive.
@cg/sdk/dedupecreateDeduper.
@cg/sdk/observablecreateEmitter / createPoller.
@cg/sdk/uploadscreatePostWithMedia and the validation helpers.
@cg/sdk/typesWire 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)
CodeRetryableMeaning
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:

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