Typed, zero-dependency client for the Circle.so Admin API v2 — members, access groups, spaces, space groups, token version detection — plus real captured API fixtures for testing your integration without hitting Circle.
Unofficial. Not affiliated with Circle Internet Services, Inc.
npm install circle-so-sdkRequires Node.js ≥ 18 (uses the global fetch).
import { createCircleClient, detectTokenVersion } from 'circle-so-sdk'
// Circle issues v1 (Professional plan) and v2 (Business plan) admin tokens
// that look identical. A v1 token on the v2 API returns a plain 401, so use
// the three-state probe to give users an actionable error:
const version = await detectTokenVersion(token) // 'v2' | 'v1' | 'invalid'
const circle = createCircleClient({ token })
const community = await circle.getCommunity()
// Invite (idempotent — re-inviting an existing email does not re-send):
const { member, alreadyExisted } = await circle.createOrInviteMember('user@example.com', 'User Name')
// Access groups (member identified by email, group id in the URL):
const groups = await circle.listAccessGroups()
await circle.addToAccessGroup(groupId, 'user@example.com')
await circle.removeFromAccessGroup(groupId, 'user@example.com')
// Spaces / space groups (id goes in the request BODY, even for DELETE):
await circle.addToSpace(spaceId, 'user@example.com')
await circle.addToSpaceGroup(spaceGroupId, 'user@example.com')All non-OK responses throw CircleApiError with a classified kind:
| kind | meaning |
|---|---|
unauthorized |
401 — wrong/invalid token (or a v1 token on the v2 API) |
forbidden |
403 — plan no longer allows the endpoint (plan downgrade) |
not_found |
404 with a JSON body — missing record/param |
wrong_path |
404 with an HTML body — the route does not exist (bug guard) |
rate_limited |
429 |
server_error |
5xx |
import { CircleApiError } from 'circle-so-sdk'
try {
await circle.addToAccessGroup(groupId, email)
} catch (error) {
if (error instanceof CircleApiError && error.kind === 'unauthorized') {
// pause syncing, prompt the user to re-connect
}
}The wrong_path distinction matters: Circle returns 404 HTML for routes that don't exist and 404 JSON for missing records. The client tells them apart via Content-Type so a typo'd endpoint doesn't masquerade as "record not found".
Learned from a live integration (all fixtures under circle-so-sdk/mocks were captured against the real API):
- Asymmetric membership endpoints. Access groups take the group id in the URL and the member's email in the body (
POST /access_groups/:id/community_members). Spaces and space groups instead use flat endpoints (/space_members,/space_group_members) with the id in the body — even forDELETE. - Members are keyed by email, not member id, for all membership operations.
- Adds/removes are idempotent. Re-adding returns the same success response; you can apply a full expected set without querying current state first.
- Space group membership cascades to all spaces inside the group (
CircleSpace.space_grouplets you detect overlaps). - First
createOrInviteMembersends Circle's invitation email; subsequent calls for the same email are no-ops (alreadyExisted: true).
Real request/response pairs captured from the live Admin API, ready to feed into MSW:
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { CIRCLE_BASE_V2, circleScenarios, CIRCLE_WRONG_PATH_HTML } from 'circle-so-sdk/mocks'
const server = setupServer(
http.post(`${CIRCLE_BASE_V2}/community_members`, () =>
HttpResponse.json(circleScenarios.memberInvited.body, { status: 201 }),
),
http.post(`${CIRCLE_BASE_V2}/access_groups/:id/community_members`, () =>
HttpResponse.json(circleScenarios.accessGroupAdd.body, { status: 201 }),
),
)Each scenario carries method, path, requestBody, status, contentType, and the captured body, so you can also drive a generic handler loop or assert against the shapes directly.
createCircleClient({
token, // required — Circle Admin API v2 token (Business plan+)
baseUrl, // optional — override https://app.circle.so/api/admin/v2 (testing)
fetch: customFetch, // optional — inject a fetch implementation
})
detectTokenVersion(token, { baseUrlV2, baseUrlV1, fetch }) // overrides optionalMIT