Developer reference
ControlStandard API v1
Read-only REST access to your organisation's training and project-control data. Built for analytics tools — Power BI, Looker Studio, data lakes — and your own scripts. Authenticate with a bearer token; everything is JSON; pagination is cursor-based.
Base URLs
The API is served from a dedicated subdomain so it can be moved to its own infrastructure if traffic warrants. Use the subdomain in production:
https://api.controlstandard.tools/v1
Authentication
Every request must carry an API key in the Authorization header:
Authorization: Bearer cs_live_<your-secret>
Keys are issued from Organisation → API keys inside the app and are tied to the issuing organisation. Plan requirement: Business, Scale, or Advisory (see pricing). Keys are bearer credentials — anyone with the key can read your organisation's data. Treat them like passwords.
- The full secret is shown once at creation. Store it in your tool right away — we don't retain it.
- You can name keys, set an optional expiry, archive (revoke), or regenerate them.
- Team scope (optional). An admin can scope a key to a single sub-team when it's created. A team-scoped key only ever returns that team's projects, people, check-ins, control points, friction and training; an unscoped key sees the whole organisation. Check
key.scopeand the top-levelteamfield in/v1/meto discover your scope. - Caps: 5 active org-wide keys and 5 active keys per team (separate budgets). Archive an old one to make room.
- Revoked or expired keys return
401 Unauthorized. - Every mint, rotation, and revocation is recorded in the admin's audit log with the actor and IP; daily request counts per key are visible in the keys admin page for usage visibility.
Rate limits
Per-key, layered. The most restrictive window applies:
- 90 requests per minute
- 375 requests per hour
- 750 requests per day
Every API response carries the standard headers X-RateLimit-Limit,
X-RateLimit-Remaining, and X-RateLimit-Reset. When you
exceed the limit you'll get 429 Too Many Requests with a Retry-After header.
GET /v1/usage reports the configured limits.
Dashboard / BI consumers — please cache.
If you're wiring this into Power BI, Looker Studio, Tableau, a data warehouse, or
any other dashboard tool, configure a scheduled refresh (hourly or
daily is plenty for check-ins and projects) and let the tool serve queries from its
own cache. Do not call the API on every page load or chart
interaction — you'll burn through the per-minute window in seconds and get
429s that break the dashboard for everyone in your org.
- /v1/disciplines and /v1/training/chapters are static reference data — fetch once per day at most.
- For incremental refreshes, store the largest
created_atyou've seen and pass it asfrom=on the next run. - Need higher limits or live data? Email hello@controlstandard.tools and we'll talk.
Pagination and scope
Every list endpoint returns at most limit rows (default 100,
max 500), a pagination block, and a scope
envelope describing what the current key is filtering on:
{
"data": [ ... ],
"pagination": {
"limit": 100,
"next_cursor": "eyJjcmVhdGVkX2F0Ijoi...",
"has_more": true
},
"scope": {
"organisation": { "name": "Acme Engineering Ltd" },
"team": { "short_id": "t_3h6j9k", "name": "Finance" }
}
}
To fetch the next page, pass the next_cursor back as the
cursor query parameter. Cursors are opaque — don't try to decode
or construct them yourself. When has_more is false,
you've reached the end. A tampered cursor returns
400 invalid_cursor.
The scope.team field is null for org-wide keys and
populated for team-scoped keys. Use it to tell consumers why a result
set is narrower than expected — a short list isn't always missing data.
Errors
All errors share a single envelope:
{
"error": {
"code": "plan_required",
"message": "API access requires the Business plan or higher.",
"request_id": "abc123def4"
}
}
Status codes:
400invalid_argument— bad query parameter (e.g. malformed date).401unauthorized— missing, invalid, expired, or revoked key.403forbidden/plan_required— org inactive or below Business plan.404not_found— resource doesn't exist or isn't visible to your organisation.429rate_limited— see Rate limits.500internal_error— quoterequest_idin support tickets.
Endpoint reference
All endpoints require a Business-tier-or-higher API key. Click any row to see parameters and an example response.
GET
/v1/me
Identify the calling key and the organisation it belongs to.
Returns the API key's metadata and a thin reference to the owning organisation, including current plan. Useful as a credential check.
Parameters
None.
Example response
{
"key": {
"short_id": "k_3f9a1c",
"name": "Power BI — finance dashboard",
"prefix": "cs_live_8k2q",
"scope": "team",
"created_at": "2026-04-12T09:14:22+00:00",
"expires_at": null,
"last_used_at": "2026-05-10T08:02:11+00:00"
},
"organisation": {
"name": "Acme Engineering Ltd",
"plan": { "id": "business", "name": "Business", "tier_order": 3 }
},
"team": { "short_id": "t_3h6j9k", "name": "Finance" }
}
key.scope is "org" for organisation-wide keys
and "team" for team-scoped keys. The top-level team
field is null for org-wide keys and identifies the scoped
team otherwise.
GET
/v1/usage
Configured rate-limit windows for this key.
Reports the configured rate limits so consumers can plan their poll cadence. Live remaining quota is exposed on the X-RateLimit-* headers of every other API response.
Parameters
None.
Example response
{
"plan": "business",
"limits": [
{ "window": "minute", "limit": 90 },
{ "window": "hour", "limit": 375 },
{ "window": "day", "limit": 750 }
],
"note": "X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers on every API response carry the live remaining quota."
}
GET
/v1/disciplines
The 10 control disciplines + 5-level rubric. Static reference — cache it.
The full discipline catalogue with Group A/B partitioning and the 5-level scoring rubric. The shape rarely changes — cache locally for 24h. Response carries Cache-Control: private, max-age=86400.
Parameters
None.
Example response (truncated)
{
"disciplines": [
{ "key": "outcomes", "name": "Outcomes", "group": "A", "rubric": { "1": "...", "5": "..." } },
{ "key": "delivery", "name": "Delivery", "group": "A", "rubric": { ... } }
],
"group_a_keys": ["outcomes", "delivery", ...],
"group_b_keys": ["risk", "comms", ...],
"level_labels": { "1": "Absent", "2": "Reactive", "3": "Defined", "4": "Managed", "5": "Optimising" }
}
GET
/v1/users
List active users in your organisation.
Active, non-archived users by default. Use include_archived=true to also retrieve archived/inactive seats — useful when reconciling against historical training data.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
include_archived | boolean | optional | Include archived and deactivated users. Defaults to false. |
limit | integer | optional | 1–500. Default 100. |
cursor | string | optional | Opaque cursor returned by the previous page. |
Example response
{
"data": [
{
"short_id": "u_1a2b3c",
"email": "alex@acme.example",
"full_name": "Alex Rivera",
"first_name": "Alex",
"last_name": "Rivera",
"role": { "name": "manager" },
"is_active": true,
"is_archived": false,
"signup_completed_at": "2026-02-03T11:30:00+00:00",
"last_login": "2026-05-09T16:22:01+00:00",
"created_at": "2026-02-03T11:24:18+00:00"
}
],
"pagination": { "limit": 100, "next_cursor": "eyJpZCI6MTIzfQ==", "has_more": true },
"scope": {
"organisation": { "name": "Acme Engineering Ltd" },
"team": null
}
}
GET
/v1/teams
Sub-teams in your organisation.
Resolves the team reference embedded on projects. A team-scoped key returns only its own team.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
include_archived | boolean | optional | Include archived teams. Defaults to false. |
limit | integer | optional | 1–500. Default 100. |
cursor | string | optional | Opaque cursor for pagination. |
Example response
{
"data": [
{
"short_id": "t_3h6j9k",
"name": "Finance",
"manager": { "short_id": "u_4z9k1m", "full_name": "Sam Patel" },
"member_count": 6,
"is_archived": false,
"created_at": "2026-02-10T09:00:00+00:00"
}
],
"pagination": { "limit": 100, "next_cursor": null, "has_more": false }
}
GET
/v1/training
Per-user × per-chapter training rollup.
One row per user × chapter, with attempt counts, best/latest score, pass status, and last attempt timestamp. Filter by user or chapter to scope.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
user_short_id | string | optional | Restrict to a single user. |
chapter_slug | string | optional | Restrict to a single chapter. |
limit | integer | optional | 1–500. Default 100. |
cursor | string | optional | Opaque cursor for pagination. |
Example response
{
"data": [
{
"user": { "short_id": "u_1a2b3c", "full_name": "Alex Rivera" },
"chapter_slug": "outcomes-vs-outputs",
"attempts": 3,
"best_score": 0.92,
"latest_score": 0.92,
"passed": true,
"last_attempt_at": "2026-04-29T14:11:09+00:00"
}
],
"pagination": { "limit": 100, "next_cursor": null, "has_more": false }
}
GET
/v1/training/chapters
Catalogue of chapter slugs, titles, and pass thresholds.
Reference data — the chapter catalogue used by the training endpoint. Cache it.
Parameters
None.
Example response
{
"data": [
{ "slug": "outcomes-vs-outputs", "title": "Outcomes vs outputs", "pass_threshold": 0.8 },
{ "slug": "control-points", "title": "Control points", "pass_threshold": 0.8 }
]
}
GET
/v1/projects
Projects in your organisation with embedded owner + DRI.
Each row embeds the project's owner and DRI as {short_id, full_name} blocks, and the owning team as a {short_id, name} block (null for org-wide projects). Use the filters to scope by status or person.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
status | string | optional | One of active, paused, completed, archived. |
owner_short_id | string | optional | Restrict to projects owned by this user. |
dri_short_id | string | optional | Restrict to projects with this DRI. |
limit | integer | optional | 1–500. Default 100. |
cursor | string | optional | Opaque cursor for pagination. |
Example response
{
"data": [
{
"short_id": "p_8x4q2r",
"name": "ERP migration",
"description": "Move ledger off legacy system.",
"status": "active",
"classification": "internal",
"visibility": "org",
"outcome_mission": "Close books in 5 days, not 12.",
"delivery_mission": "Cutover by Q3.",
"start_date": "2026-02-01",
"target_date": "2026-09-30",
"cadence_days": 14,
"owner": { "short_id": "u_1a2b3c", "full_name": "Alex Rivera" },
"dri": { "short_id": "u_4z9k1m", "full_name": "Sam Patel" },
"team": { "short_id": "t_3h6j9k", "name": "Finance" },
"created_at": "2026-01-22T09:00:00+00:00",
"updated_at": "2026-05-09T17:14:02+00:00"
}
],
"pagination": { "limit": 100, "next_cursor": "eyJpZCI6OTl9", "has_more": true }
}
GET
/v1/projects/{short_id}
Single project detail with embedded pillars.
Same shape as a list row, plus a pillars array (sorted by sort_order).
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
short_id | string | required | Project short id (e.g. p_8x4q2r). |
Example response (excerpt)
{
"short_id": "p_8x4q2r",
"name": "ERP migration",
"status": "active",
...
"pillars": [
{ "name": "Cutover plan", "description": "...", "sort_order": 1, "owner": { "short_id": "u_4z9k1m", "full_name": "Sam Patel" } },
{ "name": "Data migration", "description": "...", "sort_order": 2, "owner": null }
]
}
GET
/v1/check-ins
Cross-project check-in feed — primary endpoint for graph reproduction.
Each row carries the full per-discipline score map, the narrative block, and embedded project + owner — enough to render dashboards directly without a second call.
If from is omitted, results are capped to the last 365 days.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
from | ISO date / timestamp | optional | Inclusive lower bound on created_at. |
to | ISO date / timestamp | optional | Exclusive upper bound on created_at. |
project_short_id | string | optional | Restrict to one project. |
owner_short_id | string | optional | Restrict to projects owned by this user. |
limit | integer | optional | 1–500. Default 100. |
cursor | string | optional | Opaque cursor for pagination. |
Example response
{
"data": [
{
"short_id": "ci_77kq3a",
"created_at": "2026-05-08T15:45:11+00:00",
"project": { "short_id": "p_8x4q2r", "name": "ERP migration" },
"owner": { "short_id": "u_1a2b3c", "full_name": "Alex Rivera" },
"submitted_by": { "short_id": "u_4z9k1m", "full_name": "Sam Patel" },
"aggregate_score": 3.4,
"aggregate_status": "managed",
"discipline_scores": { "outcomes": 4, "delivery": 3, "risk": 3, "comms": 4, ... },
"mission_confirmed": true,
"narrative": {
"changed_since_last_time": "Cutover date moved by 2 weeks.",
"most_at_risk": "Data migration — schema gaps surfacing late.",
"next_control_point": "Dry-run cutover on 2026-06-01.",
"is_anything_drifting": "Comms cadence slipped to monthly.",
"is_escalation_needed": false,
"notes": null
}
}
],
"pagination": { "limit": 100, "next_cursor": "eyJjcmVhdGVkX2F0Ijoi...", "has_more": true }
}
GET
/v1/projects/{short_id}/check-ins
Convenience: same shape as /v1/check-ins with the project pinned.
Equivalent to GET /v1/check-ins?project_short_id={short_id}. Accepts the same from, to, limit, and cursor query parameters.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
short_id | string | required | Project short id. |
GET
/v1/control-points
Cross-project coaching commitments (control points).
External owners (people without a user record) appear with "short_id": null, "external": true on the owner block.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
status | string | optional | One of open, held, slipped, drifted. |
project_short_id | string | optional | Restrict to one project. |
owner_short_id | string | optional | Restrict to control points owned by this user. |
limit | integer | optional | 1–500. Default 100. |
cursor | string | optional | Opaque cursor for pagination. |
Example response
{
"data": [
{
"short_id": "cp_2m5n7p",
"project": { "short_id": "p_8x4q2r", "name": "ERP migration" },
"owner": { "short_id": "u_4z9k1m", "full_name": "Sam Patel" },
"status": "open",
"statement": "Sign off the data-mapping doc with Finance.",
"target_date": "2026-05-22",
"next_step": "Walk Finance through the field mapping on 14 May.",
"decision_or_escalation_date": null,
"decision_or_escalation_note": null,
"fallback": "Escalate to CFO if no sign-off by 2026-05-29.",
"created_at": "2026-05-01T08:30:00+00:00",
"updated_at": "2026-05-09T11:02:18+00:00"
}
],
"pagination": { "limit": 100, "next_cursor": null, "has_more": false }
}
GET
/v1/friction
Friction-log entries across the organisation.
Each entry embeds the logging user and, when set, the related project. Review fields (frequency, consequence, detectability, pain_nature, pain_type, outcome) stay null until the entry has been through a Systemise Review.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
status | string | optional | One of open, reviewed, archived. |
user_short_id | string | optional | Restrict to entries logged by this user. |
project_short_id | string | optional | Restrict to entries tied to this project. |
limit | integer | optional | 1–500. Default 100. |
cursor | string | optional | Opaque cursor for pagination. |
Example response
{
"data": [
{
"short_id": "f_9q2w4e",
"title": "Chasing finance for sign-off again",
"context": "Third time this month.",
"status": "reviewed",
"user": { "short_id": "u_1a2b3c", "full_name": "Alex Rivera" },
"project": { "short_id": "p_8x4q2r", "name": "ERP migration" },
"frequency": 3,
"consequence": 4,
"detectability": 2,
"pain_nature": "informational",
"pain_type": "status_chasing",
"outcome": "mechanism",
"reviewed_at": "2026-05-06T10:15:00+00:00",
"created_at": "2026-05-02T08:30:00+00:00",
"updated_at": "2026-05-06T10:15:00+00:00"
}
],
"pagination": { "limit": 100, "next_cursor": null, "has_more": false }
}
GET
/v1/elearning
Per-learner e-learning sub-module progress.
One row per user × course × sub-module. steps_seen counts the distinct steps the learner has visited; is_complete is sticky once the sub-module is finished.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
user_short_id | string | optional | Restrict to a single learner. |
course_slug | string | optional | Restrict to a single course. |
limit | integer | optional | 1–500. Default 100. |
cursor | string | optional | Opaque cursor for pagination. |
Example response
{
"data": [
{
"user": { "short_id": "u_1a2b3c", "full_name": "Alex Rivera" },
"course_slug": "orientation",
"submodule_slug": "why-control",
"last_step_key": "recap",
"steps_seen": 5,
"is_complete": true,
"started_at": "2026-03-01T09:10:00+00:00",
"last_visited_at": "2026-03-01T09:38:00+00:00",
"completed_at": "2026-03-01T09:38:00+00:00",
"completed_content_version": 2
}
],
"pagination": { "limit": 100, "next_cursor": null, "has_more": false }
}
Code examples
Each example fetches all /v1/check-ins rows since 1 Jan 2026, paginating
until has_more is false. Swap the URL for any other
endpoint — the auth header and pagination shape are the same everywhere. The
language samples (Python, Go, TypeScript, JavaScript, Java, C#, PHP, Ruby, R)
include a small retry helper that honours Retry-After on
429 and 5xx, plus a hard page-count safety cap.
The Postman/Bruno collection, curl, and Power Query are kept as the minimal reference.
Postman / Bruno Importable collection — all endpoints, auth, and pagination params pre-wired.
The fastest way to explore the API. Download the collection, set the
apiKey variable to your cs_live_... key, and click any
request. Bruno can import Postman v2.1 collections directly, so the same file
works for both tools.
Download Postman collection (.json)
Postman
- File → Import, drop the JSON in.
- Open the collection's Variables tab and set
apiKeyto your key. - Optionally change
baseUrlif you're testing against a non-production environment. - Run any request — auth is inherited at the collection level.
Bruno
- Collection → Import → Postman Collection and pick the file.
- Open the imported collection's Variables and set
apiKey. - Bruno stores the collection as files in a folder you choose — safe to commit (just don't commit the env with the key).
curl One-liner for smoke tests and shell scripts.
curl "https://api.controlstandard.tools/v1/check-ins?from=2026-01-01&limit=200" \ -H "Authorization: Bearer $CONTROLSTANDARD_API_KEY" # Follow the cursor: curl "https://api.controlstandard.tools/v1/check-ins?cursor=eyJjcmVhdGVkX2F0Ijoi..." \ -H "Authorization: Bearer $CONTROLSTANDARD_API_KEY"
Python
Using requests, with cursor pagination.
import os, time, requests
KEY = os.environ["CONTROLSTANDARD_API_KEY"]
BASE = "https://api.controlstandard.tools/v1"
MAX_PAGES, MAX_RETRIES = 1000, 4
session = requests.Session()
session.headers["Authorization"] = f"Bearer {KEY}"
def get_json(path, params):
for attempt in range(MAX_RETRIES):
r = session.get(f"{BASE}{path}", params=params, timeout=15)
if r.status_code in (429,) or r.status_code >= 500:
wait = int(r.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait); continue
r.raise_for_status()
return r.json()
raise RuntimeError(f"giving up after {MAX_RETRIES} retries: {r.status_code}")
rows, cursor = [], None
for _ in range(MAX_PAGES):
params = {"from": "2026-01-01", "limit": 200}
if cursor: params["cursor"] = cursor
page = get_json("/check-ins", params)
rows.extend(page["data"])
if not page["pagination"]["has_more"]: break
cursor = page["pagination"]["next_cursor"]
else:
raise RuntimeError("hit MAX_PAGES — bailing out")
print(f"{len(rows)} check-ins")
Go Standard library only — no third-party deps.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
const (
baseURL = "https://api.controlstandard.tools/v1"
maxPages = 1000
maxRetries = 4
)
type project struct {
ShortID string `json:"short_id"`
Name string `json:"name"`
}
type checkIn struct {
ShortID string `json:"short_id"`
CreatedAt time.Time `json:"created_at"`
Project project `json:"project"`
AggregateScore float64 `json:"aggregate_score"`
DisciplineScores map[string]int `json:"discipline_scores"`
}
type page struct {
Data []checkIn `json:"data"`
Pagination struct {
Limit int `json:"limit"`
NextCursor *string `json:"next_cursor"`
HasMore bool `json:"has_more"`
} `json:"pagination"`
}
func getJSON(client *http.Client, key, path string, q url.Values, out any) error {
for attempt := 0; attempt < maxRetries; attempt++ {
req, _ := http.NewRequest("GET", baseURL+path+"?"+q.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+key)
res, err := client.Do(req)
if err != nil {
return err
}
if res.StatusCode == 429 || res.StatusCode >= 500 {
wait, _ := strconv.Atoi(res.Header.Get("Retry-After"))
if wait == 0 {
wait = 1 << attempt
}
res.Body.Close()
time.Sleep(time.Duration(wait) * time.Second)
continue
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("status %d", res.StatusCode)
}
return json.NewDecoder(res.Body).Decode(out)
}
return fmt.Errorf("giving up after %d retries", maxRetries)
}
func main() {
key := os.Getenv("CONTROLSTANDARD_API_KEY")
client := &http.Client{Timeout: 15 * time.Second}
var rows []checkIn
var cursor string
for i := 0; i < maxPages; i++ {
q := url.Values{"from": {"2026-01-01"}, "limit": {"200"}}
if cursor != "" {
q.Set("cursor", cursor)
}
var p page
if err := getJSON(client, key, "/check-ins", q, &p); err != nil {
panic(err)
}
rows = append(rows, p.Data...)
if !p.Pagination.HasMore {
cursor = ""
break
}
cursor = *p.Pagination.NextCursor
}
if cursor != "" {
panic("hit maxPages — bailing out")
}
fmt.Printf("%d check-ins\n", len(rows))
}
TypeScript
Node 18+ with built-in fetch; typed response.
type CheckIn = {
short_id: string;
created_at: string;
project: { short_id: string; name: string };
aggregate_score: number;
discipline_scores: Record<string, number>;
};
type Page<T> = {
data: T[];
pagination: { limit: number; next_cursor: string | null; has_more: boolean };
};
const KEY = process.env.CONTROLSTANDARD_API_KEY!;
const BASE = "https://api.controlstandard.tools/v1";
const MAX_PAGES = 1000;
const MAX_RETRIES = 4;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function getJson<T>(url: URL): Promise<T> {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
if (res.status === 429 || res.status >= 500) {
const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await sleep(wait * 1000);
continue;
}
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return (await res.json()) as T;
}
throw new Error(`giving up after ${MAX_RETRIES} retries`);
}
async function listCheckIns(from: string): Promise<CheckIn[]> {
const out: CheckIn[] = [];
let cursor: string | null = null;
for (let i = 0; i < MAX_PAGES; i++) {
const url = new URL(`${BASE}/check-ins`);
url.searchParams.set("from", from);
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await getJson<Page<CheckIn>>(url);
out.push(...page.data);
if (!page.pagination.has_more) return out;
cursor = page.pagination.next_cursor;
}
throw new Error("hit MAX_PAGES — bailing out");
}
listCheckIns("2026-01-01").then((rows) => console.log(`${rows.length} check-ins`));
JavaScript
Plain Node.js / browser-side fetch. Don't ship the key to a browser.
⚠ Browser-side calls expose the API key to end users. Run this server-side, or proxy through your own backend.
const KEY = process.env.CONTROLSTANDARD_API_KEY;
const BASE = "https://api.controlstandard.tools/v1";
const MAX_PAGES = 1000;
const MAX_RETRIES = 4;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getJson(url) {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
if (res.status === 429 || res.status >= 500) {
const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await sleep(wait * 1000);
continue;
}
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}
throw new Error(`giving up after ${MAX_RETRIES} retries`);
}
async function listCheckIns(from) {
const rows = [];
let cursor = null;
for (let i = 0; i < MAX_PAGES; i++) {
const url = new URL(`${BASE}/check-ins`);
url.searchParams.set("from", from);
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await getJson(url);
rows.push(...page.data);
if (!page.pagination.has_more) return rows;
cursor = page.pagination.next_cursor;
}
throw new Error("hit MAX_PAGES — bailing out");
}
listCheckIns("2026-01-01").then((rows) => console.log(`${rows.length} check-ins`));
PHP
PHP 8 with Guzzle. Composer: composer require guzzlehttp/guzzle.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
const MAX_PAGES = 1000;
const MAX_RETRIES = 4;
$client = new Client([
'base_uri' => 'https://api.controlstandard.tools/v1/',
'headers' => [
'Authorization' => 'Bearer ' . getenv('CONTROLSTANDARD_API_KEY'),
'Accept' => 'application/json',
],
'timeout' => 15,
'http_errors' => false,
]);
function get_json(Client $c, string $path, array $query): array {
for ($attempt = 0; $attempt < MAX_RETRIES; $attempt++) {
$res = $c->get($path, ['query' => $query]);
$status = $res->getStatusCode();
if ($status === 429 || $status >= 500) {
$wait = (int) ($res->getHeaderLine('Retry-After') ?: (2 ** $attempt));
sleep($wait); continue;
}
if ($status >= 400) {
throw new RuntimeException("$status: " . $res->getBody());
}
return json_decode($res->getBody()->getContents(), true);
}
throw new RuntimeException('giving up after ' . MAX_RETRIES . ' retries');
}
$cursor = null;
$rows = [];
for ($i = 0; $i < MAX_PAGES; $i++) {
$query = ['from' => '2026-01-01', 'limit' => 200];
if ($cursor) $query['cursor'] = $cursor;
$page = get_json($client, 'check-ins', $query);
$rows = array_merge($rows, $page['data']);
if (!$page['pagination']['has_more']) break;
$cursor = $page['pagination']['next_cursor'];
}
if ($i === MAX_PAGES) throw new RuntimeException('hit MAX_PAGES — bailing out');
echo count($rows) . " check-ins\n";
Java
Java 17+, built-in HttpClient + Jackson for JSON.
Add com.fasterxml.jackson.core:jackson-databind to your build for JSON parsing.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public class CheckIns {
static final String BASE = "https://api.controlstandard.tools/v1";
static final int MAX_PAGES = 1000;
static final int MAX_RETRIES = 4;
static final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15)).build();
static final ObjectMapper mapper = new ObjectMapper();
static JsonNode getJson(String key, String url) throws Exception {
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer " + key)
.timeout(Duration.ofSeconds(15)).GET().build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 429 || res.statusCode() >= 500) {
long wait = res.headers().firstValue("Retry-After")
.map(Long::parseLong).orElse((long) Math.pow(2, attempt));
Thread.sleep(wait * 1000);
continue;
}
if (res.statusCode() >= 400) {
throw new RuntimeException(res.statusCode() + ": " + res.body());
}
return mapper.readTree(res.body());
}
throw new RuntimeException("giving up after " + MAX_RETRIES + " retries");
}
public static void main(String[] args) throws Exception {
String key = System.getenv("CONTROLSTANDARD_API_KEY");
List<JsonNode> rows = new ArrayList<>();
String cursor = null;
for (int i = 0; i < MAX_PAGES; i++) {
String url = BASE + "/check-ins?from=2026-01-01&limit=200";
if (cursor != null) {
url += "&cursor=" + URLEncoder.encode(cursor, StandardCharsets.UTF_8);
}
JsonNode page = getJson(key, url);
page.get("data").forEach(rows::add);
if (!page.get("pagination").get("has_more").asBoolean()) { cursor = null; break; }
cursor = page.get("pagination").get("next_cursor").asText();
}
if (cursor != null) throw new RuntimeException("hit MAX_PAGES — bailing out");
System.out.println(rows.size() + " check-ins");
}
}
C#
.NET 8, HttpClient + System.Text.Json.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
record Project(string short_id, string name);
record CheckIn(
string short_id,
DateTime created_at,
Project project,
double aggregate_score,
Dictionary<string, int> discipline_scores
);
record PageMeta(int limit, string? next_cursor, bool has_more);
record Page<T>(List<T> data, PageMeta pagination);
const int MAX_PAGES = 1000;
const int MAX_RETRIES = 4;
var key = Environment.GetEnvironmentVariable("CONTROLSTANDARD_API_KEY")!;
using var http = new HttpClient { BaseAddress = new Uri("https://api.controlstandard.tools/v1/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
async Task<T> GetJsonAsync<T>(string url)
{
for (var attempt = 0; attempt < MAX_RETRIES; attempt++)
{
var res = await http.GetAsync(url);
if (res.StatusCode == (HttpStatusCode)429 || (int)res.StatusCode >= 500)
{
var wait = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(wait);
continue;
}
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>())!;
}
throw new Exception($"giving up after {MAX_RETRIES} retries");
}
var rows = new List<CheckIn>();
string? cursor = null;
for (var i = 0; i < MAX_PAGES; i++)
{
var url = $"check-ins?from=2026-01-01&limit=200"
+ (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
var page = await GetJsonAsync<Page<CheckIn>>(url);
rows.AddRange(page.data);
if (!page.pagination.has_more) { cursor = null; break; }
cursor = page.pagination.next_cursor;
}
if (cursor is not null) throw new Exception("hit MAX_PAGES — bailing out");
Console.WriteLine($"{rows.Count} check-ins");
Ruby
Standard library only — net/http and json.
require 'net/http'
require 'json'
require 'uri'
KEY = ENV.fetch('CONTROLSTANDARD_API_KEY')
BASE = 'https://api.controlstandard.tools/v1'
MAX_PAGES = 1000
MAX_RETRIES = 4
def get_json(path, params)
uri = URI("#{BASE}#{path}")
uri.query = URI.encode_www_form(params)
MAX_RETRIES.times do |attempt|
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{KEY}"
http.request(req)
end
if res.code.to_i == 429 || res.code.to_i >= 500
wait = (res['Retry-After'] || (2**attempt)).to_i
sleep wait
next
end
raise "#{res.code}: #{res.body}" if res.code.to_i >= 400
return JSON.parse(res.body)
end
raise "giving up after #{MAX_RETRIES} retries"
end
rows = []
cursor = nil
MAX_PAGES.times do
params = { from: '2026-01-01', limit: 200 }
params[:cursor] = cursor if cursor
page = get_json('/check-ins', params)
rows.concat(page['data'])
break (cursor = nil) unless page['pagination']['has_more']
cursor = page['pagination']['next_cursor']
end
raise 'hit MAX_PAGES — bailing out' if cursor
puts "#{rows.size} check-ins"
R
Tidyverse-friendly with httr2; output ready for dplyr / ggplot2.
Install once: install.packages(c("httr2", "dplyr", "purrr")).
library(httr2)
library(dplyr)
library(purrr)
key <- Sys.getenv("CONTROLSTANDARD_API_KEY")
base <- "https://api.controlstandard.tools/v1"
MAX_PAGES <- 1000
get_json <- function(path, query) {
request(paste0(base, path)) |>
req_url_query(!!!query) |>
req_auth_bearer_token(key) |>
req_retry(
max_tries = 4,
is_transient = \(resp) resp_status(resp) %in% c(429, 500, 502, 503, 504),
after = \(resp) as.numeric(resp_header(resp, "Retry-After") %||% NA)
) |>
req_perform() |>
resp_body_json()
}
rows <- list()
cursor <- NULL
for (i in seq_len(MAX_PAGES)) {
q <- list(from = "2026-01-01", limit = 200)
if (!is.null(cursor)) q$cursor <- cursor
page <- get_json("/check-ins", q)
rows <- c(rows, page$data)
if (!page$pagination$has_more) { cursor <- NULL; break }
cursor <- page$pagination$next_cursor
}
if (!is.null(cursor)) stop("hit MAX_PAGES — bailing out")
# Flatten the JSON list into a tidy data frame for dplyr/ggplot2.
check_ins <- map_dfr(rows, \(ci) tibble(
short_id = ci$short_id,
created_at = as.POSIXct(ci$created_at, format = "%Y-%m-%dT%H:%M:%S", tz = "UTC"),
project_name = ci$project$name,
owner = ci$owner$full_name %||% NA_character_,
aggregate_score = ci$aggregate_score
))
cat(nrow(check_ins), "check-ins\n")
Looker Studio Apps Script community connector — the proper way to wire a custom API in.
Looker Studio doesn't speak arbitrary REST natively. The supported route is a
Community Connector: a small Apps Script project that fetches the
API and hands rows back as a schema. Create one at
script.google.com,
paste the snippet below, set the API key in Project Settings → Script
Properties as API_KEY, then publish as a connector and add it
to your Looker Studio report.
Looker Studio caches connector responses for ~12 hours by default — exactly what you want for keeping inside the rate limits.
// Code.gs — minimal Looker Studio Community Connector for /v1/check-ins.
var cc = DataStudioApp.createCommunityConnector();
function getAuthType() {
return cc.newAuthTypeResponse().setAuthType(cc.AuthType.NONE).build();
}
function isAdminUser() { return false; }
function getConfig() {
var config = cc.getConfig();
config.newTextInput()
.setId('from')
.setName('Earliest check-in date (YYYY-MM-DD)')
.setHelpText('Leave blank to default to the last 365 days.');
return config.build();
}
function getSchema() {
var fields = cc.getFields();
var types = cc.FieldType;
var aggs = cc.AggregationType;
fields.newDimension().setId('check_in_short_id').setType(types.TEXT);
fields.newDimension().setId('project_short_id').setType(types.TEXT);
fields.newDimension().setId('project_name').setType(types.TEXT);
fields.newDimension().setId('owner_full_name').setType(types.TEXT);
fields.newDimension().setId('created_at').setType(types.YEAR_MONTH_DAY_HOUR);
fields.newMetric().setId('aggregate_score').setType(types.NUMBER).setAggregation(aggs.AVG);
return { schema: fields.build() };
}
function getData(request) {
var key = PropertiesService.getScriptProperties().getProperty('API_KEY');
var url = 'https://api.controlstandard.tools/v1/check-ins?limit=200';
var from = request.configParams && request.configParams.from;
if (from) url += '&from=' + encodeURIComponent(from);
var rows = [];
var cursor = null;
do {
var pageUrl = cursor ? url + '&cursor=' + encodeURIComponent(cursor) : url;
var res = UrlFetchApp.fetch(pageUrl, {
headers: { Authorization: 'Bearer ' + key },
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) {
cc.newUserError().setText('API error: ' + res.getContentText()).throwException();
}
var page = JSON.parse(res.getContentText());
page.data.forEach(function (ci) {
rows.push({ values: [
ci.short_id,
ci.project ? ci.project.short_id : null,
ci.project ? ci.project.name : null,
ci.owner ? ci.owner.full_name : null,
ci.created_at ? ci.created_at.replace(/[-:T]/g, '').slice(0, 10) : null,
ci.aggregate_score,
]});
});
cursor = page.pagination.has_more ? page.pagination.next_cursor : null;
} while (cursor);
var requested = request.fields.map(function (f) { return { name: f.name }; });
return { schema: requested, rows: rows };
}
For a quick-and-dirty alternative: pipe the API into a Google Sheet (via a scheduled Apps Script) and connect Looker Studio to the Sheet. Less elegant, but fine for a single dashboard.
Power BI Power Query M — first page. For Power BI Service, store the key in a parameter.
let
Source = Json.Document(Web.Contents(
"https://api.controlstandard.tools/v1/check-ins",
[
Headers = [#"Authorization" = "Bearer cs_live_..."],
Query = [from = "2026-01-01", limit = "200"]
]
)),
Rows = Source[data],
Table = Table.FromRecords(Rows)
in
Table
For Power BI Service refreshes, store the key in a parameter and add it via Anonymous credentials on the data source. Do not hard-code the key in shared queries. To paginate, wrap the call in a List.Generate that follows pagination.next_cursor until has_more is false.
Versioning
v1 is stable. Additive changes (new fields, new endpoints) ship without
a version bump; breaking changes go to v2 with a deprecation period and
this page will list the changes when they happen.
Need help? Email hello@controlstandard.tools
and quote the request_id from any failing response.