TypeScript SDK

Official Node/TypeScript client for the ProjeX API. Use it from scripts, CI jobs, and backend services to discover live cohorts, create milestone or task submissions, and administer cohorts, teams and their members — all with the same rules as the web app.

Example apps

Start here if you want a runnable Next.js app instead of copying snippets. Set PROJEX_API_URL and PROJEX_API_KEY from Account → API keys, then clone the sample that matches how you integrate.

Example apps

Clone these Next.js samples to try the same flow as Account → API keys: live cohorts → milestones/tasks → submit (with optional file attachments).

Repo URLs come from env: NEXT_PUBLIC_SUBMISSION_API_EXAMPLE_REPO and NEXT_PUBLIC_SUBMISSION_SDK_EXAMPLE_REPO.

Overview

@pkg-projex/sdk is a thin, typed wrapper over the public REST API at /api/v1. Each SDK method maps to exactly one HTTP request. There is no separate business logic in the client — validation, submission windows, team resolution, and authorization are enforced on the server.

Typical flow: authenticate with a personal API key → list the caller's live cohorts → load milestones or tasks → submit (or resubmit) work. The package is designed for server-side and automation environments (Node 18+, Bun, Deno, edge workers). Do not embed API keys in browser bundles or public frontends.

Prefer this SDK when you want typed methods and automatic retries. Prefer the OpenAPI / Swagger explorer when integrating from another language or debugging raw HTTP.

Installation

Install from npm under the @pkg-projex scope. The package is ESM-first (type: module) and ships TypeScript declarations.

npm install @pkg-projex/sdk
# or
pnpm add @pkg-projex/sdk
# or
yarn add @pkg-projex/sdk

Runtime requirements

RequirementDetails
fetchMust be available globally (Node 18+, modern browsers, edge runtimes). On older Node, pass a fetch polyfill via the fetch client option.
Module systemESM import. CommonJS consumers should use dynamic import() or a bundler.
SecretsStore PROJEX_API_KEY in environment variables or a secrets manager — never commit keys to source control.

Authentication & scopes

Every request is authenticated as the user who owns the API key. Generate a key in Account → API keys. The plaintext secret is shown once at creation and looks like pjx_live_….

The SDK attaches Authorization: Bearer <apiKey> on every call (equivalent to the x-api-key header for raw HTTP). Scopes on the key can only narrow what that user could already do in the product — they never escalate privileges. Ownership and deadline checks still apply in the service layer.

Available scopes

ScopeGrants access toSDK methods
cohorts:readDiscover cohorts the key owner is enrolled in, including run status (planned / live / closed).cohorts.list, live, get
milestones:readList milestones and tasks inside a cohort the user can access.cohorts.milestones, cohorts.tasks
submissions:readRead submissions previously created by the key owner for a milestone or task.milestones.submissions, tasks.submissions
submissions:writeCreate new submissions and request presigned upload URLs.milestones.submit, tasks.submit, uploads.presign
cohorts:writeCreate, update and soft-delete cohorts.cohorts.create, update, delete
teams:readList the teams inside a cohort.teams.list
teams:writeCreate, update and soft-delete teams.teams.create, update, delete
cohort-members:writeEnrol, re-role and remove cohort members.cohorts.members.*
team-members:writeAdd, re-role and remove a team’s learners and staff.teams.members.*
Missing a required scope returns 403 insufficient_scope. Revoking the key in Account immediately invalidates all subsequent SDK calls. For the five administration scopes the scope is only half the check — the key owner must also hold the matching role in that specific cohort or team. A manager of cohort A can do nothing in cohort B, and a learner’s key with cohorts:write still creates nothing.

Configuration

Construct a single ProjexClient and reuse it. Configuration resolves in this order for each option: constructor argument environment variable built-in default. An API key is mandatory (via argument or PROJEX_API_KEY); the client throws at construction time if neither is set.

OptionTypeEnv / defaultDescription
apiKeystringPROJEX_API_KEY (required)Personal access token. Sent as a Bearer credential. Never log or expose this value.
baseUrlstringPROJEX_API_URL, else https://projex.xceleratordemo.inApplication origin only (scheme + host, optional port). Do not include /api/v1 — the SDK appends it.
timeoutMsnumber30000Abort the underlying fetch if the server does not respond within this many milliseconds.
maxRetriesnumber2Additional attempts after the first failure for HTTP 429 and 5xx responses. Uses exponential backoff and respects Retry-After when present.
fetchtypeof fetchglobalThis.fetchInject a custom fetch implementation for tests or older runtimes.

