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.scope and the top-level team field in /v1/me to 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.

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:

  • 400 invalid_argument — bad query parameter (e.g. malformed date).
  • 401 unauthorized — missing, invalid, expired, or revoked key.
  • 403 forbidden / plan_required — org inactive or below Business plan.
  • 404 not_found — resource doesn't exist or isn't visible to your organisation.
  • 429 rate_limited — see Rate limits.
  • 500 internal_error — quote request_id in 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

NameTypeRequiredDescription
include_archivedbooleanoptionalInclude archived and deactivated users. Defaults to false.
limitintegeroptional1–500. Default 100.
cursorstringoptionalOpaque 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

NameTypeRequiredDescription
include_archivedbooleanoptionalInclude archived teams. Defaults to false.
limitintegeroptional1–500. Default 100.
cursorstringoptionalOpaque 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

NameTypeRequiredDescription
user_short_idstringoptionalRestrict to a single user.
chapter_slugstringoptionalRestrict to a single chapter.
limitintegeroptional1–500. Default 100.
cursorstringoptionalOpaque 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

NameTypeRequiredDescription
statusstringoptionalOne of active, paused, completed, archived.
owner_short_idstringoptionalRestrict to projects owned by this user.
dri_short_idstringoptionalRestrict to projects with this DRI.
limitintegeroptional1–500. Default 100.
cursorstringoptionalOpaque 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

NameTypeRequiredDescription
short_idstringrequiredProject 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

NameTypeRequiredDescription
fromISO date / timestampoptionalInclusive lower bound on created_at.
toISO date / timestampoptionalExclusive upper bound on created_at.
project_short_idstringoptionalRestrict to one project.
owner_short_idstringoptionalRestrict to projects owned by this user.
limitintegeroptional1–500. Default 100.
cursorstringoptionalOpaque 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

NameTypeRequiredDescription
short_idstringrequiredProject 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

NameTypeRequiredDescription
statusstringoptionalOne of open, held, slipped, drifted.
project_short_idstringoptionalRestrict to one project.
owner_short_idstringoptionalRestrict to control points owned by this user.
limitintegeroptional1–500. Default 100.
cursorstringoptionalOpaque 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

NameTypeRequiredDescription
statusstringoptionalOne of open, reviewed, archived.
user_short_idstringoptionalRestrict to entries logged by this user.
project_short_idstringoptionalRestrict to entries tied to this project.
limitintegeroptional1–500. Default 100.
cursorstringoptionalOpaque 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

NameTypeRequiredDescription
user_short_idstringoptionalRestrict to a single learner.
course_slugstringoptionalRestrict to a single course.
limitintegeroptional1–500. Default 100.
cursorstringoptionalOpaque 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

  1. File → Import, drop the JSON in.
  2. Open the collection's Variables tab and set apiKey to your key.
  3. Optionally change baseUrl if you're testing against a non-production environment.
  4. Run any request — auth is inherited at the collection level.

Bruno

  1. Collection → ImportPostman Collection and pick the file.
  2. Open the imported collection's Variables and set apiKey.
  3. 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.