Examples

import { ProjexClient } from '@pkg-projex/sdk';

// Recommended for CI / local scripts: configure via env only
const projex = new ProjexClient();

// Explicit overrides (win over env)
const staging = new ProjexClient({
  apiKey: process.env.PROJEX_API_KEY!,
  baseUrl: 'https://staging.example.com',
  timeoutMs: 45_000,
  maxRetries: 3,
});
# .env / shell
# Local ProjeX app/API origin
export PROJEX_API_URL="http://localhost:3000"
export PROJEX_API_KEY="pjx_live_…"

Quickstart

End-to-end example: find a live cohort, pick the first milestone with a public ref, and create a submission. Adjust error handling for production use.

import { ProjexClient, ProjexApiError } from '@pkg-projex/sdk';

const projex = new ProjexClient();

try {
  // List methods return a page: { data, nextCursor }.
  const live = (await projex.cohorts.live()).data;
  if (!live.length) {
    throw new Error('No live cohorts for this account');
  }

  const milestones = (await projex.cohorts.milestones(live[0].id)).data;
  const milestone = milestones.find((m) => m.ref) ?? milestones[0];
  if (!milestone) {
    throw new Error('No milestones in cohort');
  }

  // Prefer a full T{team}M{ms} ref when available from your workflow.
  const ref = milestone.ref ?? milestone.id;

  const { submission } = await projex.milestones.submit(ref, {
    title: 'Sprint deliverable',
    submission: 'https://github.com/org/repo/pull/42',
  });

  console.log('Created submission', submission.id);
} catch (err) {
  if (err instanceof ProjexApiError) {
    console.error(err.status, err.code, err.message, err.requestId);
  }
  throw err;
}

Cohorts

Cohort methods discover which programs the authenticated user belongs to and what they can work on. List endpoints only return cohorts where the user is a team member. Milestone and task listing additionally require the milestones:read scope.

Cohort status is derived server-side from start/end dates: planned (not started), live (currently open), closed (ended). Most submission workflows should start with cohorts.live().

projex.cohorts.list(opts?)

Returns every cohort the API key owner is enrolled in, optionally filtered by run status. Use this when you need planned or closed cohorts as well as live ones (for example reporting). For day-to-day submission scripts, prefer live().

Parameters

NameTypeRequiredDescription
opts.status'planned' | 'live' | 'closed'NoWhen set, only cohorts in that phase are returned. When omitted, all enrolled cohorts are returned.
opts.qstringNoFree-text search over name/id/type.
opts.limitnumberNoPage size (default 50, max 100).
opts.cursorstringNoOpaque cursor from a previous page’s nextCursor.

Returns

Promise<Page<Cohort>> { data, nextCursor }. Iterate every page with projex.cohorts.iterate(opts?).

type Page<T> = { data: T[]; nextCursor: string | null };

type Cohort = {
  id: string;                 // Stable cohort UUID — use with get / milestones / tasks
  name: string;               // Display name
  type: string | null;        // Cohort type when configured
  visibility: string | null;  // Visibility setting when configured
  startDate: string | null;   // ISO-8601 UTC
  endDate: string | null;     // ISO-8601 UTC
  status: 'planned' | 'live' | 'closed';
};

projex.cohorts.live()

Convenience helper equivalent to list({ status: 'live' }). Returns only cohorts that are currently open for work. This is the recommended entry point for submission automations.

Parameters

Accepts the same q / limit / cursor options as list.

Returns

Promise<Page<Cohort>> — same shape as list.

projex.cohorts.get(cohortId)

Loads a single cohort the user belongs to, including the teams they are a member of inside that cohort. Use the teams array when you need an explicit teamId for milestone submission (required only if the user is on more than one team).

Parameters

NameTypeRequiredDescription
cohortIdstringYesCohort UUID from list or live. Returns 404 if the cohort does not exist or the user is not a member.

Returns

type CohortDetail = Cohort & {
  teams: Array<{
    id: string;        // Pass as teamId on milestone submit when needed
    publicId: number;  // Numeric team public id (used in T{{team}}M{{ms}} refs)
    name: string;
  }>;
};

projex.cohorts.milestones(cohortId)

Lists milestones for the cohort, ordered by their configured sequence. Milestones are global to the cohort — the team is only resolved at submit time. Each item’s ref is directly submittable (a fully-qualified T{team}M{ms} when you’re on one team, else the milestone id) — pass it straight to milestones.submit.

Parameters

NameTypeRequiredDescription
cohortIdstringYesCohort UUID. Requires cohorts:read and milestones:read.
opts{ q?, limit?, cursor? }NoSearch + pagination (same as list).

Returns

Promise<Page<Milestone>> — walk pages with projex.cohorts.iterateMilestones(cohortId).

type Milestone = {
  id: string;
  ref: string;                 // Directly submittable (e.g. "T12M3" or the id)
  publicId: number | null;
  title: string;
  description: string | null;
  order: number | null;
  startDate: string | null;    // Submission window start (ISO-8601)
  endDate: string | null;      // Submission window end (ISO-8601)
  submissionType: string | null;
};

projex.cohorts.tasks(cohortId, opts?)

Lists tasks in the cohort. Tasks are team-scoped: you get the tasks of the team(s) you belong to in that cohort (multi-team users get all their teams’). Task refs use the TS{n} form (e.g. TS42) for tasks.submit.

Parameters

NameTypeRequiredDescription
cohortIdstringYesCohort UUID.
opts.milestoneIdstringNoOnly tasks belonging to this milestone id.
opts.assignee'me' | 'others'No'me' = assigned to you; 'others' = assigned to teammates. Omit for all team tasks.
opts{ q?, limit?, cursor? }NoSearch + pagination (same as list).

Returns

Promise<Page<Task>> — walk pages with projex.cohorts.iterateTasks(cohortId, opts?).

type Task = {
  id: string;
  ref: string;                 // e.g. "TS42" — use with tasks.submit
  publicId: number;
  title: string;
  description: string | null;
  status: string;
  priority: string;
  milestoneId: string | null;
  deadline: string | null;     // ISO-8601 when set
};

Milestones

Milestone submissions are attributed to a team in the cohort (not only the individual). The server auto-selects the user's team when they belong to exactly one; if they belong to multiple teams you must pass teamId or the API returns 409 ambiguous_team.

Submissions are append-only: each successful submit inserts a new row. There is no PATCH. Resubmitting before the deadline is another POST; the latest by createdAt is treated as current. Optional replacesSubmissionId records lineage for UI without deleting history. Active redo requests are completed automatically when you submit.

Write operations need submissions:write. Listing needs submissions:read. Outside the allowed window (before start, after end/redo deadline, or cohort closed) the API responds with 422 validation_error.

projex.milestones.submit(ref, input)

Creates a milestone submission (or revision). projex.milestones.resubmit is an alias of the same method for readability in calling code.

Parameters

NameTypeRequiredDescription
refstringYesMilestone reference such as T12M3, or the internal milestone UUID when accepted by the API.
input.titlestringYesHuman-readable title for this submission revision. Must be non-empty.
input.submissionstringYesPrimary payload: typically a URL (GitHub PR, Figma, demo) or markdown/plain-text body. Must be non-empty.
input.teamIdstringConditionalTeam UUID. Required when the user is on more than one team for this cohort/milestone. Omit for the common single-team case.
input.contributionAreasarrayNoOptional breakdown of contribution across teammates. See schema below.
input.reflectionAnswersarrayNoAnswers to reflection questions configured on the milestone: { questionId, answer }[].
input.replacesSubmissionIdstringNoId of a previous submission this revision supersedes. Metadata only — prior rows are retained.

contributionAreas item

{
  contributionAreaId?: string;  // Existing catalog area id
  customAreaName?: string;      // Free-text area when not using catalog
  breakdown: Array<{
    userId: string;             // Teammate user id
    percentage: number;         // Share for this area (typically 0–100)
  }>;
}

Example

const { submission } = await projex.milestones.submit('T12M3', {
  title: 'Sprint 2 build',
  submission: 'https://github.com/me/proj/pull/12',
  teamId: 'optional-when-multi-team',
  contributionAreas: [
    {
      contributionAreaId: 'area-id',
      breakdown: [
        { userId: 'user-a', percentage: 60 },
        { userId: 'user-b', percentage: 40 },
      ],
    },
  ],
  reflectionAnswers: [
    { questionId: 'q1', answer: 'We validated the approach with mentors.' },
  ],
  replacesSubmissionId: 'previous-submission-id',
});

Returns

{
  submission: {
    id: string;
    teamId: string;
    milestoneId: string;
    userId: string;          // Submitter (API key owner)
    title: string;
    submission: string;
    redoAttempt?: number;    // Present when responding to a redo
    isRedo?: boolean;
    createdAt: string | Date;
    updatedAt: string | Date;
    // Additional team_submissions fields may be present
  }
}

projex.milestones.submissions(ref)

Returns submissions created by the authenticated user for the given milestone, newest first. Useful to show history or to obtain an id for replacesSubmissionId.

Parameters

NameTypeRequiredDescription
refstringYesMilestone ref or id (same forms as submit).

Returns

Promise<Page<unknown>> { data, nextCursor }, newest first. Accepts { q?, limit?, cursor? }.

// page.data entries (team_submissions rows):
{
  id: string;
  teamId: string;
  milestoneId: string;
  userId: string;
  title: string;
  submission: string;
  replacesSubmissionId: string | null;
  createdAt: string | Date;
}

Tasks

Task submissions are attributed to the individual user (the API key owner), not a team. No teamId is required. References use the TS{n} public form from cohorts.tasks. Semantics otherwise match milestones: append-only creates, optional revision lineage, server-enforced windows where applicable.

projex.tasks.submit(ref, input)

Creates a task submission. projex.tasks.resubmit is an alias of the same call.

Parameters

NameTypeRequiredDescription
refstringYesTask reference such as TS42, or internal task id when accepted.
input.titlestringYesNon-empty title for this submission.
input.submissionstringYesURL, markdown, or plain-text body describing the completed work.
input.replacesSubmissionIdstringNoOptional id of a prior submission this revision replaces (lineage only).

Example & returns

const { submission } = await projex.tasks.submit('TS42', {
  title: 'Bugfix write-up',
  submission: '## Steps\n1. Reproduced on staging…',
});

// submission: {
//   id, taskId, submittedBy, title, submission, createdAt, …
// }

projex.tasks.submissions(ref)

Lists the caller's submissions for a task, newest first. Requires submissions:read.

Returns

Promise<Page<unknown>> { data, nextCursor }, newest first. Accepts { q?, limit?, cursor? }.

// page.data entries (task_submissions rows):
{
  id: string;
  taskId: string;
  submittedBy: string;
  title: string;
  submission: string;
  replacesSubmissionId: string | null;
  createdAt: string | Date;
}

Uploads

Large files are not posted through the API body. Instead you obtain a short-lived presigned URL, PUT the bytes directly to object storage, then keep the returned fileKey for your workflow. Keys are namespaced per user so one account cannot attach another user's upload.

Requires submissions:write. Maximum size: 50 MB. Allowed contentType prefixes: image/, application/pdf, application/zip, application/octet-stream, text/.

projex.uploads.presign(input)

Step 1 of the upload flow: validates metadata and returns a presigned PUT URL plus storage key.

Parameters

NameTypeRequiredDescription
input.fileNamestringYesOriginal file name (1–255 characters). Sanitized for the object key.
input.contentTypestringYesMIME type. Must match the allowlist prefixes above or the API returns 422.
input.sizenumberNoDeclared size in bytes. When set, must be a positive integer ≤ 50_000_000.

Returns & follow-up

const { uploadUrl, fileKey, publicUrl, expiresIn } =
  await projex.uploads.presign({
    fileName: 'report.pdf',
    contentType: 'application/pdf',
    size: 812345,
  });

// uploadUrl  — presigned PUT target; complete before expiresIn seconds
// fileKey    — e.g. submissions/<userId>/<uuid>/report.pdf
// publicUrl  — the file's URL once uploaded; usable directly as a submission
// expiresIn  — TTL in seconds (typically 3600)

await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'content-type': 'application/pdf' },
  body: fileBytes, // Buffer, Blob, or Uint8Array
});

// Then either attach it, or use publicUrl as the submission value:
await projex.milestones.submit(ref, {
  title: 'Report',
  submission: publicUrl,
  attachments: [{ fileKey, fileName: 'report.pdf' }],
});

Administration

The second surface of the SDK: cohort and team CRUD plus membership, added in 0.2.0. Everything here needs an administration scope on the key and the key owner’s role in that specific cohort or team — see Authentication & scopes. Members are always identified by email, never an internal membership id.

import { ProjexClient } from '@pkg-projex/sdk';

const projex = new ProjexClient();

const cohort = await projex.cohorts.create({
  projectId: 'prj_123',
  name: 'Cohort 12',
  visibility: 'public',        // omitting this means "private" — see below
  startDate: '2026-08-01',
  endDate: '2026-10-01',
});

const team = await projex.teams.create(cohort.id, { name: 'Team Rocket' });

await projex.cohorts.members.add(cohort.id, {
  email: 'ada@example.com',
  role: 'learner',
  teamId: team.id,
});

dryRun & idempotency

Every write takes an optional third argument, AdminOptions, and behaves the same way across all of them.

OptionTypeDescription
dryRunbooleanRuns the full validation and permission check and reports what would happen, writing nothing. Sent as ?dryRun=true.
idempotencyKeystringSent as the Idempotency-Key header; an accidental retry of the same call replays the first response instead of repeating the work.
A test-mode key (pjx_test_…) never writes — every call behaves as a dry run, whatever you pass.

Both deletes return a discriminated union, so narrow on the tag before reading the payload:

type DeleteResult<T> =
  | { ok: true; deleted: T }     // it happened
  | { dryRun: true; impact: T }; // preview only, nothing was written

const preview = await projex.cohorts.delete(cohortId, { dryRun: true });
if ('impact' in preview) {
  console.log(preview.impact); // { teams: 4, members: 37, milestones: 6 }
}

projex.cohorts.create / update / delete

Scope: cohorts:write. create also needs manage-project on the template it runs from, and makes you the new cohort’s manager.

Parameters — create(input, opts?)

NameTypeRequiredDescription
projectIdstringYesThe project template the cohort runs from.
namestringYesNon-empty.
startDatestringYesAny parseable date. endDate must be on or after it.
endDatestringYesSee above.
type'team' | 'individual'No — teamAn individual cohort gives every learner an automatic solo team, and refuses teams.create.
visibility'public' | 'private'No — privateRead the callout below before omitting it.
clusterIdstring | nullOnly when privateThe cluster whose roster seeds the cohort. Accepted and simply stored on a public cohort.
visibility defaults to private, and a private cohort requires a cluster. So the minimal-looking call — projectId, name, dates, nothing else — throws ProjexApiError with code: 'validation_error' (“Cluster is required for private cohorts”). Pass visibility: 'public' if you don’t want a cluster. The same rule applies to update: switching a cohort to private needs a clusterId either in the patch or already on the cohort.

Returns

type AdminCohort = Cohort & {
  projectId: string;
  clusterId: string | null;
};

// update(cohortId, input, opts?) — every field optional; only what you send changes.
// Accepts: name, visibility, clusterId, startDate, endDate.
await projex.cohorts.update(cohortId, { name: 'Cohort 12 (Autumn)' });

// delete(cohortId, opts?) — soft, and it cascades to the cohort's teams,
// members, milestones and submissions. Preview it first.
await projex.cohorts.delete(cohortId);
// → { ok: true, deleted: { teams: 4, members: 37, milestones: 6 } }

projex.cohorts.members

Scope: cohort-members:write. add walks the whole chain — app user → org membership → cohort enrolment → optional team placement — skipping every layer that already exists, so retries are safe and re-adding someone is a no-op rather than an error. Read created to see what actually happened; all four false means they were already there.

await projex.cohorts.members.add(cohortId, {
  email: 'ada@example.com',  // required — the identifier for every member call
  role: 'manager' | 'mentor' | 'evaluator' | 'learner',  // default: learner
  name: 'Ada Lovelace',      // used only if the user has to be created
  teamId: 'team_1',          // optional placement; without it a learner is team-less
  notify: true,              // onboarding email, newly created users only
});
// → {
//     userId, cohortMemberId, role, team: { id, name } | null,
//     created: { user, orgMember, cohortMember, teamMember },  // all booleans
//   }

// Changes the cohort role. Never deletes work, but it does reshuffle team
// placement — cohort role is the source of truth for which roster you sit on.
await projex.cohorts.members.changeRole(cohortId, {
  email: 'ada@example.com',
  role: 'mentor',
});
// → { userId, cohortMemberId, previousRole, role, rosterChanges: {
//       removedFromTeams, addedToTeams, removedStaffFrom, addedStaffTo } }

// Shallow: removes them from the cohort and its teams. Their account and org
// membership survive — they may belong to other cohorts.
await projex.cohorts.members.remove(cohortId, { email: 'ada@example.com' });
// → { ok: true, userId, role }

projex.teams

list needs teams:read; the writes need teams:write. Note the asymmetry in the arguments: teams are created and listed under a cohort, then updated and deleted by team id.

const { data, nextCursor } = await projex.teams.list(cohortId);
// → data: [{ id, publicId, name, description, cohortId, teamKind,
//            memberCount, staffCount }]

// Paged like every list method — 50 per page, 100 max. Filters on top of
// { q, limit, cursor }: teamKind ('multi' | 'solo') and memberEmail.
await projex.teams.list(cohortId, { q: 'rocket', teamKind: 'multi' });

// Or let the SDK walk the pages — an individual cohort has one solo team
// per learner, so this is usually what you want.
for await (const team of projex.teams.iterate(cohortId)) {
  console.log(team.name);
}

await projex.teams.create(cohortId, {
  name: 'Team Rocket',
  description: 'Optional',
  coverImage: null,
});

await projex.teams.update(teamId, { name: 'Team Rocket II' });
// Also accepts: description, overview, coverImage.

// Soft-deletes the team and its tasks, submissions, members and staff. Its
// learners stay enrolled in the cohort but end up on no team.
await projex.teams.delete(teamId, { dryRun: true });
// → { dryRun: true, impact: { learners: 3, staff: 1 } }
create throws code: 'conflict' (409) with details.reason: 'individual_cohort' in an individual cohort, where the solo teams are made for you.

projex.teams.members

Scope: team-members:write. The role picks the roster — learner goes to members, mentor and evaluator to staff — and staff are enrolled in the cohort at that role first if they aren’t already.

await projex.teams.members.add(teamId, {
  email: 'ada@example.com',
  role: 'learner' | 'mentor' | 'evaluator',  // default: learner
  name: 'Ada Lovelace',
  notify: true,
});
// → the same ProvisioningSummary as cohorts.members.add

await projex.teams.members.changeRole(teamId, {
  email: 'ada@example.com',
  role: 'mentor',
});
// → { userId, teamId, teamRole }

// Removes whichever seat they hold. Cohort enrolment is untouched.
await projex.teams.members.remove(teamId, { email: 'ada@example.com' });
// → { ok: true, userId, removed: 'learner' | 'staff' }
A team role must agree with the person’s cohort role. Mismatch throws code: 'conflict' (409) with details.reason: 'role_locked_to_cohort' — change the cohort role via cohorts.members.changeRole first. Someone who isn’t in the cohort at all gives details.reason: 'not_cohort_member'.

Errors & retries

Failed HTTP responses throw ProjexApiError, a subclass of Error with structured fields for handling and support correlation.

PropertyTypeMeaning
statusnumberHTTP status code
codestringStable machine-readable error code from the API envelope
messagestringHuman-readable explanation
requestIdstring?Correlation id — include when contacting support
detailsunknown?Optional structured details (e.g. Zod flatten)

Error codes

codeHTTPWhen it happens
invalid_api_key401Missing, malformed, expired, revoked, or disabled key
insufficient_scope403Key lacks a scope required by the endpoint
not_found404Unknown cohort, milestone, or task (or no access)
ambiguous_team409Milestone submit without teamId while user is on multiple teams
conflict409Administration methods: the call clashes with current state. Branch on details.reason — individual_cohort, role_locked_to_cohort, not_cohort_member, user_banned
validation_error422Invalid body, disallowed content type, or closed submission window
rate_limited429Too many requests — SDK retries with backoff
internal_error500Unexpected server failure — SDK retries
import { ProjexApiError } from '@pkg-projex/sdk';

try {
  await projex.milestones.submit(ref, input);
} catch (err) {
  if (err instanceof ProjexApiError) {
    if (err.code === 'ambiguous_team') {
      // Prompt for teamId from cohorts.get(...).teams
    }
    console.error(err.status, err.code, err.message, err.requestId);
  }
  throw err;
}
Retries apply only to 429 and 5xx. Client errors (4xx other than rate limits) fail immediately so invalid payloads are not repeated.

Identifiers

Public refs are stable, human-readable identifiers used across the product (including GitHub webhook linking). Always prefer values returned by list endpoints over constructing refs by hand.

ResourceFormExampleNotes
TaskTS{n}TS42Global serial public id
MilestoneT{team}M{ms}T12M3Team public id + milestone public id
CohortUUIDFrom cohorts.list / liveNo separate public slug in v1

For interactive HTTP exploration see the API reference (Swagger).